a73x

build.zig

Ref:   Size: 67.7 KiB   History

const std = @import("std");
const builtin = @import("builtin");

/// The vendored QUIC stack (deps/quic). Built by a script rather than by
/// addCSourceFiles, and that is a deliberate v1: wolfSSL's build generates
/// its own options header from ~200 feature switches, so compiling its
/// sources directly means reproducing that generation in Zig and keeping it
/// in step with every version bump. Script-built static libs are the honest
/// first version; addCSourceFiles is banked, and the spike's gotchas
/// (UBSan default, AES-ECB, library-sources-only) are what it will need.
///
/// The step is cheap when the libraries exist — the script exits at once —
/// so every build can depend on it. A clean checkout's FIRST build fetches
/// ~30MB and takes a few minutes; that is documented in the script and the
/// README, and there is no prebuilt blob because "we can build it with our
/// own toolchain" is the thing M8 is proving.
fn quicDeps(b: *std.Build, target: std.Build.ResolvedTarget) struct {
    step: *std.Build.Step,
    dir: []const u8,
} {
    // One word per prefix, shared with build-deps.sh, `make deps`,
    // `make clean-deps` and wan.sh's musl cross-build: `native` for the
    // host's own libc, `musl` for the static x86_64 release, and the target
    // triple for any cross target — so a third OS is one more `case` arm
    // in the script and nothing here. The word follows the TARGET, never
    // the host: a cross build that reused the host's prefix would link
    // x86_64 Linux archives into an aarch64 macOS binary.
    //
    // `musl` carries an architecture as well as a libc: the script's
    // zigcc-musl wrapper spells `-target x86_64-linux-musl` outright, so
    // that word is only ever the x86_64 static release. Any other musl
    // target falls into the `<arch>-<os>` form, which the script's `case`
    // refuses with its usage line rather than quietly building x86_64
    // archives for an aarch64 binary to fail to link.
    const t = target.result;
    const name = if (t.abi == .musl and t.cpu.arch == .x86_64)
        "musl"
    else if (t.os.tag == builtin.os.tag and t.cpu.arch == builtin.cpu.arch)
        "native"
    else
        b.fmt("{s}-{s}", .{ @tagName(t.cpu.arch), @tagName(t.os.tag) });
    const run = b.addSystemCommand(&.{ "deps/quic/build-deps.sh", name });
    run.setName(b.fmt("build QUIC deps ({s})", .{name}));
    // Never cached by the build graph: the script's own marker file is the
    // cache, and it is the only thing that knows whether the libs are there.
    //
    // Not an oversight, and the alternative was measured. Step.Run only
    // treats a command as cacheable once it has an output arg, and
    // addOutputFileArg insists on choosing the path — while this script's
    // fixed `deps/quic/out/<target>` is a contract shared with `make deps`,
    // `make clean-deps`, wan.sh's musl cross-build and linkQuic below. Under
    // a build-chosen path the marker would live in a hash-named directory,
    // so any argv change would re-download ~30MB rather than skip. The cost
    // of leaving it uncached is a fork+exec that stats the marker and
    // exits: ~1ms per build.
    run.has_side_effects = true;
    return .{
        .step = &run.step,
        .dir = b.fmt("deps/quic/out/{s}", .{name}),
    };
}

/// Zig 0.15's self-hosted x86_64 ELF linker can't handle the .sframe
/// sections gcc >= 16's crt1.o emits, so ELF goes through LLD. LLD does
/// not link Mach-O, and Zig's own linker does — so Darwin is the one
/// target that must NOT ask for it.
fn linkerFor(c: *std.Build.Step.Compile) void {
    c.use_llvm = true;
    c.use_lld = !c.rootModuleTarget().os.tag.isDarwin();
}

/// One wasm-side twin of a native module: same source, the wasm32 target,
/// and ReleaseSmall — never `optimize`, because the artifact is embedded
/// into the one binary and its Debug build is 3.7MB against ReleaseSmall's 345KB.
/// They differ only in path, and the optimize mode is the one field that
/// must not drift between them.
fn wasmMod(b: *std.Build, wasm_target: std.Build.ResolvedTarget, path: []const u8) *std.Build.Module {
    return b.createModule(.{
        .root_source_file = b.path(path),
        .target = wasm_target,
        .optimize = .ReleaseSmall,
    });
}

/// Give a compile step the QUIC stack: header path, library path, and the
/// three archives in dependency order (crypto backend, core, TLS).
///
/// Linking an archive whose symbols nothing references pulls in no objects,
/// which is why this is safe to apply before any QUIC code exists. Measured
/// rather than assumed at 2b: `nm` finds **zero** ngtcp2 or wolfSSL symbols
/// in the resulting binary. The binary does grow by 80 bytes — deterministic
/// across rebuilds, and it is link metadata, not code, since no object from
/// those archives is present. Stated exactly because "unchanged" would have
/// been the easy sentence and it would have been false.
fn linkQuic(b: *std.Build, c: *std.Build.Step.Compile, deps: anytype) void {
    c.step.dependOn(deps.step);
    c.addIncludePath(b.path(b.fmt("{s}/include", .{deps.dir})));
    c.addLibraryPath(b.path(b.fmt("{s}/lib", .{deps.dir})));
    c.linkSystemLibrary2("ngtcp2_crypto_wolfssl", .{ .preferred_link_mode = .static });
    c.linkSystemLibrary2("ngtcp2", .{ .preferred_link_mode = .static });
    c.linkSystemLibrary2("wolfssl", .{ .preferred_link_mode = .static });
}

/// Fail at build-graph-construction time with one clean line. The hygiene
/// invariants below are not opinions a gate step samples: a violation has
/// to stop every build, the way the comptime table checks do, so the graph
/// simply refuses to be built.
fn fatal(comptime fmt: []const u8, args: anytype) noreturn {
    std.debug.print("build.zig: " ++ fmt ++ "\n", args);
    std.process.exit(1);
}

/// One row of the module table: the import graph as declared data.
/// The wiring loop below derives every addImport from it, so no
/// table-module import can exist except through this loop — violations
/// are impossible, not detected. (Dependency edges — ghostty — and
/// build_options are explicitly outside the table's jurisdiction and
/// stay wired by hand.) A row is one owned COMPONENT rather than one file
/// (re-grouped 2026-08-30): the root names the module and re-exports its
/// child files, so a second row claiming one of those files is a compile
/// error about a file in two modules.
const ModSpec = struct {
    name: []const u8,
    path: []const u8,
    /// Production imports: what every instance of this row may @import, the
    /// exes built from it included.
    imports: []const []const u8 = &.{},
    /// Test-only imports (the testtmp pattern): granted ONLY to the row's
    /// test twin — the second module instance `addTest` compiles — so the
    /// production instance every exe and every importer sees has no such
    /// module available. "Test scaffolding never ships" is therefore a
    /// compile error, not a convention.
    test_imports: []const []const u8 = &.{},
    link_libc: bool = false,
    /// Also instantiated against wasm32 (the browser core's twins).
    wasm: bool = false,
    /// This module's test binary needs the QUIC archives.
    quic_tests: bool = false,
    /// Built only by a named opt-in step, never by the default test loop.
    opt_in: bool = false,
};

