An Experiment in Heterogeneous Software Builds

Pick two real things you build at work. A Node HTTP service has a runtime version, an entrypoint, npm dependencies, test commands, a health endpoint. A C library with generated code has a compiler, headers, generated config, pkg-config resolution, link steps. They share no tools, no dependency model, no output shape, yet both are builders, and each one grows the same accessories. A validation pass, a dry-run flag whose semantics drift from the next builder’s dry-run flag, its own way to hand dependencies downstream. Build-system theory has a great deal to say about engines and much less about what relates these descriptions to one another. I go into this in depth in the long-form write-up, and metaBuilder is the system I built to test one answer.

metaBuilder is a typed builder DSL in pure Nix, where a build is a description, typed records for parameters, sources, dependencies, tools, and outputs together with a list of typed operations, and that one description is read by several interpreters. Everything runs at nix eval time. 0.1.0 is out, MIT.

git clone https://github.com/kleisli-io/metaBuilder
cd metaBuilder
nix flake check  # takes about 10 minutes, mostly silent

The “operations” are things like “read this source”, “declare this tool”, “run this tool with these arguments”, “materialize a derivation”. The list is the program, and the bundled interpreters fold over it.

mb.examples.node-service.validation.ok          # true
mb.examples.node-service.deps.nodes             # dependency graph
mb.examples.node-service.dryRun.steps           # the steps, no derivations produced
mb.examples.node-service.planView.shell         # the shell that would run, as a string
mb.examples.node-service.docs.markdown          # builder self-documentation
mb.examples.node-service.materialize.derivation # an ordinary pkgs.runCommand

materialize.derivation is a real derivation you can nix-build; the others cost no builds. There’s also plan-export (the plan as plain data), introspect (a meta-interpreter that composes the views into one document), and two backends that translate a finished plan off the Nix substrate entirely. One renders a standalone bash script, the other a Dockerfile.

So far that is one builder kind; the experiment is what happens across kinds. A Node HTTP service, a C library with generated sources, an OCI container image, and a protobuf IDL builder have nothing structural in common at the domain level, yet all four ship as typed refinements of one base BuilderSpec, using ornaments (from dependently-typed programming, McBride 2011). Each refinement adds typed domain fields and comes with a generated forget map back to the base, so the interpreters are written once against the base and apply to every builder kind unchanged. N builder kinds and k interpreters cost N + k implementation sites instead of N × k.

Whether ornaments are the right (not really a question that has an objective answer) account of how heterogeneous build descriptions relate is a conjecture we have not settled. The module system, record subtyping, and Bazel-style Providers are rival accounts of how one can do it. The system assumes ornaments are the right account, and we want to find out where that assumption breaks. The write-up has the full version of that argument, including what drv-parts and dream2nix, the closest predecessors in spirit, already do well. What’s different here is a second, third, and fourth interpretation running over the same description.

Now, what makes any of this possible is the levitated kernel of nix-effects, the dependent type checker in pure Nix I posted a while back. Levitation (Chapman, Dagand, McBride, Morris 2010) makes datatype descriptions first-class data inside the theory, so the kernel computes over the descriptions of its own datatypes. metaBuilder’s typed records are generated from descriptions, the schemas and documentation are derived from the same descriptions, and ornaments are themselves defined over descriptions, forget maps included. The kernel grew into this over a series of releases this spring. First a weak levitation theorem, then strong levitation, where the description type reifies through its own description, and now a public data layer generated entirely by the machinery it describes.

Since the kernel is a proof checker, the coherence claims come with more than tests. A kernel theorem proves that materialize and dry-run agree on the constructor structure of their step sequences, for every program, by induction over the program. Payloads, the shell renderer, and the store sit outside the theorem by design. An extraction drift gate checks that the proven fold reproduces the live interpreter, and for each shipped builder kind the kernel verifies that forget computes the base spec and that program compilation factors through forget. A sandboxed test builds the C example’s derivation, runs the rendered plan-view shell with the plan’s tool closure, and diffs the outputs byte for byte. nix-unit tests run in nix flake check; the kernel-heavy checks (long normalizations, minutes to hours) live in a separate suite behind nix-unit --flake .#tests-heavy.

The examples are real artifacts in that they produce derivations that Nix can realize. The node-service builder materializes an HTTP server with /health, /version, and /metrics endpoints, the c-codegen builder generates sources from a checked-in schema and links a static library plus a CLI, and the OCI builder assembles a runnable busybox image digest by digest in build-time bash with no container tooling. The image it produces runs under podman directly and reaches docker through skopeo.

There are of course limits. For one (which is also the point), metaBuilder runs at nix eval time only. The operations describe build-time steps as data, but what happens inside pkgs.runCommand once it runs is opaque to us; crossing into the store to watch a build execute would mean reinventing Nix. It’s not a build engine either. Nix is. Incrementality, scheduling, and caching are delegated to it, and metaBuilder is a description language that runs inside it. Dependency analysis is per-spec, not whole-system. The ornaments we ship use only the forgetful fragment of the ornament algebra; the index-changing machinery is in the kernel but unused. Additionally, 0.1.0 has rough edges, the describe output format will change, sandbox backend coverage is uneven, and the ornament catalogue is still growing.

To use it from a flake.

{
  inputs.metaBuilder.url = "github:kleisli-io/metaBuilder";
  # mb = metaBuilder.lib.mkMb pkgs
}

Is all this overengineering… maybe, but it was great fun :slight_smile:

2 Likes