std/vendor: Vendoring Is for Patching, So the Pin Has to Remember Two Trees
Nobody vendors a library for fun. You copy someone else’s source into your own repository because you had to change it — a bug they will not fix on your schedule, a platform they do not support, an assumption that does not hold where you work. Vendoring is a fork you are honest about.
That is the use case, and it is worth saying out loud, because a dependency system that treats vendored code as an archive to be preserved has misunderstood why the directory exists.
What is built
A binding names a module and the local tree that supplies it:
import std/io
import std/vendor
std/vendor:bindings {
koru/vaxis: ./vendored/vaxis
}
import koru/vaxis
koru/vaxis:greet(): v |> std/io:print.ln("{{ v:s }}") That block does two jobs. It records a SHA-256 for every file in the tree, plus
one hash over the sorted manifest, in a vendor.lock:
{
"version": 1,
"bindings": {
"koru/vaxis": {
"path": "./vendored/vaxis",
"tree": "sha256:dd9d7ba90445ff54bd899653cb861c3ced82c4fab430ff0c8baebc7d9d4615ae",
"files": {
"README.md": "38ffe71173859c406fd866ae1ff95d74892462eeff098f0139a73a982831366e",
"main.zig": "4531d2679a5a06dfc599bff91581e2e0d32d6f8fa0c44bef90fc0562f7540939"
}
}
}
} And it makes koru/vaxis resolve to that directory, which is why the import on
the line below it works with no other configuration in the project. The folder is
named once. Until recently it had to be named twice — once to pin it, once in a
separate config file to make it importable — with nothing keeping the two equal.
Two lists and one truth is not a small inconvenience; it is a state in which you
can fingerprint one directory while compiling another.
The check is not a command
The obvious design is vendor audit — something you run in CI, or before a
release, or when you remember. Every ecosystem has one. An audit nobody runs is
how compromised packages get through, and “nobody ran it” is not a rare
failure; it is the normal condition of a command separate from the thing it
protects.
So the check is not a command. It is a comptime transform attached to the declaration. You cannot forget to run it, because running it is compiling. There is no state in which the program builds and its dependencies are unverified. When everything matches, the declaration emits nothing — the check is its entire contribution, paid once, at compile time.
Patching is the supported path, and it looks like this
This is the part most easily misread, including by the person who wrote the first draft of this post. The refusal is a speed bump, not a lock. Here is a vendored library being changed on purpose:
upstream vaxis, exactly as shipped ← the pinned copy, running
... edit vendored/vaxis/index.kz by hand ...
error[KORU171]: vendored `koru/vaxis` drifted from its pin: changed `index.kz`.
Review it with `vendor diff`, then re-pin with `vendor sync`
once you have read the change
... koruc input.k vendor sync ...
MY PATCH — I fixed their bug myself ← the edit, live Two commands: change it, then say you meant it. The refusal exists so a change
cannot pass without a human looking at it exactly once. The diagnostic routes you
through diff before sync deliberately — a tool that offers “fix it” next to
“look at it” gets “fix it” pressed.
Two trees, so accepting a patch cannot erase it
A single recorded manifest cannot survive this. The copy as it arrived hashes to
one value; after sync the lock holds a different one, and the first is gone.
Your patch becomes invisible, a deliberate fix and a tampered file become the
same object, and diff can only ever compare against the thing your patch
already became.
So a binding records two trees. as-copied is what arrived from upstream and
is never overwritten. as-compiled is what your program is built from. sync moves the second and cannot touch the first — the only writer of the copied side
is the command that acquires the tree, because acquiring is the only moment
anyone knows the answer.
"is_number": {
"path": "./vendored/is-number",
"origin": { "source": "npm", "package": "is-number", "version": "7.0.0",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+..." },
"as-copied": { "tree": "sha256:47b583bca97d...", "files": { "...": "..." } },
"as-compiled": { "tree": "sha256:a35734ab2d44...", "files": { "...": "..." } }
} The difference between those two trees is your fork — computed rather than remembered, and available forever instead of until the next re-pin:
koru/vaxis -> ./vendored/vaxis sha256:a35734ab2d44 (4 files)
upstream is-number@7.0.0 via npm
patch 1 file(s) differ from upstream
changed index.js Acquiring, without becoming a package manager
The verb that fills all of this in:
koruc app.k vendor add is-number It resolves the package through npm, downloads the published tarball, recomputes npm’s own integrity hash over the bytes that actually arrived and refuses before anything touches your tree if they disagree, extracts it, and pins both sides at once.
What it deliberately does not do is reimplement npm. No registry client, no version-range resolution, no walking a dependency’s own dependencies. npm acquires; Koru keeps, pins, and diffs — which is the part npm has no answer for. The ecosystem’s answer to “I patched a dependency” is a bolt-on that stashes a diff and reapplies it at install time. Here the fork is a first-class object the compiler already checks on every build.
One detail worth knowing: an npm name is not a Koru module name. is-number is
ordinary on the registry and illegal as an import alias, which is a bare
identifier. The directory keeps the real package name and only the module is
normalized — and the mapping between them is the binding line the command prints
for you to paste.
What this does not do
It pins vendored source — trees in your repository, named in a bindings
block. A dependency you merely declare with std/package:requires.npm still
carries no pin: that path writes a package.json and lets npm install whatever a
version range resolves to, and nothing checks the result.
So the guarantee has a shape worth stating plainly. Bringing a package in through vendor add puts it under the pin. Leaving it as a declared requirement does not.
This post is not a claim that Koru secures your dependency graph; it is a claim
that Koru gives you one way to take a dependency where the bytes are yours, the
change is refused unless you look at it, and your patch is a thing the toolchain
can still show you a year later.
The unpinned half is the obvious next piece, and it is not built.
The doctrine
This is the rule the rest of Koru runs on, pointed at the dependency graph: the real surface, or a loud failure. A dependency that changed under you is not a warning, not a log line, and not something an audit might catch on Tuesday. It does not compile.
The correction this post makes to that doctrine is small and it matters: changed under you was never meant to include changed by you, on purpose, for a reason you could state. That case is the point of vendoring. It deserves to be recorded, not merely permitted.