std/vendor: Vendoring Is for Patching, So the Pin Has to Remember Two Trees

· 9 min read

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.