a73x

9e7707c0

build: every grant spelled, test twins enforce the column, shell gate

a73x   2026-08-14 12:45

Commit message
build: every grant spelled, test twins enforce the column, shell gate

Three review findings against the gates themselves.

The table carried one edge no source uses — exe granted "cmd", dead
since its call site left main.zig, carried faithfully by the
extract-and-diff (a pure refactor preserves mistakes too). Dropped, and
the class closed: checkGrantsUsed reads each row's root at
graph-construction time and refuses every build — not just the gate —
when a declared import is never spelled as @import("name") there. Sound
because every row is a single-file module; the stated limit is that
text cannot tell which column a use belongs to, which is the twin's
job. The table stands at 75 edges.

test_imports was documentation wired identically to production — "test
scaffolding never ships" held only because nobody had referenced
testtmp outside a test block. Now the eight rows with test grants get a
test twin: a second module instance carrying the test imports, used
only by the test builds, importing production dependency instances so
no compilation holds two instances of one row; the other twenty-four
rows alias. A production reference to scaffolding is now a compile
error, verified both ways: an unreferenced container-scope alias stays
silent (never analyzed, i.e. never shipped), a reached one fails with
"no module named 'testtmp'". Only exe's build_options needed
hand-wiring onto its twin.

And the scripts that ARE the e2e/agent/soak gates get the same floor as
the code they check: sh -n over every .sh in test/, tools/ and
deps/quic under the check umbrella, globbed and sorted so nothing needs
a list edit, each script a file arg so its contents hash into the
graph; shellcheck at error severity when installed, silently absent
when not. The check step's comment now states its compile coverage
exactly (every row builds as a test binary, webhub_main's @embedFile
drags in the wasm build; the exe link itself belongs to zig build).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