const mod_table = [_]ModSpec{
    // ---- the leaves: they import nothing internal in production ----
    // The wire component, one row over three files: the wire contract, the
    // client-side grid a payload decodes into and the one replay core,
    // reached as `term.protocol`, `.grid`, `.replica`. They are one owner —
    // protocol.zig owns the bytes a row is made of and grid.zig owns the
    // rows — and only this root is a seam, so a second module claiming any
    // of the three is a file-in-multiple-modules compile error. It imports
    // nothing internal and links no terminal emulator, which is what lets
    // every client hold a replica without ghostty-vt in the binary, and what
    // lets `mux_core.wasm` compile the whole component.
    // `link_libc` for its TESTS, not its code: two of protocol.zig's tests
    // need a socket whose peer refuses to read, and std.posix has no
    // socketpair on the pinned 0.15.2, so they call std.c's. The row used to
    // get libc for free through ghostty-vt; dropping the emulator made the
    // dependency visible rather than new.
    .{ .name = "term", .path = "src/engine/term.zig", .link_libc = true, .wasm = true },
    // Surface-neutral keyboard, paste and pointer input become application
    // bytes here. It has no connection or terminal I/O policy, so native,
    // browser and future terminal surfaces can share one mapping.
    .{ .name = "input", .path = "src/input.zig", .wasm = true },
    // The authoritative emulator and the daemon-side delta minting, reached
    // as `Engine` and `engine.delta`. The ONE row that links ghostty-vt: a
    // client parses no VT, so this sits above `term` rather than inside it,
    // and the client rows below carry it as a TEST import only — for the
    // tests that author a screen by feeding VT to an engine and mirroring it
    // into a grid.
    .{ .name = "engine", .path = "src/engine/engine.zig", .imports = &.{"term"} },
    // The platform layer, one row per side (docs/superpowers/specs/
    // 2026-09-03-macos-port-design.md). Leaves: they import nothing of ours,
    // and the raw OS spellings are meant to end up here rather than in the
    // rows that call them, so a second arm is a folder and not a grep.
    // Two rows rather than one because the client never links a fork or a
    // pty, and an app that links the engine and a client must not either.
    .{ .name = "server_os", .path = "src/os/server_os.zig", .link_libc = true },
    .{ .name = "client_os", .path = "src/os/client_os.zig", .link_libc = true },
    .{ .name = "pty", .path = "src/server/pty.zig", .link_libc = true, .imports = &.{"server_os"} },
    // The QUIC vocabulary both ends share: the one @cImport of the vendored
    // stack, the key, the wire constants, the egress ring. It has to be ONE
    // module — two @cImport blocks over the same headers are two distinct
    // type universes, and `ngtcp2_vec`s cross between listener and client.
    .{ .name = "quic", .path = "src/quic.zig", .link_libc = true, .quic_tests = true },
    // Test-only: short temp paths for the tests that bind unix sockets.
    // Imported by every module that has such a test, which is why it is a
    // module rather than three copies.
    .{ .name = "testtmp", .path = "src/testtmp.zig" },
    // What the two scripted fixtures share: the escape table and the exit
    // codes. One copy, so ptyclient and wsclient cannot disagree about
    // what a scenario's heredoc sent.
    .{ .name = "script", .path = "test/script.zig" },
    // Test helpers, built as real binaries because that is how the suite
    // uses them: rawmode is a deterministic stand-in for an editor (nvim's
    // redraw timing is its own business and it is not installed everywhere),
    // and delaypipe makes a slow round trip out of a shell pipeline instead
    // of out of netem and root.
    .{ .name = "rawmode", .path = "test/rawmode.zig" },
    .{ .name = "delaypipe", .path = "test/delaypipe.zig" },
    // XDG-derived paths (key file, daemon log), shared by both binaries.
    .{ .name = "xdg", .path = "src/xdg.zig", .link_libc = true, .test_imports = &.{"testtmp"} },
    // The socket path's identity and the right to bind it: the stale-socket
    // claim and the dev+ino record teardown compares against. A leaf — it
    // takes a path and nothing else, and knows no Server exists.
    .{ .name = "sockpath", .path = "src/sockpath.zig", .test_imports = &.{"testtmp"} },
    // The bind-and-unlink half of owning a socket path, above sockpath and
    // below every binder: the daemon socket, the per-session agent sockets
    // and askpass's prompt socket all take their listener from here, so the
    // guarded unlink is written once instead of three times.
    .{ .name = "serve", .path = "src/serve.zig", .imports = &.{"sockpath"}, .test_imports = &.{"testtmp"} },
    // Nothing that teaches it the protocol, deliberately: the proxy is a
    // byte pump that knows nothing about what it carries. Its two imports
    // are both about the PATH and never the bytes — `sockpath` for the
    // socket name it dials, `testtmp` for a short directory its tests can
    // put one in.
    .{ .name = "proxy", .path = "src/proxy.zig", .link_libc = true, .imports = &.{"sockpath"}, .test_imports = &.{"testtmp"} },
    // Reflection over a caller's options struct, so it imports nothing: the
    // struct is the flag table and the parser learns it at comptime.
    .{ .name = "cliflags", .path = "src/cli/flags.zig" },
    // This image, as a path something can exec. Under src/os/ with the rest
    // of the platform layer because it asks the OS about the process it is
    // in — a question no headless client may spell, and one whose answer is
    // spelled differently on every OS.
    .{ .name = "spawn", .path = "src/os/spawn.zig", .link_libc = true },
    // ---- single-hop over the leaves ----
    // The client side of a daemon's socket: dial it, and say hello. `term`
    // is the attach encoders, `link` is the round trip's wait — an embedder
    // reaches a daemon by linking those two and this, instead of the whole
    // client module.
    .{ .name = "dial", .path = "src/dial.zig", .link_libc = true, .imports = &.{ "term", "link", "sockpath" }, .quic_tests = true },
    // The live connection itself — fd, pipe or QUIC — and the one wait-for-a-
    // frame loop. `term` for frames, `quic` for the third arm; policy stays
    // with the rows that import this one.
    .{ .name = "link", .path = "src/link.zig", .link_libc = true, .imports = &.{ "term", "quic" }, .quic_tests = true },
    // Replays a captured client stdout stream and prints the final grid in
    // `mux d dump`'s formats — the client half of the M11 render-vs-dump
    // convergence check. It plays the TERMINAL the client painted onto
    // rather than a client, so it imports the engine, and both sides of the
    // diff go through the same ghostty-vt and the same formatter.
    .{ .name = "render", .path = "test/render.zig", .imports = &.{"engine"} },
    // The pty-driving e2e fixture: real client on a pty slave, scripted
    // from stdin (M12). Imports pty so the product's own module is the one
    // under it.
    .{ .name = "ptyclient", .path = "test/ptyclient.zig", .link_libc = true, .imports = &.{ "pty", "script" } },
    // The command state machine, shell integration, the upgrade vocabulary
    // and the QUIC listener are CHILD FILES of this root rather than modules
    // of their own, so nothing outside src/server/ can name one: a second
    // module claiming any of those files is a file-in-multiple-modules
    // compile error. `quic` is the listener's and the daemon's both — the
    // socket that child opens, and the vocabulary this root names directly
    // (the key it loads, the idle default it falls back to). xdg is for
    // endpoint_req's lazy bind — the default key path, resolved by the
    // daemon itself when nobody handed it a --key — and for the shim
    // directory shell integration writes under the same 0700 policy.
    // `pty` stays a row of its own: the ptyclient fixture consumes it.
    .{ .name = "daemon", .path = "src/server/server.zig", .link_libc = true, .imports = &.{ "term", "engine", "pty", "sockpath", "serve", "quic", "xdg", "proxy", "server_os" }, .test_imports = &.{ "testtmp", "dial", "link" }, .quic_tests = true },
    // The agent-facing client. It speaks frames and owns no terminal, which
    // is the whole point — it attaches at 0x0 and never claims the grid.
    // The transport modules are the CLI client's, minus everything that
    // renders: `quic` for the remote arm and `xdg` for the one
    // key-resolution rule all three binaries obey. Of `term` it spells the
    // wire contract and the row decoder — no engine and no replica, muxa
    // having nothing to draw and no grid to keep, but `mux a run` reports a
    // command's output and the rows that carry it are cells like any others.
    .{ .name = "agent", .path = "src/cli/muxa.zig", .link_libc = true, .imports = &.{ "term", "sockpath", "quic", "xdg", "cliflags", "dial", "link" }, .quic_tests = true },
    .{ .name = "wsclient", .path = "test/wsclient.zig", .link_libc = true, .imports = &.{ "term", "script" }, .test_imports = &.{"engine"} },
    // Dialling, and what a chord means. The client is the only thing that
    // predicts — the overlay is a local display decision and never becomes
    // state anybody else can see — but the predicting itself is interact's
    // now, along with the rest of the terminal-facing machinery; what stays
    // here is Target/Transport, the attach loop and the session's meanings.
    // The core decoder, the hosts file, the handoff vocabulary, the pane
    // tree, the cancellation byte and the askpass carriage are CHILD FILES of this
    // root re-exported as `client.core`, `.hosts`, `.handoff`, `.layout`,
    // `.interrupt`, `.askpass` — one row, so a second module claiming any of
    // those files is a file-in-multiple-modules compile error. `resolveHost`
    // sits here rather than in either front so the CLI wall and the browser
    // hub resolve a host line the same way. Nothing here WRITES that file —
    // `wall_host.recordHost` and `webhub_main` do.
    .{ .name = "client", .path = "src/client/client.zig", .link_libc = true, .imports = &.{ "term", "input", "quic", "xdg", "serve", "dial", "link", "client_os" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
    // ---- the two fronts ----
    // The browser hub's HTTP/WebSocket decisions: Origin gate, route table,
    // WS endpoint naming. Assets are injected (the exe root @embedFiles
    // them), so its tests build no artifacts. It stays a row rather than
    // becoming a child file of the dispatcher that @embedFiles for it: a
    // module's root directory is the dirname of its root file, and
    // `@import("../client/webhub.zig")` from src/cli is "import of file
    // outside module path" — a compiler rule, not a table choice.
    .{ .name = "webhub", .path = "src/client/webhub.zig", .imports = &.{ "term", "client" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
    // The CLI wall (`mux wall`): multiattach stripes in one terminal, one of
    // which can be ZOOMED — promoted to the terminal's size and typed
    // through. It sits beside webhub for the same reason — both are fronts
    // on client's Transport and `client.hosts`' grammar; neither imports the
    // other; both resolve a spelling through `client.Target.fromSpec`.
    // The interaction loop, painter and prediction overlay are CHILD FILES of
    // this root rather than modules of their own. Selection policy is a public
    // child of the client root so the native and terminal adapters share it,
    // so nothing outside src/tui/ can name one: a second module claiming any
    // of those files is a file-in-multiple-modules compile error. `term` is
    // the children's as much as the root's: `term.replica` is the keyboard
    // loop's alone, which the root never spells, and the painter takes
    // `term.grid` and `term.protocol`; the decoder and the key table they
    // also want reach them through `client`'s seams. `engine` is a TEST
    // grant only — the painter's and the loop's tests author a screen by
    // feeding VT to an engine and mirroring it into a grid, and no
    // production line here parses VT at all.
    .{ .name = "wall", .path = "src/tui/wallview.zig", .link_libc = true, .imports = &.{ "term", "client", "proxy", "spawn", "client_os", "sockpath" }, .test_imports = &.{ "testtmp", "engine" }, .quic_tests = true },
    // ---- the one binary ----
    // Four words, one image — and one row: the daemon's entrypoint, the
    // client's and the hub's are CHILD FILES of the dispatcher, so a second
    // module claiming any of them is a file-in-multiple-modules compile
    // error. `agent` stays a row because muxa's suite must be able to fail on
    // its own. The grants are the four mains' union: `webhub` for the hub
    // surface the entrypoint serves, `daemon` for the Server it constructs
    // and the `quic_server`/`upgrade` seams it reaches through it, `quic` for
    // the key it loads before that listener exists, `client` for the announce
    // vocabulary the daemon writes and the client reads — one owner, or the
    // two spell it differently — and for `client.hosts`, which owns the host
    // grammar and the state file, so `mux web`'s argv is parsed by the SAME
    // rules `mux hosts add` is: one grammar, not two. Then the wall the
    // no-arg client opens, `term` for `term.protocol`'s SessionName.parseCLI
    // (a bad --session is a usage error at parse, not bytes a daemon
    // downstream must refuse) and `sockpath` for the sun_path bound every verb
    // checks before acting on a path. `dial` is how `mux d`'s observer verbs
    // — stats, dump, endpoint, upgrade — ask their one question: the same
    // round trip the client and the agent already reach for, rather than a
    // fourth copy of connect-write-poll-read here. `testtmp` is the keygen
    // round-trip's: it needs a directory to generate into, which the daemon
    // never touches.
    .{ .name = "mux", .path = "src/cli/mux.zig", .link_libc = true, .imports = &.{ "daemon", "client", "wall", "agent", "webhub", "term", "proxy", "quic", "xdg", "spawn", "sockpath", "cliflags", "dial", "link", "server_os", "client_os" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
    // Opt-in rows: no default artifact or test names them, so machines
    // without the viewer's system libraries keep all existing gates.
    .{ .name = "native_core", .path = "src/gui/native_core.zig", .link_libc = true, .imports = &.{ "client", "term", "input" }, .opt_in = true },
    .{ .name = "native", .path = "src/gui/native.zig", .link_libc = true, .imports = &.{ "native_core", "client", "term", "input" }, .opt_in = true },
    .{ .name = "muxg", .path = "src/cli/muxg.zig", .link_libc = true, .imports = &.{ "native", "client", "term", "cliflags", "sockpath", "xdg" }, .opt_in = true },
};

/// Comptime row lookup. Every hand-written module name in this file goes
/// through it, so renaming a table row fails the build at comptime naming
/// the module, rather than reaching a runtime `unreachable` in the wiring.
fn idxOf(comptime name: []const u8) usize {
    for (mod_table, 0..) |m, i| {
        if (std.mem.eql(u8, m.name, name)) return i;
    }
    @compileError("module table: unknown module '" ++ name ++ "'");
}

comptime {
    // Both checks are O(edges x rows) name comparisons; the default 1000
    // backwards branches does not cover a 21-row, 45-edge table (38
    // production imports and 7 test-only ones).
    @setEvalBranchQuota(20_000);
    for (mod_table) |m| {
        // Every edge names a real row, production and test-only alike, so a
        // typo or a name left behind by a rename is a compile error here
        // rather than the wiring loop's runtime `unreachable`.
        for (m.imports) |dep| _ = idxOf(dep);
        for (m.test_imports) |dep| _ = idxOf(dep);
        // A flagged row is also compiled against wasm32-freestanding, and
        // having a `.wasm` twin is exactly what says a module compiles
        // there — so an import without one cannot. Mechanical rather than
        // architectural, and failing here names the offending edge, where
        // the twin loop's later @panic could only say a module was missing.
        if (m.wasm) for (m.imports) |dep| {
            if (!mod_table[idxOf(dep)].wasm) @compileError(std.fmt.comptimePrint(
                "wasm row {s} imports {s}, which has no .wasm twin — a twin " ++
                    "can only import twins",
                .{ m.name, dep },
            ));
        };
    }
}

/// The folders the doc gate walks. Listed rather than globbed: a new folder
/// under `src/` is a decision about who owns what, and a glob would let one
/// appear — with every file in it ungated — as a side effect of a mkdir.
const src_dirs = [_][]const u8{ "src", "src/engine", "src/server", "src/client", "src/tui", "src/cli", "src/os", "src/gui" };

/// The source bans, read off the PRODUCTION lines of the files under `src/`.
/// They catch what the import graph cannot: a module needs no import to
/// spell an escape byte, a shell path or a fork, so these read the sources
/// themselves. The rules keep the numbers 4, 5 and 6 they were given, because
/// files across the repo cite them by number in their own
/// `folder rule N exemption:` lines.
fn checkSourceBans(b: *std.Build) void {
    for (source_bans) |ban| checkSourceBan(b, ban);
}

/// A byte a folder's PRODUCTION lines may not spell. The import graph
/// cannot catch a module that writes the bytes itself, so these read the
/// sources the way the doc gate does.
///
/// `test` blocks are skipped: driving an engine with VT bytes is how a test
/// speaks to a VT, and a test that spawns a shell is testing what the
/// product refuses to spawn. Line arithmetic rather than a parser, sound for
/// the doc gate's reason — `zig fmt --check` is already a gate, so a
/// container-level `test` opens at column 0 and its `}` closes there and
/// nowhere else.
///
/// A `// folder rule N exemption: reason` exempts only the following line.
/// Keeping the allowance beside the occurrence prevents one legitimate escape
/// sequence from permitting unrelated terminal calls elsewhere in the file.
const SourceBan = struct {
    rule: []const u8,
    folders: []const []const u8,
    needles: []const []const u8,
    /// What is wrong with spelling it, in the fatal's own voice.
    why: []const u8,
    /// The files a rule is ABOUT rather than against: rule 6 exists to say
    /// WHERE the fork lives, so naming those files here is the rule's
    /// content and not a hole in it. A list rather than one name, because
    /// the fork's spelling is per OS arm and each arm is a file. Unlike the
    /// in-file `exemption:` line, which any file may write for itself, this
    /// is a diff to build.zig.
    except: []const []const u8 = &.{},
};

const source_bans = [_]SourceBan{
    .{
        .rule = "4",
        .folders = &.{ "src/engine", "src/client" },
        // The termios CALLS as well as the header's name: a module can ask
        // the OS about a terminal without ever spelling `termios`, and
        // `isatty` is how it starts. Matched against a lower-cased line, so
        // `\X1B[` is the same needle as `\x1b[`.
        .needles = &.{ "termios", "isatty", "tcgetattr", "tcsetattr", "\\x1b[", "\\x1b]", "\\u{1b}" },
        .why = "driving a terminal is src/tui/'s job, and this module must " ++
            "link into an app that paints its own way",
    },
    .{
        .rule = "5",
        // Every folder, `src/` root and `src/engine/` included: a shell
        // spelled by a leaf utility runs exactly as well as one spelled by
        // the daemon, and a rule with a hole in it is a rule that reports
        // green about the place nobody looked.
        .folders = &.{ "src", "src/engine", "src/client", "src/tui", "src/server", "src/cli", "src/os", "src/gui" },
        .needles = &.{ "\"/bin/sh\"", "\"-c\"" },
        .why = "the only program mux runs is one the user named — the " ++
            "session shell, `ssh` from the handoff recipe, or `--via`'s own " ++
            "words — and every one is exec'd as argv, so no shell of ours " ++
            "ever parses a line we built",
    },
    .{
        .rule = "6",
        .folders = &.{ "src", "src/engine", "src/client", "src/tui", "src/server", "src/cli", "src/os", "src/gui" },
        .needles = &.{"posix.fork("},
        .except = &.{ "src/os/server_os_linux.zig", "src/os/server_os_macos.zig" },
        .why = "the daemon starts itself \u{2014} `mux d start -d` forks, and " ++
            "every other starter spells that argv and execs this image. A " ++
            "client that forked a daemon would be choosing the daemon's " ++
            "flags, its log and its refusals, none of which it can see. One " ++
            "fork per OS arm, and the arm's file is named here so a third " ++
            "file that forks is caught",
    },
    .{
        .rule = "7",
        .folders = &.{ "src", "src/engine", "src/client", "src/tui", "src/server", "src/cli", "src/gui" },
        // The raw spellings the platform layer exists to hold. `src/os/` is
        // absent from the list on purpose: its children may spell anything,
        // and its roots have no reason to. Comments count, as they do for
        // rule 4 — a comment naming a Linux mechanism is one that goes
        // stale the day a second arm exists.
        // Four of the needles are spelled to catch a name in both the form
        // Zig writes it and the form C and our own prose do. `so.peercred`
        // and `so_peercred` are `std.posix.SO.PEERCRED` and `SO_PEERCRED`; a
        // bare `peercred` would ban `client_os.peerCred`, the very operation
        // callers are supposed to reach for. `iocsptlck` and `iocgptn` drop
        // the leading T so they catch `std.posix.T.IOCGPTN` as well as
        // `TIOCGPTN` — `std.posix.T` exists, so that first spelling names no
        // `std.os.linux` and would otherwise be a Linux-ism that passes.
        // `nosignal` catches `std.posix.MSG.NOSIGNAL`, a flag Linux and the
        // BSDs spell differently and macOS does not have at all: a send that
        // must not signal goes through `server_os.sendNoSigNoWait` or
        // `client_os.sendNoSig`, whichever side is asking.
        .needles = &.{ "std.os.linux", "/proc", "memfd", "close_range", "exit_group", "so.peercred", "so_peercred", "iocsptlck", "iocgptn", "nosignal" },
        .why = "a call whose spelling differs by OS belongs in src/os/, behind a " ++
            "server_os or client_os operation whose doc names what it guarantees; " ++
            "everything else builds for every OS from the same line",
    },
    .{
        .rule = "8",
        .folders = &.{"src/gui"},
        .needles = &.{ "@import(\"wall\")", "wall_host", "wall_layout", "wall_picker", "layoutfile", "sessionpoll" },
        .why = "src/gui/ owns its workspace independently of terminal wall policy and persistence",
    },
    .{
        .rule = "9",
        .folders = &.{"src/gui"},
        .needles = &.{"sdl"},
        .except = &.{"src/gui/frame.zig"},
        .why = "the windowing dependency is confined to frame.zig",
    },
};

fn checkSourceBan(b: *std.Build, ban: SourceBan) void {
    const exempt = b.fmt("folder rule {s} exemption:", .{ban.rule});
    const marker = b.fmt("// {s}", .{exempt});
    for (ban.folders) |sub| {
        var paths: std.ArrayList([]const u8) = .empty;
        zigFilesIn(b, sub, &paths);
        for (paths.items) |path| {
            var excepted = false;
            for (ban.except) |ex| {
                if (std.mem.eql(u8, path, ex)) excepted = true;
            }
            if (excepted) continue;
            const src = b.build_root.handle.readFileAlloc(b.allocator, path, 4 << 20) catch |e|
                fatal("folder rule {s}: cannot read {s} ({s})", .{ ban.rule, path, @errorName(e) });
            var in_test = false;
            var lineno: usize = 0;
            var exempt_next = false;
            var it = std.mem.splitScalar(u8, src, '\n');
            while (it.next()) |line| {
                lineno += 1;
                const allowed = exempt_next;
                exempt_next = false;
                const trimmed = std.mem.trim(u8, line, " \t\r");
                if (std.mem.startsWith(u8, trimmed, marker)) {
                    if (std.mem.trim(u8, trimmed[marker.len..], " \t").len == 0)
                        fatal("folder rule {s} broken: {s}:{d}: exemption needs a reason", .{ ban.rule, path, lineno });
                    exempt_next = true;
                    continue;
                }
                if (in_test) {
                    if (std.mem.eql(u8, std.mem.trimRight(u8, line, "\r"), "}")) in_test = false;
                    continue;
                }
                if (std.mem.startsWith(u8, line, "test ") or
                    std.mem.startsWith(u8, line, "test{"))
                {
                    in_test = true;
                    continue;
                }
                if (allowed) continue;
                // Lower-cased once per line, because the bytes a rule bans
                // have more than one spelling: `\X1B[` is the escape the
                // needle names, and case is the only thing between them.
                const lower = std.ascii.allocLowerString(b.allocator, line) catch @panic("OOM");
                defer b.allocator.free(lower);
                for (ban.needles) |n| {
                    if (std.mem.indexOf(u8, lower, n) != null) fatal(
                        "folder rule {s} broken: {s}:{d} spells `{s}` outside a test " ++
                            "block — {s}. Move it, or write one line `// folder rule " ++
                            "{s} exemption: <why>` immediately before this line",
                        .{ ban.rule, path, lineno, n, ban.why, ban.rule },
                    );
                }
            }
        }
    }
}

/// Every grant the table hands out must be one the source asked for: for
/// each name in a row's `imports` / `test_imports`, that row's root source
/// file has to contain `@import("<name>")`. The table's own checks cannot
/// see a stale grant — an edge nobody uses still names a real row — so a grant
/// outlives the code that needed it silently, and the table stops being a
/// description of the program. (Exactly how `exe` kept `cmd` after main.zig
/// stopped importing it; found in review 2026-08-14, and this is the check
/// that would have refused the build instead.)
///
/// Textual on purpose, and scanned over the module's WHOLE file set, not the
/// root alone: a domain root owns child files reached by relative import (the
/// wall's interaction loop, the server's session table), and a grant is the
/// module's, so any of those files may be the one that spends it. Scanning
/// the root alone would call a child's import stale and refuse the build.
/// What the text cannot tell is WHICH column a used import belongs in: a
/// production grant referenced only inside a `test` block still reads as used
/// here. Separating the columns is the test twin's job in build(), not this
/// scan's.
fn checkGrantsUsed(b: *std.Build) void {
    for (&mod_table) |spec| {
        var files: std.ArrayList([]const u8) = .empty;
        moduleFiles(b, spec.path, &files);
        for ([_][]const []const u8{ spec.imports, spec.test_imports }, 0..) |col, which| {
            for (col) |dep| {
                const needle = b.fmt("@import(\"{s}\")", .{dep});
                var used = false;
                for (files.items) |f| {
                    const src = b.build_root.handle.readFileAlloc(b.allocator, f, 4 << 20) catch |err|
                        fatal("module table: cannot read {s} ({s})", .{ f, @errorName(err) });
                    if (std.mem.indexOf(u8, src, needle) != null) {
                        used = true;
                        break;
                    }
                }
                if (!used) fatal(
                    "module table: row '{s}' grants {s} '{s}', but no file of " ++
                        "{s} writes @import(\"{s}\") — delete the stale grant; the " ++
                        "table is the program's import graph, not a wish list",
                    .{ spec.name, if (which == 0) "import" else "test_import", dep, spec.path, dep },
                );
            }
        }
    }
}

/// The files one module is built from: its root, plus every `.zig` file the
/// root reaches by relative import, transitively. The set the compiler will
/// claim for that module, which is the set a grant can be spent in.
fn moduleFiles(b: *std.Build, path: []const u8, out: *std.ArrayList([]const u8)) void {
    for (out.items) |seen| if (std.mem.eql(u8, seen, path)) return;
    out.append(b.allocator, path) catch @panic("OOM");
    const src = b.build_root.handle.readFileAlloc(b.allocator, path, 4 << 20) catch |err|
        fatal("module table: cannot read {s} ({s})", .{ path, @errorName(err) });
    const dir = std.fs.path.dirname(path) orelse ".";
    const open = "@import(\"";
    var i: usize = 0;
    while (std.mem.indexOfPos(u8, src, i, open)) |at| {
        const start = at + open.len;
        const close = std.mem.indexOfScalarPos(u8, src, start, '"') orelse break;
        i = close + 1;
        const spelled = src[start..close];
        if (!std.mem.endsWith(u8, spelled, ".zig")) continue;
        const child = b.pathJoin(&.{ dir, spelled });
        // A relative import the compiler will refuse anyway (a file in
        // another module, a path that is not there): let it say so.
        b.build_root.handle.access(child, .{}) catch continue;
        moduleFiles(b, child, out);
    }
}

/// A split file's tests live beside it, and analysis is what registers a
/// test: the `_ = @import(…)` lines in the root's test block are the only
/// thing that reaches them. Drop one line and that whole domain stops
/// running — against a green tree, because a suite that never ran fails
/// nothing. Nothing in the toolchain notices; only a test count someone
/// wrote down by hand would, and the count is not written down anywhere.
///
/// Both directions are gated, because either half alone is silent. A file
/// with no import is tests nobody runs. An import with no file is a rename
/// that took a domain with it — that one is at least a compile error today,
/// but the message names a missing file rather than the rule, and this is
/// where the rule lives.
///
/// Textual, for checkGrantsUsed's reason and soundly for the same one: the
/// import is a relative path, so the root is the only file that can spell
/// it. Globbed rather than listed for zigFilesIn's: a domain file added
/// tomorrow is covered without anybody remembering to add it here.
const sibling_tests = [_]struct { root: []const u8, dir: []const u8, prefix: []const u8 }{
    .{ .root = "src/server/server.zig", .dir = "src/server", .prefix = "server_test_" },
    .{ .root = "src/tui/wallview.zig", .dir = "src/tui", .prefix = "wall_test_" },
};

fn checkSiblingTestsReached(b: *std.Build) void {
    for (sibling_tests) |spec| checkOneRootReaches(b, spec.root, spec.dir, spec.prefix);
}

fn checkOneRootReaches(b: *std.Build, root: []const u8, subdir: []const u8, prefix: []const u8) void {
    const src = b.build_root.handle.readFileAlloc(b.allocator, root, 4 << 20) catch |err|
        fatal("sibling tests: cannot read {s} ({s})", .{ root, @errorName(err) });

    var dir = b.build_root.handle.openDir(subdir, .{ .iterate = true }) catch |e|
        fatal("sibling tests: cannot open {s}/ ({s})", .{ subdir, @errorName(e) });
    defer dir.close();

    var found: usize = 0;
    var it = dir.iterate();
    while (it.next() catch |e|
        fatal("sibling tests: cannot list {s}/ ({s})", .{ subdir, @errorName(e) })) |ent|
    {
        if (ent.kind != .file) continue;
        if (!std.mem.startsWith(u8, ent.name, prefix)) continue;
        if (!std.mem.endsWith(u8, ent.name, ".zig")) continue;
        found += 1;
        if (std.mem.indexOf(u8, src, b.fmt("@import(\"{s}\")", .{ent.name})) == null) fatal(
            "sibling tests: {s}/{s} exists, but {s} never writes " ++
                "@import(\"{s}\") — add the line to its test block; until then " ++
                "every test in that file is unreached and passes by not running",
            .{ subdir, ent.name, root, ent.name },
        );
    }
    // An empty glob would make this gate green forever, the doc gate's hazard.
    if (found == 0) fatal(
        "sibling tests: no {s}/{s}*.zig at all — if the tests moved " ++
            "back into {s}, delete this row rather than leave it passing on " ++
            "an empty set",
        .{ subdir, prefix, root },
    );

    const needle = b.fmt("@import(\"{s}", .{prefix});
    var rest = src;
    while (std.mem.indexOf(u8, rest, needle)) |at| {
        const open = at + "@import(\"".len;
        const close = std.mem.indexOfScalarPos(u8, rest, open, '"') orelse
            fatal("sibling tests: unterminated @import in {s}", .{root});
        const name = rest[open..close];
        dir.access(name, .{}) catch fatal(
            "sibling tests: {s} writes @import(\"{s}\"), but {s}/{s} does not " ++
                "exist — point the line at the file the tests live in now; a " ++
                "line naming nothing is a domain of tests nobody runs",
            .{ root, name, subdir, name },
        );
        rest = rest[close..];
    }
}

/// Shell syntax and ShellCheck are required by check; missing tools fail the
/// step instead of silently reducing coverage on a fresh machine.
fn shellGate(b: *std.Build, step: *std.Build.Step) void {
    var paths: [64][]const u8 = undefined;
    var n: usize = 0;
    for ([_][]const u8{ "test", "tools", "deps", "deps/quic" }) |sub| {
        var dir = b.build_root.handle.openDir(sub, .{ .iterate = true }) catch |err|
            fatal("shell gate: cannot open {s}/ ({s})", .{ sub, @errorName(err) });
        defer dir.close();
        var it = dir.iterate();
        while (it.next() catch |err|
            fatal("shell gate: cannot list {s}/ ({s})", .{ sub, @errorName(err) })) |ent|
        {
            if (ent.kind != .file or !std.mem.endsWith(u8, ent.name, ".sh")) continue;
            if (n == paths.len) fatal("shell gate: more than {d} scripts", .{paths.len});
            paths[n] = b.fmt("{s}/{s}", .{ sub, ent.name });
            n += 1;
        }
    }
    // Directory order is whatever the filesystem feels like; sort so the step
    // names and the shellcheck argv are identical on every machine.
    std.mem.sort([]const u8, paths[0..n], {}, struct {
        fn lt(_: void, a: []const u8, c: []const u8) bool {
            return std.mem.lessThan(u8, a, c);
        }
    }.lt);
    for (paths[0..n]) |p| {
        const run = b.addSystemCommand(&.{ "sh", "-n" });
        // As a FILE arg, not a string: the script's CONTENTS are then part of
        // what the build graph hashes, the lesson web/verify.js paid for.
        run.addFileArg(b.path(p));
        run.setName(b.fmt("sh -n {s}", .{p}));
        run.expectExitCode(0);
        step.dependOn(&run.step);
    }
    const run = b.addSystemCommand(&.{ "shellcheck", "--severity=error" });
    for (paths[0..n]) |p| run.addFileArg(b.path(p));
    run.setName("shellcheck (severity=error)");
    run.expectExitCode(0);
    step.dependOn(&run.step);
}

/// Collect `<sub>/*.zig`, sorted, into `paths`. Globbed rather than listed for
/// shellGate's reason: a module added tomorrow is covered without anybody
/// remembering to add it.
fn zigFilesIn(b: *std.Build, sub: []const u8, paths: *std.ArrayList([]const u8)) void {
    var dir = b.build_root.handle.openDir(sub, .{ .iterate = true }) catch |e|
        fatal("doc gate: cannot open {s}/ ({s})", .{ sub, @errorName(e) });
    defer dir.close();
    const first = paths.items.len;
    var it = dir.iterate();
    while (it.next() catch |e|
        fatal("doc gate: cannot list {s}/ ({s})", .{ sub, @errorName(e) })) |ent|
    {
        if (ent.kind != .file or !std.mem.endsWith(u8, ent.name, ".zig")) continue;
        paths.append(b.allocator, b.fmt("{s}/{s}", .{ sub, ent.name })) catch @panic("OOM");
    }
    if (paths.items.len == first) fatal("doc gate: no .zig files in {s}/", .{sub});
    std.mem.sort([]const u8, paths.items[first..], {}, struct {
        fn lt(_: void, a: []const u8, c: []const u8) bool {
            return std.mem.lessThan(u8, a, c);
        }
    }.lt);
}

/// The comment-discipline gate (tools/docscheck.zig). CLAUDE.md has said
/// "comments say why, not how" and "code, comments, docs drift" since the
/// first commit; a week of drift showed that prose is instruction and only a
/// check that RUNS is codification — the same reasoning behind the comptime
/// table checks above. It gates REFERENCES, not length: a comment must still
/// name something real, and may take as many lines as saying so takes.
///
/// The tool is a build tool, not part of the program, so it stays out of the
/// module table: that table is the program's import graph, and a row there
/// would claim docscheck is something `mux` links.
///
/// Nothing about this step can silently skip, which is the whole hazard with
/// a gate: the tool is built from source in this repo, so "not installed" is a
/// compile error rather than a green tree; the file lists are globbed with a
/// fatal on an empty directory; the tool itself refuses an empty --check or
/// --index group; and `stdio = .inherit` makes the run unconditional, so no
/// cache hit can stand in for a check that did not happen. Inherit also puts
/// the violations on the terminal at the moment of failure instead of inside a
/// captured-stderr dump.
/// Every source file is still passed as a FILE arg: that is what declares the
/// dependency and lets the build system resolve the paths.
fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Build.Step) void {
    const mod = b.createModule(.{
        .root_source_file = b.path("tools/docscheck.zig"),
        .target = target,
        .optimize = .Debug,
    });
    const exe = b.addExecutable(.{ .name = "docscheck", .root_module = mod });
    linkerFor(exe);

    // The tool is inside its own corpus: a gate its author is exempt from is
    // an argument, not a rule.
    var checked: std.ArrayList([]const u8) = .empty;
    for (src_dirs) |d| zigFilesIn(b, d, &checked);
    zigFilesIn(b, "tools", &checked);
    var indexed: std.ArrayList([]const u8) = .empty;
    for (src_dirs) |d| zigFilesIn(b, d, &indexed);
    zigFilesIn(b, "test", &indexed);
    zigFilesIn(b, "tools", &indexed);
    // build.zig is cited by name in src/cli/main.zig's comments and is a real
    // file of this repo, so it belongs in the corpus even though it is not
    // under src/ or test/.
    indexed.append(b.allocator, "build.zig") catch @panic("OOM");

    // The tool's own rules are asserted, not assumed: its unit tests pin the
    // codename patterns against the domain vocabulary they must not catch.
    const unit = b.addTest(.{ .root_module = mod });
    const run_unit = b.addRunArtifact(unit);
    run_unit.setName("test docscheck");
    check_step.dependOn(&run_unit.step);

    const run = b.addRunArtifact(exe);
    run.addArg("--check");
    for (checked.items) |p| run.addFileArg(b.path(p));
    run.addArg("--index");
    for (indexed.items) |p| run.addFileArg(b.path(p));
    // `.inherit` carries its own term check — a non-zero exit fails the
    // step — so this needs no expectExitCode, and adding one would
    // silently switch the step back to captured stdio.
    run.stdio = .inherit;
    run.setName("docscheck");
    check_step.dependOn(&run.step);
}

/// Test registration order — the order failures ARRIVE in, and deliberately
/// NOT the table's. A suite that waits on a socket can wedge, and a wedged
/// step prints nothing at all; whatever runs before it is the only legible
/// catch. So the suites that CAN wedge go last: from `pty` on, every row
/// either waits on a pty or imports `testtmp`, which is what a row asks for
/// when its tests bind a unix socket. Ahead of that tail nothing takes
/// longer than `term`'s 301ms, and the tail holds both whales — `pty` 5s,
/// `daemon` 40s (`zig build test --summary all`, Debug, 2026-08-30) — so a
/// wedge always has the cheap suites' verdicts printed above it. `script`
/// leads because both fixtures inherit its escape pins, and `mux` runs last
/// of all: it carries every argument parser but muxa's, its mains being
/// child files — a test that is never built is not a test (decisions.md).
const test_order = [_][]const u8{
    "script",    "cliflags", "testtmp",  "server_os", "client_os", "spawn",
    "dial",      "link",     "quic",     "webhub",    "agent",     "input",
    "term",      "engine",   "rawmode",  "delaypipe", "render",    "wsclient",
    "ptyclient", "pty",      "sockpath", "serve",     "xdg",       "proxy",
    "wall",      "client",   "daemon",   "mux",
};

comptime {
    @setEvalBranchQuota(20_000);
    // Every table row appears in the test loop exactly once. A module in
    // the table but not the loop is the silent-module-loss hazard with a
    // new spelling; a duplicate runs a suite twice and skews timings.
    var default_rows: usize = 0;
    for (mod_table) |m| {
        if (!m.opt_in) default_rows += 1;
    }
    if (test_order.len != default_rows)
        @compileError("test_order must cover every non-opt-in mod_table row exactly once");
    for (test_order, 0..) |n, i| {
        if (mod_table[idxOf(n)].opt_in)
            @compileError("test_order contains opt-in row: " ++ n);
        for (test_order[i + 1 ..]) |n2| {
            if (std.mem.eql(u8, n, n2)) @compileError("duplicate in test_order: " ++ n);
        }
    }
}

pub fn build(b: *std.Build) void {
    // Single source for the product binaries' --version. Bumped at tag time.
    const version = "0.0.1-17";
    const version_opts = b.addOptions();
    version_opts.addOption([]const u8, "version", version);

    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});
    const quic = quicDeps(b, target);

    const ghostty_dep = b.lazyDependency("ghostty", .{
        .target = target,
        .optimize = optimize,
    });

    // The table is the law; this loop can only wire what it declares.
    const idx = struct {
        fn of(name: []const u8) usize {
            for (&mod_table, 0..) |m, i| {
                if (std.mem.eql(u8, m.name, name)) return i;
            }
            // Loop-driven lookups are comptime-validated by the table checks
            // above (every imports/test_imports entry names a real row);
            // named literals use `comptime idxOf` instead of this.
            unreachable;
        }
    };
    // No grant outlives its @import (and the message names the row).
    checkGrantsUsed(b);
    // ...and no domain of a split file's tests goes unreached (nor the reverse).
    checkSiblingTestsReached(b);
    // ...and no production line under src/ spells a byte a source ban forbids.
    checkSourceBans(b);

    // Two instances per row that has test grants: `mods[i]` is production —
    // what the exes are built from and what every importer sees — and
    // `test_mods[i]` is the twin `addTest` compiles, which is the only one
    // the test_imports column reaches. That is what makes "test scaffolding
    // never ships" mechanical: a production function reaching for testtmp
    // fails to compile in every binary that reaches that function. (An
    // unreferenced `const TmpDir = @import("testtmp").TmpDir;` at container
    // scope stays silent, because Zig never analyzes it — which is the same
    // statement: it did not ship.) Rows without test grants alias the
    // production instance, so the graph grows by exactly the eight rows that
    // use the pattern, and the twins import PRODUCTION deps — a dependency's
    // own test grants are its own test binary's business.
    var mods: [mod_table.len]*std.Build.Module = undefined;
    var test_mods: [mod_table.len]*std.Build.Module = undefined;
    for (&mod_table, 0..) |spec, i| {
        const opts: std.Build.Module.CreateOptions = .{
            .root_source_file = b.path(spec.path),
            .target = target,
            .optimize = optimize,
            .link_libc = if (spec.link_libc) true else null,
        };
        mods[i] = b.createModule(opts);
        test_mods[i] = if (spec.test_imports.len == 0) mods[i] else b.createModule(opts);
    }
    for (&mod_table, 0..) |spec, i| {
        for (spec.imports) |dep| mods[i].addImport(dep, mods[idx.of(dep)]);
        if (test_mods[i] != mods[i]) {
            for (spec.imports) |dep| test_mods[i].addImport(dep, mods[idx.of(dep)]);
            for (spec.test_imports) |dep| test_mods[i].addImport(dep, mods[idx.of(dep)]);
        }
    }

    // -Dgraph: dump the declared edges for the extract-and-diff proof.
    if (b.option(bool, "graph", "print the module import graph and continue") orelse false) {
        for (&mod_table) |spec| {
            for (spec.imports) |dep| std.debug.print("edge {s} {s}\n", .{ spec.name, dep });
            for (spec.test_imports) |dep| std.debug.print("edge {s} {s}\n", .{ spec.name, dep });
        }
    }

    // Named handles for the exe/wasm/step wiring below — only the ones
    // that wiring actually uses (an unused local is a compile error).
    // Dep edges (ghostty) and build_options stay outside the table's
    // jurisdiction, explicit.
    const mux_mod = mods[comptime idxOf("mux")];
    const agent_mod = mods[comptime idxOf("agent")];
    const rawmode_mod = mods[comptime idxOf("rawmode")];
    const delaypipe_mod = mods[comptime idxOf("delaypipe")];
    const render_mod = mods[comptime idxOf("render")];
    const ptyclient_mod = mods[comptime idxOf("ptyclient")];
    const wsclient_mod = mods[comptime idxOf("wsclient")];

    // The dep is named by engine.zig, the engine module's root: an import
    // name is resolved in the module that owns the spelling file, so the
    // handle belongs on that row and nowhere else. `term` used to carry it,
    // which put a terminal emulator in every binary that held a replica.
    if (ghostty_dep) |dep| {
        mods[comptime idxOf("engine")].addImport("ghostty-vt", dep.module("ghostty-vt"));
    }
    // ONE module object, shared: the two rows link into one binary, and a
    // second instance of the same root file in one compilation is "file
    // exists in modules 'build_options' and 'build_options0'".
    const build_opts_mod = version_opts.createModule();
    mux_mod.addImport("build_options", build_opts_mod);
    agent_mod.addImport("build_options", build_opts_mod);
    // A row with test_imports gets a SEPARATE module for its test twin, and
    // build_options is outside the table's jurisdiction — so every such twin
    // needs it by hand, or the argument parsers lose the version they print.
    test_mods[comptime idxOf("mux")].addImport("build_options", build_opts_mod);

    // ONE product binary. Three of the four mains are files under it and
    // muxa is the `agent` row it imports; all four are reached by the mode
    // word rather than by four names on PATH — which is also what
    // lets the client's auto-start exec this same image with argv
    // `mux d run …` (spawn.zig) instead of hunting PATH for a sibling. The
    // fixtures below stay separate: they stand in for users, not for the
    // product.
    const mux_exe = b.addExecutable(.{ .name = "mux", .root_module = mux_mod });
    linkerFor(mux_exe);
    linkQuic(b, mux_exe, quic);
    b.installArtifact(mux_exe);

    const rawmode_exe = b.addExecutable(.{ .name = "rawmode", .root_module = rawmode_mod });
    linkerFor(rawmode_exe);
    b.installArtifact(rawmode_exe);

    const delaypipe_exe = b.addExecutable(.{ .name = "delaypipe", .root_module = delaypipe_mod });
    linkerFor(delaypipe_exe);
    b.installArtifact(delaypipe_exe);

    const render_exe = b.addExecutable(.{ .name = "render", .root_module = render_mod });
    linkerFor(render_exe);
    b.installArtifact(render_exe);

    const ptyclient_exe = b.addExecutable(.{ .name = "ptyclient", .root_module = ptyclient_mod });
    linkerFor(ptyclient_exe);
    b.installArtifact(ptyclient_exe);

    // ---- The wasm core (M-web Task 4) ----
    // A SECOND resolved target: modules are target-bound, so the wasm-clean
    // row — the term component — is instantiated again against wasm32. It
    // is the only dependency the core has. Always ReleaseSmall — the artifact
    // is @embedFile'd into the one binary, and its Debug build is 3.7MB
    // against ReleaseSmall's 345KB (spike-measured).
    // Safety checks are the price; the native test suite runs the same
    // code checked.
    const wasm_target = b.resolveTargetQuery(.{
        .cpu_arch = .wasm32,
        .os_tag = .freestanding,
    });
    // Rows with .wasm get wasm32 twins, imports rewired from the SAME table
    // rows — one source of truth for both instantiations. wasm_core itself
    // stays explicit: it is wasm-only, never in the native test loop.
    var wasm_mods = [_]?*std.Build.Module{null} ** mod_table.len;
    for (&mod_table, 0..) |spec, i| {
        if (spec.wasm) wasm_mods[i] = wasmMod(b, wasm_target, spec.path);
    }
    for (&mod_table, 0..) |spec, i| {
        if (wasm_mods[i]) |wm| {
            for (spec.imports) |dep| {
                wm.addImport(dep, wasm_mods[idx.of(dep)] orelse
                    @panic("wasm module imports a module without the wasm flag"));
            }
        }
    }
    // No ghostty-vt twin at all: `term` links no emulator, so the browser
    // core decodes cells and the wasm build has one fewer dependency to
    // fetch and compile.
    const term_wasm_mod = wasm_mods[comptime idxOf("term")].?;
    const wasm_core_mod = wasmMod(b, wasm_target, "src/client/wasm_core.zig");
    wasm_core_mod.addImport("term", term_wasm_mod);
    wasm_core_mod.addImport("input", wasm_mods[comptime idxOf("input")].?);
    // Compile a tiny, never-embedded canary that calls the semantic decoder
    // through typed mode and clipboard result paths. This build-only object
    // forces the production API and its validation helpers through wasm
    // semantic analysis without adding runtime exports or behavior. It
    // names `term` because client_core.zig, its relative child now,
    // imports that module by name.
    const client_core_wasm_check_mod = wasmMod(b, wasm_target, "src/client/client_core_wasm_check.zig");
    client_core_wasm_check_mod.addImport("term", term_wasm_mod);
    const client_core_wasm_guard = b.addObject(.{
        .name = "client_core_wasm_check",
        .root_module = client_core_wasm_check_mod,
    });
    const wasm_exe = b.addExecutable(.{ .name = "mux_core", .root_module = wasm_core_mod });
    // A wasm reactor, not a command: no _start, and the exports must
    // survive the linker's dead-strip. Deliberately NOT use_llvm/use_lld —
    // that pair is the native x86-64 self-hosted-linker workaround and
    // must not be copied onto the wasm exe (spike-proven recipe).
    wasm_exe.entry = .disabled;
    wasm_exe.rdynamic = true;
    b.installArtifact(wasm_exe);

    // ---- the hub's browser stand-in ----
    const wsclient_exe = b.addExecutable(.{ .name = "wsclient", .root_module = wsclient_mod });
    linkerFor(wsclient_exe);
    b.installArtifact(wsclient_exe);

    // The page's three assets arrive as anonymous imports so @embedFile
    // can name them; the wasm one is the artifact itself, which also
    // sequences the wasm build before the hub's.
    // webhub_main.zig is a child file of the dispatcher, so the assets attach
    // to that module and to its test twin both — an @embedFile the twin
    // cannot name is a test binary that will not compile.
    for ([_]*std.Build.Module{ mux_mod, test_mods[comptime idxOf("mux")] }) |m| {
        m.addAnonymousImport("index.html", .{ .root_source_file = b.path("web/index.html") });
        m.addAnonymousImport("mux.js", .{ .root_source_file = b.path("web/mux.js") });
        m.addAnonymousImport("mux_core.wasm", .{ .root_source_file = wasm_exe.getEmittedBin() });
    }

    const test_step = b.step("test", "Run unit tests");
    test_step.dependOn(&client_core_wasm_guard.step);
    for (test_order) |name| {
        const i = idx.of(name);
        const t = b.addTest(.{ .root_module = test_mods[i] });
        linkerFor(t);
        // quic_tests is also what makes `make test` build the QUIC deps on
        // a clean checkout — the dependency must reach the test binaries,
        // not only the binary (decisions.md, M8).
        if (mod_table[i].quic_tests) linkQuic(b, t, quic);
        const run = b.addRunArtifact(t);
        test_step.dependOn(&run.step);
        // Component owners use the same artifact as the full gate, without
        // compiling or running another component's suite.
        if (std.mem.eql(u8, name, "daemon") or std.mem.eql(u8, name, "client")) {
            const component_test = b.step(b.fmt("{s}-test", .{name}), b.fmt("Run {s} component tests", .{name}));
            component_test.dependOn(&run.step);
        }
    }

    // The web ABI check is part of every test run; Node is a test dependency.
    const verify = b.addSystemCommand(&.{"node"});
    // Both as FILE args, not strings: a string argument is not an input
    // the build graph hashes, so a doctored verify.js would have stayed
    // cached and the check would have been decorative. (Caught by
    // doctoring one, exactly as the fix round asked.)
    verify.addFileArg(b.path("web/verify.js"));
    verify.addFileArg(wasm_exe.getEmittedBin());
    // Hashed as INPUTS though they are not argv: verify.js reads both at
    // runtime (the shell's call list and the page), so without these a
    // mux.js change replays a stale cached pass — which is exactly how
    // the dataset.tileId harness gap shipped green.
    verify.addFileInput(b.path("web/mux.js"));
    verify.addFileInput(b.path("web/index.html"));
    verify.setName("verify wasm ABI (web/verify.js)");
    // stdio is the assertion: a failing check exits non-zero.
    verify.expectExitCode(0);
    test_step.dependOn(&verify.step);

    const e2e = b.addSystemCommand(&.{"test/e2e.sh"});
    e2e.addArtifactArg(mux_exe);
    // The prediction scenarios need a deterministic editor and a slow path;
    // passed as artifacts so the suite runs against the binaries this build
    // just produced rather than whatever is installed.
    e2e.addArtifactArg(rawmode_exe);
    e2e.addArtifactArg(delaypipe_exe);
    e2e.addArtifactArg(render_exe);
    e2e.addArtifactArg(ptyclient_exe);
    e2e.addArtifactArg(wsclient_exe);
    const e2e_step = b.step("e2e", "Run end-to-end test");
    e2e_step.dependOn(&e2e.step);

    // The agent surface gets a step of its own rather than a place in e2e:
    // its ten scenarios spend ~51s mostly waiting on real idle timeouts and
    // a 20s quiet await, which is a cost the paint-and-convergence suite
    // should not have to carry on every run. A step is what makes it a gate
    // at all — M7's rule, paid for twice: an end-to-end property that only
    // runs when somebody types the script is the shape a regression ships
    // through.
    const agent = b.addSystemCommand(&.{"test/agent.sh"});
    agent.addArtifactArg(mux_exe);
    const agent_step = b.step("agent", "Run the agent-surface end-to-end suite");
    agent_step.dependOn(&agent.step);

    // The native client and its core are opt-in. Only the window/painter
    // artifacts ask pkg-config for the native system libraries.
    // GL functions are resolved through the window library at runtime, so
    // there is deliberately no libGL link here.
    const native_mod = mods[comptime idxOf("native")];
    const muxg_mod = mods[comptime idxOf("muxg")];
    muxg_mod.addImport("build_options", build_opts_mod);
    const muxg_exe = b.addExecutable(.{ .name = "muxg", .root_module = muxg_mod });
    linkerFor(muxg_exe);
    for ([_][]const u8{ "sdl3", "freetype2", "fontconfig", "harfbuzz" }) |lib|
        muxg_exe.linkSystemLibrary2(lib, .{ .use_pkg_config = .force });
    linkQuic(b, muxg_exe, quic);
    const install_muxg = b.addInstallArtifact(muxg_exe, .{});
    const native_step = b.step("native", "Build muxg (opt-in; needs SDL3, freetype, fontconfig, HarfBuzz)");
    native_step.dependOn(&install_muxg.step);

    // Policy tests have their own artifact: running them does not compile
    // the frame adapter or resolve any window/font system libraries.
    const native_core_tests = b.addTest(.{ .root_module = mods[comptime idxOf("native_core")] });
    linkerFor(native_core_tests);
    linkQuic(b, native_core_tests, quic);
    const run_native_core_tests = b.addRunArtifact(native_core_tests);
    const native_core_test_step = b.step("native-core-test", "Run native workspace and interaction tests without window libraries");
    native_core_test_step.dependOn(&run_native_core_tests.step);

    // This compiles the same native component and links the same libraries,
    // but its tests do not initialize video or open a window.
    const native_tests = b.addTest(.{ .root_module = native_mod });
    linkerFor(native_tests);
    for ([_][]const u8{ "sdl3", "freetype2", "fontconfig", "harfbuzz" }) |lib|
        native_tests.linkSystemLibrary2(lib, .{ .use_pkg_config = .force });
    linkQuic(b, native_tests, quic);
    const native_test_step = b.step("native-test", "Run the native client's unit tests (opt-in)");
    native_test_step.dependOn(&run_native_core_tests.step);
    native_test_step.dependOn(&b.addRunArtifact(native_tests).step);

    // One ordinary journey keeps the shared window/panes/daemons alive through
    // input, tiling, resize, transport, rendering, fonts and restore. Destructive
    // and platform-specific Python probes remain directly runnable, not another
    // repetition of this default acceptance setup.
    const native_journey = b.addSystemCommand(&.{ "python3", "-B", "test/native_journey.py" });
    native_journey.addArtifactArg(mux_exe);
    native_journey.addArtifactArg(muxg_exe);
    const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)");
    native_e2e_step.dependOn(&native_journey.step);

    // Both paths come from this build graph: a ReleaseSafe GUI beside a stale
    // Debug daemon gives misleading latency numbers under raw terminal output.
    const native_stress_step = b.step("native-stress", "Check raw-output native pane responsiveness (Linux, release build)");
    if (optimize != .ReleaseSafe and optimize != .ReleaseFast) {
        native_stress_step.dependOn(&b.addFail("native-stress requires -Doptimize=ReleaseSafe or ReleaseFast for both binaries").step);
    } else {
        const native_stress = b.addSystemCommand(&.{ "python3", "-B", "test/native_stress.py" });
        native_stress.addArtifactArg(mux_exe);
        native_stress.addArtifactArg(muxg_exe);
        native_stress_step.dependOn(&native_stress.step);
    }

    const soak = b.addSystemCommand(&.{"test/soak.sh"});
    // The same list the e2e step passes, in the same order: soak IS that
    // suite run N times, so an argument added to one and not the other
    // makes every soak run abort on an unbound variable before its first
    // scenario.
    soak.addArtifactArg(mux_exe);
    soak.addArtifactArg(rawmode_exe);
    soak.addArtifactArg(delaypipe_exe);
    soak.addArtifactArg(render_exe);
    soak.addArtifactArg(ptyclient_exe);
    soak.addArtifactArg(wsclient_exe);
    const soak_step = b.step("soak", "Run the e2e suite SOAK_N times (default 10)");
    soak_step.dependOn(&soak.step);

    const bench = b.addSystemCommand(&.{"test/bench.sh"});
    bench.addArtifactArg(mux_exe);
    const bench_step = b.step("bench", "Measure delta vs snapshot bytes");
    bench_step.dependOn(&bench.step);

    // The format gate. `check = true` makes this a --check run: it fails
    // naming the offending files and rewrites nothing.
    const fmt_step = b.step("fmt", "Check formatting (zig fmt --check)");
    const fmt = b.addFmt(.{ .paths = &.{ "build.zig", "build.zig.zon", "src", "test", "tools" }, .check = true });
    fmt_step.dependOn(&fmt.step);

    // The seconds-long pre-commit gate: fmt + unit tests + the shell scripts'
    // syntax. e2e/agent/soak stay separate on purpose — they are minutes-long
    // and process-spawning.
    //
    // It does not depend on the default install step, and does not need to:
    // every table row is built as a test binary here, and webhub_main's
    // @embedFile of mux_core.wasm drags the wasm build in with it, so the
    // compile coverage this gate gives is every module in the table plus the
    // wasm core. What it does NOT cover is the executable wiring itself —
    // linking `mux` and the test fixtures, the QUIC archives included — which
    // is `zig build` (and e2e, which runs the binaries).
    const check_step = b.step("check", "fmt + unit tests + shell syntax — the pre-commit gate");
    check_step.dependOn(fmt_step);
    check_step.dependOn(test_step);
    shellGate(b, check_step);
    docGate(b, target, check_step);
}