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);
}