build.zig
Old New
@@ -72,6 +72,15 @@ fn linkQuic(b: *std.Build, c: *std.Build.Step.Compile, deps: anytype) void {
72 c.linkSystemLibrary2("wolfssl", .{ .preferred_link_mode = .static }); 72 c.linkSystemLibrary2("wolfssl", .{ .preferred_link_mode = .static });
73 } 73 }
74 74
75 /// Fail at build-graph-construction time with one clean line. The hygiene
76 /// invariants below are not opinions a gate step samples: a violation has
77 /// to stop every build, the way the comptime layer checks do, so the graph
78 /// simply refuses to be built.
79 fn fatal(comptime fmt: []const u8, args: anytype) noreturn {
80 std.debug.print("build.zig: " ++ fmt ++ "\n", args);
81 std.process.exit(1);
82 }
83
75 /// One row of the module table: the import graph as declared data. 84 /// One row of the module table: the import graph as declared data.
76 /// The wiring loop below derives every addImport from it, so no 85 /// The wiring loop below derives every addImport from it, so no
77 /// table-module import can exist except through this loop — violations 86 /// table-module import can exist except through this loop — violations
@@ -87,10 +96,11 @@ const ModSpec = struct {
87 layer: u8, 96 layer: u8,
88 /// Production imports: must point at a strictly lower layer. 97 /// Production imports: must point at a strictly lower layer.
89 imports: []const []const u8 = &.{}, 98 imports: []const []const u8 = &.{},
90 /// Test-only imports (the testtmp pattern): wired identically — lazy 99 /// Test-only imports (the testtmp pattern): granted ONLY to the row's
91 /// compilation keeps them out of release binaries — but declared 100 /// test twin — the second module instance `addTest` compiles — so the
92 /// apart, because "test scaffolding never ships" is a stated rule. 101 /// production instance every exe and every importer sees has no such
93 /// Excluded from the strata computation. 102 /// module available. "Test scaffolding never ships" is therefore a
103 /// compile error, not a convention. Excluded from the strata computation.
94 test_imports: []const []const u8 = &.{}, 104 test_imports: []const []const u8 = &.{},
95 link_libc: bool = false, 105 link_libc: bool = false,
96 /// Also instantiated against wasm32 (the muxweb core's twins). 106 /// Also instantiated against wasm32 (the muxweb core's twins).
@@ -229,7 +239,7 @@ const mod_table = [_]ModSpec{
229 // the sun_path bound, checked before any verb acts on the path; and the 239 // the sun_path bound, checked before any verb acts on the path; and the
230 // keygen round-trip test needs a directory to generate into, which the 240 // keygen round-trip test needs a directory to generate into, which the
231 // daemon itself never touches. 241 // daemon itself never touches.
232 .{ .name = "exe", .path = "src/main.zig", .layer = 3, .link_libc = true, .imports = &.{ "server", "protocol", "cmd", "proxy", "quic", "quic_server", "xdg", "spawn", "handoff", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 242 .{ .name = "exe", .path = "src/main.zig", .layer = 3, .link_libc = true, .imports = &.{ "server", "protocol", "proxy", "quic", "quic_server", "xdg", "spawn", "handoff", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
233 // ---- layer 4 ---- 243 // ---- layer 4 ----
234 // The HOST tile's recipe comes from the same owner mux_main uses, and 244 // The HOST tile's recipe comes from the same owner mux_main uses, and
235 // sockpath is the sun_path bound its --sock tiles are refused against. 245 // sockpath is the sun_path bound its --sock tiles are refused against.
@@ -278,6 +288,91 @@ comptime {
278 } 288 }
279 } 289 }
280 290
291 /// Every grant the table hands out must be one the source asked for: for
292 /// each name in a row's `imports` / `test_imports`, that row's root source
293 /// file has to contain `@import("<name>")`. The layer rules cannot see a
294 /// stale grant — an edge nobody uses violates no direction — so a grant
295 /// outlives the code that needed it silently, and the table stops being a
296 /// description of the program. (Exactly how `exe` kept `cmd` after main.zig
297 /// stopped importing it; found in review 2026-08-14, and this is the check
298 /// that would have refused the build instead.)
299 ///
300 /// Textual on purpose, and sound because of a property of this table: every
301 /// row is a single-file module — no row's source pulls in a sibling by
302 /// relative path — so the root file is the only file that can spell the
303 /// import. What the text cannot tell is WHICH column a used import belongs
304 /// in: a production grant referenced only inside a `test` block still reads
305 /// as used here. Separating the columns is the test twin's job in build(),
306 /// not this scan's.
307 fn checkGrantsUsed(b: *std.Build) void {
308 for (&mod_table) |spec| {
309 const src = b.build_root.handle.readFileAlloc(b.allocator, spec.path, 4 << 20) catch |err|
310 fatal("module table: cannot read {s} ({s})", .{ spec.path, @errorName(err) });
311 for ([_][]const []const u8{ spec.imports, spec.test_imports }, 0..) |col, which| {
312 for (col) |dep| {
313 if (std.mem.indexOf(u8, src, b.fmt("@import(\"{s}\")", .{dep})) == null) fatal(
314 "module table: row '{s}' grants {s} '{s}', but {s} never " ++
315 "writes @import(\"{s}\") — delete the stale grant; the " ++
316 "table is the program's import graph, not a wish list",
317 .{ spec.name, if (which == 0) "import" else "test_import", dep, spec.path, dep },
318 );
319 }
320 }
321 }
322 }
323
324 /// The gates that are shell get a gate of their own. test/*.sh and tools/*.sh
325 /// ARE the e2e, agent, soak and valgrind checks; nothing type-checks them, so
326 /// an unbalanced quote turns a gate into a script that dies on line 3 — and a
327 /// suite that never ran is the one failure mode a green tree cannot show.
328 /// `sh -n` parses without executing a single line, so the whole set costs
329 /// milliseconds. Globbed, never listed: a script added tomorrow is covered by
330 /// this step without anybody remembering to add it. shellcheck runs too when
331 /// it happens to be on PATH, at error severity only — a linter that is not
332 /// installed everywhere must never be the difference between a green tree and
333 /// a red one, and style opinions are not what this gate is for.
334 fn shellGate(b: *std.Build, step: *std.Build.Step) void {
335 var paths: [64][]const u8 = undefined;
336 var n: usize = 0;
337 for ([_][]const u8{ "test", "tools", "deps/quic" }) |sub| {
338 var dir = b.build_root.handle.openDir(sub, .{ .iterate = true }) catch |err|
339 fatal("shell gate: cannot open {s}/ ({s})", .{ sub, @errorName(err) });
340 defer dir.close();
341 var it = dir.iterate();
342 while (it.next() catch |err|
343 fatal("shell gate: cannot list {s}/ ({s})", .{ sub, @errorName(err) })) |ent|
344 {
345 if (ent.kind != .file or !std.mem.endsWith(u8, ent.name, ".sh")) continue;
346 if (n == paths.len) fatal("shell gate: more than {d} scripts", .{paths.len});
347 paths[n] = b.fmt("{s}/{s}", .{ sub, ent.name });
348 n += 1;
349 }
350 }
351 // Directory order is whatever the filesystem feels like; sort so the step
352 // names and the shellcheck argv are identical on every machine.
353 std.mem.sort([]const u8, paths[0..n], {}, struct {
354 fn lt(_: void, a: []const u8, c: []const u8) bool {
355 return std.mem.lessThan(u8, a, c);
356 }
357 }.lt);
358 for (paths[0..n]) |p| {
359 const run = b.addSystemCommand(&.{ "sh", "-n" });
360 // As a FILE arg, not a string: the script's CONTENTS are then part of
361 // what the build graph hashes, the lesson web/verify.js paid for.
362 run.addFileArg(b.path(p));
363 run.setName(b.fmt("sh -n {s}", .{p}));
364 run.expectExitCode(0);
365 step.dependOn(&run.step);
366 }
367 if (b.findProgram(&.{"shellcheck"}, &.{})) |sc| {
368 const run = b.addSystemCommand(&.{ sc, "--severity=error" });
369 for (paths[0..n]) |p| run.addFileArg(b.path(p));
370 run.setName("shellcheck (severity=error)");
371 run.expectExitCode(0);
372 step.dependOn(&run.step);
373 } else |_| {}
374 }
375
281 /// Test registration order. Doctrine-laden and deliberately NOT derived 376 /// Test registration order. Doctrine-laden and deliberately NOT derived
282 /// from the layers: delta, cmd, shellint and sockpath run BEFORE server 377 /// from the layers: delta, cmd, shellint and sockpath run BEFORE server
283 /// because their tests are seconds-long and socket-free, while a 378 /// because their tests are seconds-long and socket-free, while a
@@ -339,18 +434,39 @@ pub fn build(b: *std.Build) void {
339 unreachable; 434 unreachable;
340 } 435 }
341 }; 436 };
437 // No grant outlives its @import (and the message names the row).
438 checkGrantsUsed(b);
439
440 // Two instances per row that has test grants: `mods[i]` is production —
441 // what the exes are built from and what every importer sees — and
442 // `test_mods[i]` is the twin `addTest` compiles, which is the only one
443 // the test_imports column reaches. That is what makes "test scaffolding
444 // never ships" mechanical: a production function reaching for testtmp
445 // fails to compile in every binary that reaches that function. (An
446 // unreferenced `const TmpDir = @import("testtmp").TmpDir;` at container
447 // scope stays silent, because Zig never analyzes it — which is the same
448 // statement: it did not ship.) Rows without test grants alias the
449 // production instance, so the graph grows by exactly the eight rows that
450 // use the pattern, and the twins import PRODUCTION deps — a dependency's
451 // own test grants are its own test binary's business.
342 var mods: [mod_table.len]*std.Build.Module = undefined; 452 var mods: [mod_table.len]*std.Build.Module = undefined;
453 var test_mods: [mod_table.len]*std.Build.Module = undefined;
343 for (&mod_table, 0..) |spec, i| { 454 for (&mod_table, 0..) |spec, i| {
344 mods[i] = b.createModule(.{ 455 const opts: std.Build.Module.CreateOptions = .{
345 .root_source_file = b.path(spec.path), 456 .root_source_file = b.path(spec.path),
346 .target = target, 457 .target = target,
347 .optimize = optimize, 458 .optimize = optimize,
348 .link_libc = if (spec.link_libc) true else null, 459 .link_libc = if (spec.link_libc) true else null,
349 }); 460 };
461 mods[i] = b.createModule(opts);
462 test_mods[i] = if (spec.test_imports.len == 0) mods[i] else b.createModule(opts);
350 } 463 }
351 for (&mod_table, 0..) |spec, i| { 464 for (&mod_table, 0..) |spec, i| {
352 for (spec.imports) |dep| mods[i].addImport(dep, mods[idx.of(dep)]); 465 for (spec.imports) |dep| mods[i].addImport(dep, mods[idx.of(dep)]);
353 for (spec.test_imports) |dep| mods[i].addImport(dep, mods[idx.of(dep)]); 466 if (test_mods[i] != mods[i]) {
467 for (spec.imports) |dep| test_mods[i].addImport(dep, mods[idx.of(dep)]);
468 for (spec.test_imports) |dep| test_mods[i].addImport(dep, mods[idx.of(dep)]);
469 }
354 } 470 }
355 471
356 // -Dgraph: dump the declared edges for the extract-and-diff proof. 472 // -Dgraph: dump the declared edges for the extract-and-diff proof.
@@ -382,6 +498,10 @@ pub fn build(b: *std.Build) void {
382 mux_mod.addImport("build_options", version_opts.createModule()); 498 mux_mod.addImport("build_options", version_opts.createModule());
383 exe_mod.addImport("build_options", version_opts.createModule()); 499 exe_mod.addImport("build_options", version_opts.createModule());
384 webhub_main_mod.addImport("build_options", version_opts.createModule()); 500 webhub_main_mod.addImport("build_options", version_opts.createModule());
501 // exe is the one hand-wired row that owns a test twin, and build_options
502 // is outside the table's jurisdiction — so the twin needs it by hand, or
503 // main.zig's tests lose the version their argument parser prints.
504 test_mods[comptime idxOf("exe")].addImport("build_options", version_opts.createModule());
385 505
386 const exe = b.addExecutable(.{ .name = "muxd", .root_module = exe_mod }); 506 const exe = b.addExecutable(.{ .name = "muxd", .root_module = exe_mod });
387 // Zig 0.15's self-hosted x86_64 linker can't handle the .sframe 507 // Zig 0.15's self-hosted x86_64 linker can't handle the .sframe
@@ -497,7 +617,7 @@ pub fn build(b: *std.Build) void {
497 const test_step = b.step("test", "Run unit tests"); 617 const test_step = b.step("test", "Run unit tests");
498 for (test_order) |name| { 618 for (test_order) |name| {
499 const i = idx.of(name); 619 const i = idx.of(name);
500 const t = b.addTest(.{ .root_module = mods[i] }); 620 const t = b.addTest(.{ .root_module = test_mods[i] });
501 t.use_llvm = true; 621 t.use_llvm = true;
502 t.use_lld = true; 622 t.use_lld = true;
503 // quic_tests is also what makes `make test` build the QUIC deps on 623 // quic_tests is also what makes `make test` build the QUIC deps on
@@ -585,9 +705,19 @@ pub fn build(b: *std.Build) void {
585 const fmt = b.addFmt(.{ .paths = &.{ "build.zig", "build.zig.zon", "src", "test" }, .check = true }); 705 const fmt = b.addFmt(.{ .paths = &.{ "build.zig", "build.zig.zon", "src", "test" }, .check = true });
586 fmt_step.dependOn(&fmt.step); 706 fmt_step.dependOn(&fmt.step);
587 707
588 // The seconds-long pre-commit gate: fmt + unit tests. e2e/agent/soak 708 // The seconds-long pre-commit gate: fmt + unit tests + the shell scripts'
589 // stay separate on purpose — they are minutes-long and process-spawning. 709 // syntax. e2e/agent/soak stay separate on purpose — they are minutes-long
590 const check_step = b.step("check", "fmt + unit tests — the pre-commit gate"); 710 // and process-spawning.
711 //
712 // It does not depend on the default install step, and does not need to:
713 // every table row is built as a test binary here, and webhub_main's
714 // @embedFile of mux_core.wasm drags the wasm build in with it, so the
715 // compile coverage this gate gives is every module in the table plus the
716 // wasm core. What it does NOT cover is the executable wiring itself —
717 // linking muxd/mux/muxa/muxweb, the QUIC archives included — which is
718 // `zig build` (and e2e, which runs the binaries).
719 const check_step = b.step("check", "fmt + unit tests + shell syntax — the pre-commit gate");
591 check_step.dependOn(fmt_step); 720 check_step.dependOn(fmt_step);
592 check_step.dependOn(test_step); 721 check_step.dependOn(test_step);
722 shellGate(b, check_step);
593 } 723 }