The Package System That Was Mostly Already There
A language that compiles itself and runs in a browser still couldn't be shared: there was no way to depend on someone else's code. The fix came from a real consumer, working backwards from its pain — and the surprise was how little of it was new. The resolver already resolved cross-package imports correctly; what was missing wasn't language or semantics but tooling. A manifest, a lockfile, and a way to vendor the runtime host turned a released binary into something that could both build and run an app with no compiler checkout at all.
By the end of the eighth part, Mere could do a surprising amount: compile itself, run in a browser, serve HTTP, talk to Postgres, hold four backends in exact agreement. And yet it could not do the most ordinary thing a language is expected to do — let one program depend on another that someone else wrote. There was no way to say “I need this library” and have it appear. That gap doesn’t show up while you are building the compiler, because the compiler is one tree you own entirely. It shows up the moment you try to build a second thing on top of the first.
The shape came from a consumer, not a design
The temptation with a package system is to design it in the abstract — registries, version ranges, resolution algorithms — and arrive at something large before anyone has used it. Mere took the opposite route. A real application, a small collaborative notes server written in Mere, was made to depend on the compiler’s own libraries, and the requirements were read off the pain that produced.
The pain was concrete. There was no install step, so dependencies were copied by hand
out of the monorepo. There was no way to pin a version, so what you actually depended
on was written down in prose in a VENDOR.md and immediately drifted. And the names
collided: the libraries imported each other with relative paths, and the on-disk
directory names the vendoring produced didn’t match the names those imports expected,
so cross-package imports broke. Three problems, each one a sentence, each one from a
program that actually existed. That list was the specification.
The resolver was already enough
The instructive part came next. Having listed the requirements, the natural assumption was that satisfying them meant changing the language — a new import syntax, a resolution pass, compiler support for versions. It didn’t. The resolver already in place resolved everything correctly.
That resolver, from the first version of the package system, walks up from an
importing file toward the filesystem root looking for a .mere_modules/ directory,
exactly the way Node walks up looking for node_modules. Once it finds one, an import
of "pkg/module.mere" reads .mere_modules/pkg/module.mere, and nested imports from
inside a vendored module walk up from their location and find the same top-level
directory. Cross-package imports already Just Worked, as long as everything lived
under one project root. The dogfood had proven this by accident. So the whole of the
new work was not semantics but tooling: something that populated .mere_modules/
correctly, and then got out of the way. The compiler doesn’t care how the files got
there.
A manifest and a lockfile, and nothing else
What that tooling needed was small. A manifest — mere.toml — declares a package and
its dependencies, each dependency a git repository at an exact commit, optionally a
subdirectory of that repository so a single monorepo can host many packages. mere install reads the manifest, fetches each dependency at its pinned commit, follows the
relative imports between packages to pull in the transitive ones too, and writes them
into .mere_modules/. Then it records what it did in a mere.lock — the resolved
full commit hash for each package plus a content hash over the whole set — so a second
install reproduces the first exactly.
The deliberate omissions are as important as what’s there. A dependency pins an exact commit, not a version range, so there is no resolution to do — no satisfiability search, no diamond to solve. If two packages want different revisions of a shared dependency, the walk-up decides, and the fix is to sort it out at the deployment layer rather than in a solver. There is no central registry. These are not gaps waiting to be filled on a schedule; they are things left unbuilt until a program needs them, which is the same discipline the rest of the language was built on.
The naming knot
One of the three original pains deserves its own note, because it is the kind of
detail that quietly decides whether the whole thing works. A registry naturally wants
namespaced names — mere-http, mere-db — so that packages from different authors
don’t collide. But the libraries themselves import each other by bare relative paths,
written when they lived side by side in one tree: ../log/log.mere, not
../mere-log/log.mere. If the installer vendored a package into a directory called
mere-log, every relative import that expected log would break.
The resolution was to separate the two naming worlds. The name on disk, and the name
in an import, is the bare one — http, db, log — so that .mere_modules/ mirrors
the source tree and every relative import keeps resolving. The mere- prefix lives
only in the repository coordinate: it is where the package is fetched from, the
future standalone repo name or the monorepo subdirectory, and it never appears in a
path the compiler sees. The manifest is the one place the two are tied together, which
is exactly where a mapping like that belongs.
Shipping the runtime, not just the source
There was a second gap, adjacent to the first, that a package system for source code
doesn’t obviously cover. A compiled Mere program is a .wasm file, and a .wasm file
does nothing on its own: it imports functions like puts, read_file, http_serve
from its host, and those host functions are small pieces of JavaScript that live in
the compiler’s own repository. So even with every source dependency vendored, a
compiled app still couldn’t run without a checkout of the compiler — which defeats
much of the point of compiling it.
The fix extends the manifest with a [host] section that fetches those host pieces
too, flattening their internal references so the bundle stands alone in a
.mere_host/ directory, and adds a mere serve that runs a compiled server against
it. The consequence is the one that matters: a released mere binary plus mere install is now enough to both build an application and run it, with no compiler
source tree present at runtime at all. Distribution, it turned out, was never only
about source; the runtime host had to travel with it, and once it did, the loop
closed.
What the smallness bought
The whole package system is intentionally minor — no registry, no version solver, an install that fetches exact commits and a resolver that was already written. It would be easy to read that as unfinished. It is better read as the same restraint the series has practiced throughout: build the smallest thing that removes a real, present pain, and let the shape of it be dictated by a program that actually needs it rather than by an idea of what a package system is supposed to contain. The consumer wrote the specification. The language, mostly, already satisfied it. What remained was to notice that, and to build only the rest.