Artifact 0035d4ce262481ee7d4ca8acee81611900afc6cd24436e91c64bb39f7cf427e6:
- File
build.zig
— part of check-in
[7363238ad5]
at
2026-07-31 02:43:47
on branch trunk
— build: split the unit lane from the full gate
zig build test was 9m13s, and the tests it actually ran were a small fraction of that: it also compiled the e2e and smoke harnesses in the pinned ReleaseSafe and then never executed them. test is now unit tests only -- 2m54s cold, 1m42s warm -- and the new gate step carries those two compile proofs.
The dst, bench and smoke UNIT tests deliberately stay on test. They are unit tests despite their siblings' names, zig build dst/smoke run the simulation and the interop check rather than them, and CI invokes the lanes one by one and never invokes gate -- so moving them would have dropped them from CI while every step still looked green. ci_test's declared_steps records gate as intentionally not-in-CI, and names that failure mode for the next lane someone adds. (user: dmestas, size: 18831) [annotate] [blame] [check-ins using]
const std = @import("std"); /// Config threaded into each per-artifact builder: target/optimize choice plus /// the single `zit` library module every root imports. storage, logstore, /// checkout, and update are folded into that one module (re-exported from /// root.zig), so there are no separate store peers to wire. const Env = struct { b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, mod: *std.Build.Module, }; pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); // `--release` means ReleaseSafe here, and that is a correctness decision // rather than a preference. src/ carries over a thousand `std.debug.assert` // calls, which are the mechanism by which a detected inconsistency becomes a // crash instead of a durable write: TigerStyle's rule is that assertions // "downgrade catastrophic correctness bugs into liveness bugs", and that // trade only exists while the assertion does. ReleaseFast and ReleaseSmall // compile every one of them to nothing, so a binary built that way silently // converts each guarded corruption into an unguarded one — in an append-only // store, where a bad frame is permanent and a crash is not. // // Two consequences, both measured rather than assumed: // // Stating a preference REPLACES `-Doptimize=<mode>` with `-Drelease=<bool>` // in the option surface. The explicit escape hatch is the top-level // `--release=fast`, not `-Doptimize=ReleaseFast`, which no longer parses. // // The cost is NOT uniform, and "a few percent" would be wrong. Read-side // verbs are indistinguishable between the two modes (status/log/stat all // inside noise on a 5 MB store). `zit pack` is not: 599 ms fast against // 15.2 s safe on that same store, ~25x, because the delta encoder's // best-of-N search is a tight per-byte loop where a bounds check is a large // fraction of the work. That is an argument for `@setRuntimeSafety(false)` // on the measured inner loop — a local, reviewable opt-out over code the // fuzz and DST lanes already cover — and NOT an argument for stripping the // assertions from every other path in the binary. // // `zig build bench` forces ReleaseFast on its own executable regardless, // because a benchmark must measure optimized code. const optimize = b.standardOptimizeOption(.{ .preferred_optimize_mode = .ReleaseSafe }); // The zit library: a Zig implementation of the Fossil formats and protocol // as specified by the four RFC drafts in docs/rfcs/. root.zig re-exports the // store layers (storage/logstore/checkout/update) and the Repository facade; // one module is the whole embeddable surface. const mod = b.addModule("zit", .{ .root_source_file = b.path("src/root.zig"), .target = target, }); const env = Env{ .b = b, .target = target, .optimize = optimize, .mod = mod, }; // `zig build test` — library + CLI unit tests, and nothing else. The store // layers and their sibling tests aggregate into `mod_tests` via root.zig's // test block; build_cli wires the CLI's inline tests in below. Every other // lane hangs off `gate`, not here. const test_step = b.step("test", "Run unit tests"); const mod_tests = b.addTest(.{ .root_module = build_unit_test_module(b, target, optimize) }); test_step.dependOn(&b.addRunArtifact(mod_tests).step); // `zig build gate` — everything `test` runs, plus the compile-only proofs // that the e2e and smoke harnesses still build against the current library. // // Those two compiles used to hang off `test`, and they are not cheap: both // rebuild the whole `zit` module in the pinned ReleaseSafe. Measured at the // time of the split, `zig build test` was 9m13s wall while the tests it // actually RAN accounted for well under a minute of it -- the rest was // compilation, a large share of it these two artifacts that `test` builds // and then never executes. A suite that slow between edits stops being run // between edits, which is the opposite of what a unit lane is for. // // What did NOT move: the dst, bench and smoke *unit* tests. Those are unit // tests despite their siblings' names, and `zig build dst`/`smoke` run the // simulation and the interop check rather than them -- so moving them here // would have dropped them from CI entirely, since CI invokes the lanes // individually and never invokes `gate`. Cheap to run, and the only thing // covering that code. const gate_step = b.step("gate", "Full pre-merge gate: unit tests plus the e2e and smoke compile proofs"); gate_step.dependOn(test_step); const cli = build_cli(env, test_step); build_e2e(env, cli.exe, gate_step); build_fuzz(env); const dst_tests = build_dst(env, test_step); build_bench(env, cli.exe, test_step); build_smoke(env, cli.exe, test_step, gate_step); build_cov(env, &.{ mod_tests, cli.tests, dst_tests }); } /// The unit-test compile of root.zig. A *separate* module from the exported /// `zit` one, purely so the repository files that src/packaging_test.zig pins /// (LICENSE, the package manifest, the versioned ignore-glob) can be reached /// as anonymous imports without attaching them to the public module. /// /// Attaching them to `zit` itself made every consumer's build depend on files /// outside `build.zig.zon`'s `.paths` — a fetched package then failed with /// `failed to check cache: '.fossil-settings/ignore-glob' file_hash /// FileNotFound` before compiling a line. The need is test-only, so the scope /// is test-only. fn build_unit_test_module( b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, ) *std.Build.Module { const test_mod = b.createModule(.{ .root_source_file = b.path("src/root.zig"), .target = target, .optimize = optimize, }); test_mod.addAnonymousImport("license_text", .{ .root_source_file = b.path("LICENSE") }); test_mod.addAnonymousImport("build_manifest", .{ .root_source_file = b.path("build.zig.zon") }); test_mod.addAnonymousImport("ignore_glob", .{ .root_source_file = b.path(".fossil-settings/ignore-glob") }); test_mod.addAnonymousImport("build_source", .{ .root_source_file = b.path("build.zig") }); test_mod.addAnonymousImport("ci_workflow", .{ .root_source_file = b.path(".github/workflows/ci.yml") }); // The repository root, handed to the tests as a constant. src/packaging_test.zig // asks fossil which files are version-controlled, and a test binary's working // directory is not the checkout; without this the query has nowhere to run. // Embedding a *file* cannot answer the question — whether a path is tracked is // a property of the repository, not of the working tree. // // A null `path` gets a panic rather than a "." fallback. "." is the test // binary's working directory, not the checkout, so the fallback would not // degrade the pin to a wrong answer — it would degrade it to a *silent // skip*, since `fossil ls` outside a checkout fails and the pin treats that // as "no fossil here". A configure-time panic naming the cause is the // legible failure; a pin that quietly stops checking is not. const repo = b.addOptions(); const repo_root = b.build_root.path orelse @panic("build.zig: build root has no path; src/packaging_test.zig's version-control " ++ "pin needs the checkout directory and would otherwise skip itself into silence"); repo.addOption([]const u8, "root", repo_root); test_mod.addOptions("repo_options", repo); return test_mod; } /// build_cli's two artifacts: the installed `zit` exe (the e2e harness spawns /// it) and its unit-test compile (the out-of-band cov step traces it). const Cli = struct { exe: *std.Build.Step.Compile, tests: *std.Build.Step.Compile, }; /// The `zit` command-line tool: `zig build` installs it, `zig build run -- /// <args>` runs it, and its own unit tests join `test_step`. fn build_cli(env: Env, test_step: *std.Build.Step) Cli { const exe = env.b.addExecutable(.{ .name = "zit", .root_module = env.b.createModule(.{ .root_source_file = env.b.path("src/main.zig"), .target = env.target, .optimize = env.optimize, .imports = &.{ .{ .name = "zit", .module = env.mod }, }, }), }); env.b.installArtifact(exe); // `zig build run -- <args>` const run_step = env.b.step("run", "Run the zit CLI"); const run_cmd = env.b.addRunArtifact(exe); run_step.dependOn(&run_cmd.step); run_cmd.step.dependOn(env.b.getInstallStep()); if (env.b.args) |args| run_cmd.addArgs(args); const exe_tests = env.b.addTest(.{ .root_module = exe.root_module }); test_step.dependOn(&env.b.addRunArtifact(exe_tests).step); return .{ .exe = exe, .tests = exe_tests }; } /// `zig build e2e` — end-to-end tests that spawn the REAL `zit` binary in an /// isolated, pinned environment and read the resulting repository back through /// the library. The exe's path is threaded in as a build option (a `LazyPath`, /// so the binary is always rebuilt before the tests run); the `zit` module is /// imported so the tests can decode `.zit`/`.zit-checkout` state. /// /// The subprocess-driven run stays out of `zig build test` (it spawns the real /// binary ~150 times), but the test binary is *compiled* by `test` so this /// 4.6k-line file cannot silently rot — a type error here fails `test`, at no /// execution cost. fn build_e2e(env: Env, exe: *std.Build.Step.Compile, gate_step: *std.Build.Step) void { const opts = env.b.addOptions(); opts.addOptionPath("zit_exe", exe.getEmittedBin()); const e2e_mod = env.b.createModule(.{ .root_source_file = env.b.path("src/e2e.zig"), .target = env.target, .optimize = env.optimize, .imports = &.{ .{ .name = "zit", .module = env.mod }, .{ .name = "build_options", .module = opts.createModule() }, }, }); const e2e_tests = env.b.addTest(.{ .root_module = e2e_mod }); // Compile-only gate: `test` builds the e2e binary but never runs it. gate_step.dependOn(&e2e_tests.step); const e2e_run = env.b.addRunArtifact(e2e_tests); const e2e_step = env.b.step("e2e", "End-to-end tests: spawn the real zit binary"); e2e_step.dependOn(&e2e_run.step); } /// `zig build fuzz -- <target> [--seed N] [--rounds N]` — seeded deterministic /// fuzz targets, driven in a loop by an out-of-band runner; not part of `test`. fn build_fuzz(env: Env) void { const fuzz_exe = env.b.addExecutable(.{ .name = "fuzz", .root_module = env.b.createModule(.{ .root_source_file = env.b.path("src/fuzz_tests.zig"), .target = env.target, .optimize = env.optimize, .imports = &.{ .{ .name = "zit", .module = env.mod }, }, }), }); const fuzz_step = env.b.step("fuzz", "Run a seeded fuzz target (zig build fuzz -- <target> --seed N)"); const fuzz_run = env.b.addRunArtifact(fuzz_exe); if (env.b.args) |args| fuzz_run.addArgs(args); fuzz_step.dependOn(&fuzz_run.step); } /// `zig build dst [-- --seed N | --seeds K] [--steps M]` — deterministic /// simulation of the repostate derivation. No args runs the fixed smoke /// sweep (seeds 1..25 x 10_000 steps); deep sweeps pass `--seeds`. /// /// dst.zig's module reaches sim/*.zig (kept out of the public `zit` module); /// running it as a test binary is how those sibling harness files' inline unit /// tests join `zig build test`. fn build_dst(env: Env, test_step: *std.Build.Step) *std.Build.Step.Compile { const dst_exe = env.b.addExecutable(.{ .name = "dst", .root_module = env.b.createModule(.{ .root_source_file = env.b.path("src/dst.zig"), .target = env.target, .optimize = env.optimize, .imports = &.{ .{ .name = "zit", .module = env.mod }, }, }), }); const dst_step = env.b.step("dst", "Deterministic simulation (default smoke sweep; zig build dst -- --seed N)"); const dst_run = env.b.addRunArtifact(dst_exe); if (env.b.args) |args| dst_run.addArgs(args); dst_step.dependOn(&dst_run.step); const dst_tests = env.b.addTest(.{ .root_module = dst_exe.root_module }); test_step.dependOn(&env.b.addRunArtifact(dst_tests).step); return dst_tests; } /// `zig build bench -- <put|read|open> [flags]` — LogStore throughput and /// latency measurement. Always ReleaseFast (a benchmark measures optimized /// code, not the top-level optimize choice). The executable reaches the store /// layers through the `zit` module (`zit.logstore`/`zit.storage`); its pure /// corpus tests join `zig build test` in Debug (no timing loop ever runs under /// `test`). /// /// The `serve` subcommand spawns the REAL `zit` binary (the #13 listen-before- /// prime probe), so the exe's path is threaded in as a build option — a /// `LazyPath`, so the binary is always rebuilt before the bench runs — exactly /// as e2e/smoke do. The option module is shared by the bench exe and its test /// compile so `serve_benchmark.zig` resolves `build_options.zit_exe` in both. fn build_bench(env: Env, exe: *std.Build.Step.Compile, test_step: *std.Build.Step) void { const opts = env.b.addOptions(); opts.addOptionPath("zit_exe", exe.getEmittedBin()); const bench_imports = [_]std.Build.Module.Import{ .{ .name = "zit", .module = env.mod }, .{ .name = "build_options", .module = opts.createModule() }, }; const bench_exe = env.b.addExecutable(.{ .name = "bench", .root_module = env.b.createModule(.{ .root_source_file = env.b.path("src/logstore_benchmark.zig"), .target = env.target, .optimize = .ReleaseFast, .imports = &bench_imports, }), }); const bench_step = env.b.step("bench", "LogStore + sync benchmarks (zig build bench -- <put|read|open|sync> [flags])"); const bench_run = env.b.addRunArtifact(bench_exe); if (env.b.args) |args| bench_run.addArgs(args); bench_step.dependOn(&bench_run.step); const bench_tests = env.b.addTest(.{ .root_module = env.b.createModule(.{ .root_source_file = env.b.path("src/logstore_benchmark.zig"), .target = env.target, .optimize = env.optimize, .imports = &bench_imports, }) }); test_step.dependOn(&env.b.addRunArtifact(bench_tests).step); } /// `zig build smoke` — the one sanctioned use of the real `fossil` binary: /// confirms zit-produced artifacts/deltas against fossil's own /// parser/hasher/store. Confined to this step; never part of `test` or /// pre-commit. `zig build test` still compiles smoke.zig (catching type /// errors) without running its fossil-driving scenarios. fn build_smoke(env: Env, exe: *std.Build.Step.Compile, test_step: *std.Build.Step, gate_step: *std.Build.Step) void { // Thread the real `zit` binary's path in as a build option (a `LazyPath`, so // it is always rebuilt first), same as e2e: S5 spawns `zit serve`/`pack` as // subprocesses and clones from them with the real `fossil` client. const opts = env.b.addOptions(); opts.addOptionPath("zit_exe", exe.getEmittedBin()); const smoke_exe = env.b.addExecutable(.{ .name = "smoke", .root_module = env.b.createModule(.{ .root_source_file = env.b.path("src/smoke.zig"), .target = env.target, .optimize = env.optimize, .imports = &.{ .{ .name = "zit", .module = env.mod }, .{ .name = "build_options", .module = opts.createModule() }, }, }), }); gate_step.dependOn(&smoke_exe.step); // The scenarios need a real `fossil` and stay out of `test`, but smoke.zig's // pure REPORT ORACLES (the helpers that decide pass/fail from fossil's stdout) // are ordinary string code and are unit-tested here: an oracle that cannot // fail is worse than no oracle, and only a test says which it is. Runs no // subprocess — only the `test` blocks in this file execute. const smoke_tests = env.b.addTest(.{ .root_module = env.b.createModule(.{ .root_source_file = env.b.path("src/smoke.zig"), .target = env.target, .optimize = env.optimize, .imports = &.{ .{ .name = "zit", .module = env.mod }, .{ .name = "build_options", .module = opts.createModule() }, }, }) }); test_step.dependOn(&env.b.addRunArtifact(smoke_tests).step); const smoke_step = env.b.step("smoke", "Fossil interop smoke check (milestone-manual)"); const smoke_run = env.b.addRunArtifact(smoke_exe); if (env.b.args) |args| smoke_run.addArgs(args); smoke_step.dependOn(&smoke_run.step); } /// Reads kcov's merged `coverage.json` and prints a `src/`-only per-file line /// summary, ascending by coverage so the least-tested regions surface first. const cov_summary_py = \\import json,sys \\d=json.load(open(sys.argv[1])) \\for f in sorted(d["files"],key=lambda f:float(f["percent_covered"])): \\ print(f"{float(f['percent_covered']):6.2f}% {f['covered_lines']:>5}/{f['total_lines']:<5} {f['file'].split('/zit/')[-1]}") \\print(f"total {d['percent_covered']}% html: zig-out/cov/kcov-merged/index.html") ; /// `zig build cov` — merged line coverage over every unit-test binary via /// kcov (`brew install kcov`). Out-of-band: never part of `test`/pre-commit, /// so untested regions surface mechanically without gating a commit. fn build_cov(env: Env, test_bins: []const *std.Build.Step.Compile) void { const b = env.b; const merge = b.addSystemCommand(&.{ "kcov", "--merge" }); const merged = merge.addOutputDirectoryArg("cov"); for (test_bins) |t| { const run = b.addSystemCommand(&.{"kcov"}); run.addArg(b.fmt("--include-pattern={s}/", .{b.pathFromRoot("src")})); merge.addDirectoryArg(run.addOutputDirectoryArg("kcov")); run.addArtifactArg(t); } const install = b.addInstallDirectory(.{ .source_dir = merged, .install_dir = .prefix, .install_subdir = "cov", }); const summary = b.addSystemCommand(&.{ "python3", "-c", cov_summary_py }); summary.addFileArg(merged.path(b, "kcov-merged/coverage.json")); summary.step.dependOn(&install.step); b.step("cov", "Merged kcov line coverage (out-of-band)").dependOn(&summary.step); }