9e7707c0
build: every grant spelled, test twins enforce the column, shell gate
a73x 2026-08-14 12:45
Commit message
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 | } |