a73x

b2a07df2

build: the import graph as a declared, layer-checked table

a73x   2026-08-14 08:04

Commit message
build: the import graph as a declared, layer-checked table

87 scattered addImport calls become one table; the wiring loop can only
grant what it declares, and a production import that does not point
strictly downward is a comptime error. Strata computed and frozen;
test-only imports are a first-class column (server->replica moved there
per the spec's adjudication; client->proxy grandfathered in production —
ignoreSigpipe runs in the live attach path). wasm twins derive from the
same rows. Graph equality proven by extract-and-diff (empty); deliberate
upward edge refused at comptime; test/e2e/agent green.

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

build.zig
Old New
@@ -72,366 +72,294 @@ fn linkQuic(b: *std.Build, c: *std.Build.Step.Compile, deps: anytype) void {
72 c.linkSystemLibrary2("wolfssl", .{ .preferred_link_mode = .static }); 72 c.linkSystemLibrary2("wolfssl", .{ .preferred_link_mode = .static });
73 } 73 }
74 74
75 pub fn build(b: *std.Build) void { 75 /// One row of the module table: the import graph as declared data.
76 // Single source for both binaries' --version. Bumped at tag time. 76 /// The wiring loop below derives every addImport from it, so an import
77 const version = "0.0.1-5"; 77 /// the table does not declare cannot exist — violations are impossible,
78 const version_opts = b.addOptions(); 78 /// not detected. Layers are the topological strata of the production
79 version_opts.addOption([]const u8, "version", version); 79 /// graph, computed 2026-08-14 and FROZEN: a new import that would
80 80 /// flatten or invert a stratum fails at comptime, and re-stratifying
81 const target = b.standardTargetOptions(.{}); 81 /// requires editing this table, which is the point.
82 const optimize = b.standardOptimizeOption(.{}); 82 const ModSpec = struct {
83 const quic = quicDeps(b, target); 83 name: []const u8,
84 84 path: []const u8,
85 const ghostty_dep = b.lazyDependency("ghostty", .{ 85 layer: u8,
86 .target = target, 86 /// Production imports: must point at a strictly lower layer.
87 .optimize = optimize, 87 imports: []const []const u8 = &.{},
88 }); 88 /// Test-only imports (the testtmp pattern): wired identically — lazy
89 89 /// compilation keeps them out of release binaries — but declared
90 const protocol_mod = b.createModule(.{ 90 /// apart, because "test scaffolding never ships" is a stated rule.
91 .root_source_file = b.path("src/protocol.zig"), 91 /// Excluded from the strata computation.
92 .target = target, 92 test_imports: []const []const u8 = &.{},
93 .optimize = optimize, 93 link_libc: bool = false,
94 }); 94 /// Also instantiated against wasm32 (the muxweb core's twins).
95 95 wasm: bool = false,
96 const engine_mod = b.createModule(.{ 96 /// This module's test binary needs the QUIC archives.
97 .root_source_file = b.path("src/engine.zig"), 97 quic_tests: bool = false,
98 .target = target, 98 };
99 .optimize = optimize, 99
100 }); 100 const mod_table = [_]ModSpec{
101 if (ghostty_dep) |dep| { 101 // ---- layer 0: imports nothing internal in production ----
102 engine_mod.addImport("ghostty-vt", dep.module("ghostty-vt")); 102 .{ .name = "protocol", .path = "src/protocol.zig", .layer = 0, .wasm = true },
103 } 103 .{ .name = "engine", .path = "src/engine.zig", .layer = 0, .wasm = true },
104 104 .{ .name = "pty", .path = "src/pty.zig", .layer = 0, .link_libc = true },
105 const pty_mod = b.createModule(.{
106 .root_source_file = b.path("src/pty.zig"),
107 .target = target,
108 .optimize = optimize,
109 .link_libc = true,
110 });
111
112 // The QUIC vocabulary both ends share: the one @cImport of the vendored 105 // The QUIC vocabulary both ends share: the one @cImport of the vendored
113 // stack, the key, the wire constants, the egress ring. It has to be ONE 106 // stack, the key, the wire constants, the egress ring. It has to be ONE
114 // module — two @cImport blocks over the same headers are two distinct 107 // module — two @cImport blocks over the same headers are two distinct
115 // type universes, and `ngtcp2_vec`s cross between listener and client. 108 // type universes, and `ngtcp2_vec`s cross between listener and client.
116 const quic_mod = b.createModule(.{ 109 .{ .name = "quic", .path = "src/quic.zig", .layer = 0, .link_libc = true, .quic_tests = true },
117 .root_source_file = b.path("src/quic.zig"), 110 // Test-only: short temp paths for the tests that bind unix sockets.
118 .target = target, 111 // Imported by every module that has such a test, which is why it is a
119 .optimize = optimize, 112 // module rather than three copies.
120 .link_libc = true, 113 .{ .name = "testtmp", .path = "src/testtmp.zig", .layer = 0 },
121 }); 114 // Normalized key events -> VT bytes. A leaf with no platform imports —
122 115 // each shell (browser, later xkb) produces the normalized form and this
116 // owns the bytes; must compile for wasm32.
117 .{ .name = "keymap", .path = "src/keymap.zig", .layer = 0, .wasm = true },
118 // What the two scripted fixtures share: the escape table and the exit
119 // codes. One copy, so ptyclient and wsclient cannot disagree about
120 // what a scenario's heredoc sent.
121 .{ .name = "script", .path = "test/script.zig", .layer = 0 },
122 // Test helpers, built as real binaries because that is how the suite
123 // uses them: rawmode is a deterministic stand-in for an editor (nvim's
124 // redraw timing is its own business and it is not installed everywhere),
125 // and delaypipe makes a slow round trip out of a shell pipeline instead
126 // of out of netem and root.
127 .{ .name = "rawmode", .path = "test/rawmode.zig", .layer = 0 },
128 .{ .name = "delaypipe", .path = "test/delaypipe.zig", .layer = 0 },
129 // XDG-derived paths (key file, daemon log), shared by both binaries.
130 .{ .name = "xdg", .path = "src/xdg.zig", .layer = 0, .link_libc = true, .test_imports = &.{"testtmp"} },
131 // The socket path's identity and the right to bind it: the stale-socket
132 // claim and the dev+ino record teardown compares against. A leaf — it
133 // takes a path and nothing else, and knows no Server exists.
134 .{ .name = "sockpath", .path = "src/sockpath.zig", .layer = 0, .test_imports = &.{"testtmp"} },
135 // No imports that teach it anything, deliberately: the proxy is a byte
136 // pump that knows nothing about the protocol it carries. `testtmp` is
137 // the one exception and does not weaken that — it hands its tests a
138 // short directory to put a socket in and knows nothing about the bytes.
139 .{ .name = "proxy", .path = "src/proxy.zig", .layer = 0, .link_libc = true, .test_imports = &.{"testtmp"} },
140 // ---- layer 1: single-hop over the leaves ----
123 // The QUIC listener: the vocabulary plus a UDP socket and a connection 141 // The QUIC listener: the vocabulary plus a UDP socket and a connection
124 // table, and deliberately no protocol import — it carries opaque bytes, 142 // table, and deliberately no protocol import — it carries opaque bytes,
125 // exactly as proxy.zig does. 143 // exactly as proxy.zig does.
126 const quic_server_mod = b.createModule(.{ 144 .{ .name = "quic_server", .path = "src/quic_server.zig", .layer = 1, .link_libc = true, .imports = &.{"quic"}, .quic_tests = true },
127 .root_source_file = b.path("src/quic_server.zig"), 145 // The client's QUIC transport. Imports the vocabulary module — NOT the
128 .target = target, 146 // listener — for the pieces both ends must agree on (the key, the egress
129 .optimize = optimize, 147 // ring's lifetime discipline, the PSK identity and ALPN); duplicating
130 .link_libc = true, 148 // those would make a handshake failure the first sign they had drifted.
131 }); 149 // It has no business knowing a listener exists, and now it cannot.
132 quic_server_mod.addImport("quic", quic_mod); 150 .{ .name = "quic_client", .path = "src/quic_client.zig", .layer = 1, .link_libc = true, .imports = &.{"quic"}, .quic_tests = true },
133
134 // Speculative local echo: the overlay and its policy, and deliberately 151 // Speculative local echo: the overlay and its policy, and deliberately
135 // nothing else. No engine import, which is what lets the whole state 152 // nothing else. No engine import, which is what lets the whole state
136 // machine be exercised without a terminal — or a daemon — anywhere in 153 // machine be exercised without a terminal — or a daemon — anywhere in
137 // the picture; reconcile takes its grid duck-typed instead. 154 // the picture; reconcile takes its grid duck-typed instead.
138 const predict_mod = b.createModule(.{ 155 .{ .name = "predict", .path = "src/predict.zig", .layer = 1, .imports = &.{"protocol"} },
139 .root_source_file = b.path("src/predict.zig"),
140 .target = target,
141 .optimize = optimize,
142 });
143 predict_mod.addImport("protocol", protocol_mod);
144
145 // Test-only: short temp paths for the tests that bind unix sockets.
146 // Imported by every module that has such a test, which is why it is a
147 // module rather than three copies.
148 const testtmp_mod = b.createModule(.{
149 .root_source_file = b.path("src/testtmp.zig"),
150 .target = target,
151 .optimize = optimize,
152 });
153
154 // XDG-derived paths (key file, daemon log), shared by both binaries.
155 const xdg_mod = b.createModule(.{
156 .root_source_file = b.path("src/xdg.zig"),
157 .target = target,
158 .optimize = optimize,
159 .link_libc = true,
160 });
161 xdg_mod.addImport("testtmp", testtmp_mod);
162
163 // Daemon spawning (probe / detach / poll). muxd start today; attach 156 // Daemon spawning (probe / detach / poll). muxd start today; attach
164 // auto-start is a banked second call site. 157 // auto-start is a banked second call site.
165 const spawn_mod = b.createModule(.{ 158 .{ .name = "spawn", .path = "src/spawn.zig", .layer = 1, .link_libc = true, .imports = &.{"xdg"}, .test_imports = &.{"testtmp"} },
166 .root_source_file = b.path("src/spawn.zig"),
167 .target = target,
168 .optimize = optimize,
169 .link_libc = true,
170 });
171 spawn_mod.addImport("xdg", xdg_mod);
172 spawn_mod.addImport("testtmp", testtmp_mod);
173
174 // The ssh→QUIC handoff's shared vocabulary: the announce line, the 159 // The ssh→QUIC handoff's shared vocabulary: the announce line, the
175 // per-host cache, the dial-host strip. Both binaries import it — 160 // per-host cache, the dial-host strip. Both binaries import it —
176 // `muxd endpoint` writes the line, `mux HOST` reads it — and it is 161 // `muxd endpoint` writes the line, `mux HOST` reads it. xdg is for
177 // pure enough to need nothing but testtmp for its cache tests. 162 // makePrivateParent only — the 0700-parent discipline the cache file
178 const handoff_mod = b.createModule(.{ 163 // shares with the key file; xdg is a leaf, so no cycle.
179 .root_source_file = b.path("src/handoff.zig"), 164 .{ .name = "handoff", .path = "src/handoff.zig", .layer = 1, .link_libc = true, .imports = &.{"xdg"}, .test_imports = &.{"testtmp"} },
180 .target = target,
181 .optimize = optimize,
182 .link_libc = true,
183 });
184 handoff_mod.addImport("testtmp", testtmp_mod);
185 // For makePrivateParent only — the 0700-parent discipline the cache
186 // file shares with the key file. xdg is a leaf, so no cycle.
187 handoff_mod.addImport("xdg", xdg_mod);
188
189 // Row-level change tracking behind the delta stream. Engine plus 165 // Row-level change tracking behind the delta stream. Engine plus
190 // protocol and nothing else — no daemon, no clients — so the tracker's 166 // protocol and nothing else — no daemon, no clients — so the tracker's
191 // own tests drive it with an engine and no socket in sight. 167 // own tests drive it with an engine and no socket in sight.
192 const delta_mod = b.createModule(.{ 168 .{ .name = "delta", .path = "src/delta.zig", .layer = 1, .imports = &.{ "engine", "protocol" } },
193 .root_source_file = b.path("src/delta.zig"),
194 .target = target,
195 .optimize = optimize,
196 });
197 delta_mod.addImport("engine", engine_mod);
198 delta_mod.addImport("protocol", protocol_mod);
199
200 // The session's command state machine: MarkEvents in, transitions out. 169 // The session's command state machine: MarkEvents in, transitions out.
201 // Engine plus protocol and nothing else, same shape as delta_mod — pure, 170 // Engine plus protocol and nothing else, same shape as delta — pure,
202 // socket-free, and its own tests drive it with no daemon in sight. 171 // socket-free, and its own tests drive it with no daemon in sight.
203 const cmd_mod = b.createModule(.{ 172 .{ .name = "cmd", .path = "src/cmd.zig", .layer = 1, .imports = &.{ "engine", "protocol" } },
204 .root_source_file = b.path("src/cmd.zig"),
205 .target = target,
206 .optimize = optimize,
207 });
208 cmd_mod.addImport("engine", engine_mod);
209 cmd_mod.addImport("protocol", protocol_mod);
210
211 // Shell integration: the OSC 133 mark scripts and what a spawn must add 173 // Shell integration: the OSC 133 mark scripts and what a spawn must add
212 // to hand them to a shell. Near-leaf on purpose — it writes files and 174 // to hand them to a shell. Near-leaf on purpose — it writes files and
213 // reads the environment, and knows nothing of ptys, servers or the 175 // reads the environment, and knows nothing of ptys, servers or the
214 // protocol, so its tests need no daemon and no socket. The one import 176 // protocol, so its tests need no daemon and no socket. The one import
215 // is xdg, for the private-directory policy the shim directory shares 177 // is xdg, for the private-directory policy the shim directory shares
216 // with the key file's parent; xdg is itself a leaf, so no cycle. 178 // with the key file's parent; xdg is itself a leaf, so no cycle.
217 const shellint_mod = b.createModule(.{ 179 .{ .name = "shellint", .path = "src/shellint.zig", .layer = 1, .imports = &.{"xdg"} },
218 .root_source_file = b.path("src/shellint.zig"),
219 .target = target,
220 .optimize = optimize,
221 });
222 shellint_mod.addImport("xdg", xdg_mod);
223
224 // The replay core: snapshot/delta application and the resume 180 // The replay core: snapshot/delta application and the resume
225 // coordinates, shared by the CLI client, the wasm core, and the 181 // coordinates, shared by the CLI client, the wasm core, and the
226 // server's test fixtures. Engine plus protocol and nothing else, and 182 // server's test fixtures. Engine plus protocol and nothing else, and
227 // deliberately platform-free — it must compile for wasm32. 183 // deliberately platform-free — it must compile for wasm32.
228 const replica_mod = b.createModule(.{ 184 .{ .name = "replica", .path = "src/replica.zig", .layer = 1, .wasm = true, .imports = &.{ "engine", "protocol" } },
229 .root_source_file = b.path("src/replica.zig"), 185 // Painting the replica onto a tty. Takes an fd out and replica/engine
230 .target = target, 186 // types in, and knows nothing about transports — which is what lets its
231 .optimize = optimize, 187 // tests drive every painter through a pipe with no daemon anywhere.
232 }); 188 .{ .name = "paint", .path = "src/paint.zig", .layer = 1, .imports = &.{ "engine", "protocol" } },
233 replica_mod.addImport("engine", engine_mod); 189 // Replays a captured client stdout stream and prints the final grid in
234 replica_mod.addImport("protocol", protocol_mod); 190 // `muxd dump`'s formats — the client half of the M11 render-vs-dump
235 191 // convergence check. Imports the engine module so both sides of the
236 // Normalized key events -> VT bytes. A leaf with no platform imports — 192 // diff go through the same ghostty-vt and the same formatter.
237 // each shell (browser, later xkb) produces the normalized form and this 193 .{ .name = "render", .path = "test/render.zig", .layer = 1, .imports = &.{"engine"} },
238 // owns the bytes; must compile for wasm32. 194 // The pty-driving e2e fixture: real client on a pty slave, scripted
239 const keymap_mod = b.createModule(.{ 195 // from stdin (M12). Imports pty so the product's own module is the one
240 .root_source_file = b.path("src/keymap.zig"), 196 // under it.
241 .target = target, 197 .{ .name = "ptyclient", .path = "test/ptyclient.zig", .layer = 1, .link_libc = true, .imports = &.{ "pty", "script" } },
242 .optimize = optimize, 198 // ---- layer 2 ----
243 }); 199 // quic and quic_server both: the listener it owns, and the vocabulary
244 200 // it names directly (the key it loads, the idle default it falls back
201 // to). xdg is for endpoint_req's lazy bind — the default key path,
202 // resolved by the daemon itself when nobody handed it a --key.
203 .{ .name = "server", .path = "src/server.zig", .layer = 2, .link_libc = true, .imports = &.{ "engine", "pty", "protocol", "delta", "cmd", "shellint", "sockpath", "quic", "quic_server", "xdg" }, .test_imports = &.{ "replica", "testtmp" }, .quic_tests = true },
204 // The client is the only thing that predicts: the overlay is a local
205 // display decision and never becomes state anybody else can see. It
206 // also borrows ignoreSigpipe, which proxy owns — proxy is a leaf, so
207 // this adds no cycle and teaches the proxy nothing.
208 .{ .name = "client", .path = "src/client.zig", .layer = 2, .link_libc = true, .imports = &.{ "engine", "protocol", "replica", "quic_client", "quic", "predict", "handoff", "proxy", "paint" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
209 // The agent-facing client. It speaks frames and owns no terminal, which
210 // is the whole point — it attaches at 0x0 and never claims the grid.
211 // The transport modules are the CLI client's, minus everything that
212 // renders: `quic_client` for the remote arm and `xdg` for the one
213 // key-resolution rule all three binaries obey. Deliberately still no
214 // engine and no replica — muxa has nothing to draw.
215 .{ .name = "muxa", .path = "src/muxa.zig", .layer = 2, .link_libc = true, .imports = &.{ "protocol", "sockpath", "quic_client", "quic", "xdg" }, .quic_tests = true },
216 .{ .name = "wsclient", .path = "test/wsclient.zig", .layer = 2, .link_libc = true, .imports = &.{ "engine", "replica", "protocol", "script" } },
217 // ---- layer 3 ----
245 // The muxweb hub's HTTP/WebSocket decisions: Origin gate, route 218 // The muxweb hub's HTTP/WebSocket decisions: Origin gate, route
246 // table, WS endpoint naming. Assets are injected (the exe root 219 // table, WS endpoint naming. Assets are injected (the exe root
247 // @embedFiles them), so its tests build no artifacts. 220 // @embedFiles them), so its tests build no artifacts.
248 const webhub_mod = b.createModule(.{ 221 .{ .name = "webhub", .path = "src/webhub.zig", .layer = 3, .imports = &.{ "protocol", "client" }, .quic_tests = true },
249 .root_source_file = b.path("src/webhub.zig"), 222 // sockpath is the sun_path bound only; the client binds no socket itself.
250 .target = target, 223 .{ .name = "mux", .path = "src/mux_main.zig", .layer = 3, .link_libc = true, .imports = &.{ "client", "xdg", "spawn", "handoff", "sockpath" }, .quic_tests = true },
251 .optimize = optimize, 224 // The daemon entrypoint loads the key and constructs the listener, so
252 }); 225 // it needs quic/quic_server directly rather than through the server.
253 webhub_mod.addImport("protocol", protocol_mod); 226 // `muxd endpoint` prints the announce line handoff spells; sockpath is
254 227 // the sun_path bound, checked before any verb acts on the path; and the
255 // The socket path's identity and the right to bind it: the stale-socket 228 // keygen round-trip test needs a directory to generate into, which the
256 // claim and the dev+ino record teardown compares against. A leaf — it 229 // daemon itself never touches.
257 // takes a path and nothing else, and knows no Server exists. 230 .{ .name = "exe", .path = "src/main.zig", .layer = 3, .link_libc = true, .imports = &.{ "server", "protocol", "cmd", "proxy", "quic", "quic_server", "xdg", "spawn", "handoff", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
258 const sockpath_mod = b.createModule(.{ 231 // ---- layer 4 ----
259 .root_source_file = b.path("src/sockpath.zig"), 232 // The HOST tile's recipe comes from the same owner mux_main uses, and
260 .target = target, 233 // sockpath is the sun_path bound its --sock tiles are refused against.
261 .optimize = optimize, 234 .{ .name = "webhub_main", .path = "src/webhub_main.zig", .layer = 4, .link_libc = true, .imports = &.{ "client", "webhub", "xdg", "handoff", "sockpath" }, .quic_tests = true },
262 }); 235 };
263 sockpath_mod.addImport("testtmp", testtmp_mod); 236
264 237 fn layerOf(comptime name: []const u8) u8 {
265 const server_mod = b.createModule(.{ 238 for (mod_table) |m| {
266 .root_source_file = b.path("src/server.zig"), 239 if (std.mem.eql(u8, m.name, name)) return m.layer;
267 .target = target, 240 }
268 .optimize = optimize, 241 @compileError("module table: unknown module '" ++ name ++ "'");
269 .link_libc = true, 242 }
270 });
271 server_mod.addImport("engine", engine_mod);
272 server_mod.addImport("pty", pty_mod);
273 server_mod.addImport("protocol", protocol_mod);
274 server_mod.addImport("delta", delta_mod);
275 server_mod.addImport("cmd", cmd_mod);
276 server_mod.addImport("shellint", shellint_mod);
277 server_mod.addImport("replica", replica_mod);
278 server_mod.addImport("sockpath", sockpath_mod);
279 // Both: the listener it owns, and the vocabulary it names directly
280 // (the key it loads, the idle default it falls back to).
281 server_mod.addImport("quic", quic_mod);
282 server_mod.addImport("quic_server", quic_server_mod);
283 server_mod.addImport("testtmp", testtmp_mod);
284 // For endpoint_req's lazy bind: the default key path, resolved by the
285 // daemon itself when nobody handed it a --key.
286 server_mod.addImport("xdg", xdg_mod);
287 243
288 // The client's QUIC transport. Imports the vocabulary module — NOT the 244 comptime {
289 // listener — for the pieces both ends must agree on (the key, the egress 245 // Both checks are O(edges x rows) name comparisons; the default 1000
290 // ring's lifetime discipline, the PSK identity and ALPN); duplicating 246 // backwards branches does not cover a 32-row, 76-edge table.
291 // those would make a handshake failure the first sign they had drifted. 247 @setEvalBranchQuota(20_000);
292 // It has no business knowing a listener exists, and now it cannot. 248 for (mod_table) |m| {
293 const quic_client_mod = b.createModule(.{ 249 for (m.imports) |dep| {
294 .root_source_file = b.path("src/quic_client.zig"), 250 if (layerOf(dep) >= m.layer) @compileError(std.fmt.comptimePrint(
295 .target = target, 251 "layer violation: {s} (layer {d}) imports {s} (layer {d}) — " ++
296 .optimize = optimize, 252 "production imports point strictly downward; re-stratifying " ++
297 .link_libc = true, 253 "is a deliberate edit to mod_table, never an accident",
298 }); 254 .{ m.name, m.layer, dep, layerOf(dep) },
299 quic_client_mod.addImport("quic", quic_mod); 255 ));
256 }
257 // Test-only imports skip the direction rule but must name real rows.
258 for (m.test_imports) |dep| _ = layerOf(dep);
259 }
260 }
300 261
301 // No imports that teach it anything, deliberately: the proxy is a byte 262 /// Test registration order. Doctrine-laden and deliberately NOT derived
302 // pump that knows nothing about the protocol it carries. `testtmp` is 263 /// from the layers: delta, cmd, shellint and sockpath run BEFORE server
303 // the one exception and does not weaken that — it hands its tests a 264 /// because their tests are seconds-long and socket-free, while a
304 // short directory to put a socket in and knows nothing about the bytes. 265 /// regression in any of them can wedge a server test that waits on a
305 const proxy_mod = b.createModule(.{ 266 /// client forever — and a wedged step prints nothing at all. Failing
306 .root_source_file = b.path("src/proxy.zig"), 267 /// first is what makes the catch legible. script leads for the same
307 .target = target, 268 /// reason: instant, allocation-only, and both fixtures inherit its
308 .optimize = optimize, 269 /// escape pins. mux and exe are executable roots but carry the argument
309 .link_libc = true, 270 /// parsers — a test that is never built is not a test (decisions.md).
310 }); 271 const test_order = [_][]const u8{
311 proxy_mod.addImport("testtmp", testtmp_mod); 272 "script", "protocol", "engine", "pty", "delta",
273 "cmd", "shellint", "replica", "keymap", "webhub",
274 "sockpath", "muxa", "server", "client", "proxy",
275 "mux", "quic", "quic_server", "exe", "testtmp",
276 "quic_client", "predict", "rawmode", "delaypipe", "xdg",
277 "spawn", "handoff", "paint", "render", "ptyclient",
278 "webhub_main", "wsclient",
279 };
280
281 comptime {
282 @setEvalBranchQuota(20_000);
283 // Every table row appears in the test loop exactly once. A module in
284 // the table but not the loop is the silent-module-loss hazard with a
285 // new spelling; a duplicate runs a suite twice and skews timings.
286 if (test_order.len != mod_table.len)
287 @compileError("test_order must cover every mod_table row exactly once");
288 for (test_order, 0..) |n, i| {
289 _ = layerOf(n);
290 for (test_order[i + 1 ..]) |n2| {
291 if (std.mem.eql(u8, n, n2)) @compileError("duplicate in test_order: " ++ n);
292 }
293 }
294 }
312 295
313 // Painting the replica onto a tty. Takes an fd out and replica/engine 296 pub fn build(b: *std.Build) void {
314 // types in, and knows nothing about transports — which is what lets its 297 // Single source for both binaries' --version. Bumped at tag time.
315 // tests drive every painter through a pipe with no daemon anywhere. 298 const version = "0.0.1-5";
316 const paint_mod = b.createModule(.{ 299 const version_opts = b.addOptions();
317 .root_source_file = b.path("src/paint.zig"), 300 version_opts.addOption([]const u8, "version", version);
318 .target = target,
319 .optimize = optimize,
320 });
321 paint_mod.addImport("engine", engine_mod);
322 paint_mod.addImport("protocol", protocol_mod);
323 301
324 const client_mod = b.createModule(.{ 302 const target = b.standardTargetOptions(.{});
325 .root_source_file = b.path("src/client.zig"), 303 const optimize = b.standardOptimizeOption(.{});
326 .target = target, 304 const quic = quicDeps(b, target);
327 .optimize = optimize,
328 .link_libc = true,
329 });
330 client_mod.addImport("engine", engine_mod);
331 client_mod.addImport("protocol", protocol_mod);
332 client_mod.addImport("replica", replica_mod);
333 client_mod.addImport("testtmp", testtmp_mod);
334 client_mod.addImport("quic_client", quic_client_mod);
335 client_mod.addImport("quic", quic_mod);
336 // The client is the only thing that predicts: the overlay is a local
337 // display decision and never becomes state anybody else can see.
338 client_mod.addImport("predict", predict_mod);
339 client_mod.addImport("handoff", handoff_mod);
340 // The client borrows ignoreSigpipe, which proxy owns. proxy is a leaf,
341 // so this adds no cycle and teaches the proxy nothing.
342 client_mod.addImport("proxy", proxy_mod);
343 client_mod.addImport("paint", paint_mod);
344
345 // Late-bound: webhub is defined before client in this file, but the
346 // import graph only needs both to exist by the time the exes compile.
347 webhub_mod.addImport("client", client_mod);
348
349 const mux_mod = b.createModule(.{
350 .root_source_file = b.path("src/mux_main.zig"),
351 .target = target,
352 .optimize = optimize,
353 .link_libc = true,
354 });
355 mux_mod.addImport("client", client_mod);
356 mux_mod.addImport("build_options", version_opts.createModule());
357 mux_mod.addImport("xdg", xdg_mod);
358 mux_mod.addImport("spawn", spawn_mod);
359 mux_mod.addImport("handoff", handoff_mod);
360 // The sun_path bound only; the client binds no socket itself.
361 mux_mod.addImport("sockpath", sockpath_mod);
362 305
363 // Test helpers, built as real binaries because that is how the suite 306 const ghostty_dep = b.lazyDependency("ghostty", .{
364 // uses them: rawmode is a deterministic stand-in for an editor (nvim's
365 // redraw timing is its own business and it is not installed everywhere),
366 // and delaypipe makes a slow round trip out of a shell pipeline instead
367 // of out of netem and root.
368 const rawmode_mod = b.createModule(.{
369 .root_source_file = b.path("test/rawmode.zig"),
370 .target = target,
371 .optimize = optimize,
372 });
373 const delaypipe_mod = b.createModule(.{
374 .root_source_file = b.path("test/delaypipe.zig"),
375 .target = target, 307 .target = target,
376 .optimize = optimize, 308 .optimize = optimize,
377 }); 309 });
378 310
379 // Replays a captured client stdout stream and prints the final grid in 311 // The table is the law; this loop can only wire what it declares.
380 // `muxd dump`'s formats — the client half of the M11 render-vs-dump 312 const idx = struct {
381 // convergence check. Imports the engine module so both sides of the 313 fn of(name: []const u8) usize {
382 // diff go through the same ghostty-vt and the same formatter. 314 for (&mod_table, 0..) |m, i| {
383 const render_mod = b.createModule(.{ 315 if (std.mem.eql(u8, m.name, name)) return i;
384 .root_source_file = b.path("test/render.zig"), 316 }
385 .target = target, 317 unreachable; // every lookup below is comptime-checked against the table
386 .optimize = optimize, 318 }
387 }); 319 };
388 render_mod.addImport("engine", engine_mod); 320 var mods: [mod_table.len]*std.Build.Module = undefined;
321 for (&mod_table, 0..) |spec, i| {
322 mods[i] = b.createModule(.{
323 .root_source_file = b.path(spec.path),
324 .target = target,
325 .optimize = optimize,
326 .link_libc = if (spec.link_libc) true else null,
327 });
328 }
329 for (&mod_table, 0..) |spec, i| {
330 for (spec.imports) |dep| mods[i].addImport(dep, mods[idx.of(dep)]);
331 for (spec.test_imports) |dep| mods[i].addImport(dep, mods[idx.of(dep)]);
332 }
389 333
390 // What the two scripted fixtures share: the escape table and the exit 334 // -Dgraph: dump the declared edges for the extract-and-diff proof.
391 // codes. One copy, so ptyclient and wsclient cannot disagree about 335 if (b.option(bool, "graph", "print the module import graph and continue") orelse false) {
392 // what a scenario's heredoc sent. 336 for (&mod_table) |spec| {
393 const script_mod = b.createModule(.{ 337 for (spec.imports) |dep| std.debug.print("edge {s} {s}\n", .{ spec.name, dep });
394 .root_source_file = b.path("test/script.zig"), 338 for (spec.test_imports) |dep| std.debug.print("edge {s} {s}\n", .{ spec.name, dep });
395 .target = target, 339 }
396 .optimize = optimize, 340 }
397 });
398 341
399 // The pty-driving e2e fixture: real client on a pty slave, scripted 342 // Named handles for the exe/wasm/step wiring below — only the ones
400 // from stdin (M12). Imports pty so the product's own module is the one 343 // that wiring actually uses (an unused local is a compile error).
401 // under it. 344 // Dep edges (ghostty) and build_options stay outside the table's
402 const ptyclient_mod = b.createModule(.{ 345 // jurisdiction, explicit.
403 .root_source_file = b.path("test/ptyclient.zig"), 346 const engine_mod = mods[idx.of("engine")];
404 .target = target, 347 const mux_mod = mods[idx.of("mux")];
405 .optimize = optimize, 348 const exe_mod = mods[idx.of("exe")];
406 .link_libc = true, 349 const muxa_mod = mods[idx.of("muxa")];
407 }); 350 const rawmode_mod = mods[idx.of("rawmode")];
408 ptyclient_mod.addImport("pty", pty_mod); 351 const delaypipe_mod = mods[idx.of("delaypipe")];
409 ptyclient_mod.addImport("script", script_mod); 352 const render_mod = mods[idx.of("render")];
353 const ptyclient_mod = mods[idx.of("ptyclient")];
354 const wsclient_mod = mods[idx.of("wsclient")];
355 const webhub_main_mod = mods[idx.of("webhub_main")];
410 356
411 const exe_mod = b.createModule(.{ 357 if (ghostty_dep) |dep| {
412 .root_source_file = b.path("src/main.zig"), 358 engine_mod.addImport("ghostty-vt", dep.module("ghostty-vt"));
413 .target = target, 359 }
414 .optimize = optimize, 360 mux_mod.addImport("build_options", version_opts.createModule());
415 .link_libc = true,
416 });
417 exe_mod.addImport("server", server_mod);
418 exe_mod.addImport("protocol", protocol_mod);
419 exe_mod.addImport("cmd", cmd_mod);
420 exe_mod.addImport("proxy", proxy_mod);
421 // The daemon entrypoint loads the key and constructs the listener, so it
422 // needs the modules directly rather than through the server.
423 exe_mod.addImport("quic", quic_mod);
424 exe_mod.addImport("quic_server", quic_server_mod);
425 exe_mod.addImport("build_options", version_opts.createModule()); 361 exe_mod.addImport("build_options", version_opts.createModule());
426 exe_mod.addImport("xdg", xdg_mod); 362 webhub_main_mod.addImport("build_options", version_opts.createModule());
427 // The keygen round-trip test needs a directory to generate into; the
428 // daemon itself never touches this.
429 exe_mod.addImport("testtmp", testtmp_mod);
430 exe_mod.addImport("spawn", spawn_mod);
431 // `muxd endpoint` prints the announce line this module spells.
432 exe_mod.addImport("handoff", handoff_mod);
433 // The sun_path bound, checked before any verb acts on the path.
434 exe_mod.addImport("sockpath", sockpath_mod);
435 363
436 const exe = b.addExecutable(.{ .name = "muxd", .root_module = exe_mod }); 364 const exe = b.addExecutable(.{ .name = "muxd", .root_module = exe_mod });
437 // Zig 0.15's self-hosted x86_64 linker can't handle the .sframe 365 // Zig 0.15's self-hosted x86_64 linker can't handle the .sframe
@@ -443,24 +371,6 @@ pub fn build(b: *std.Build) void {
443 linkQuic(b, exe, quic); 371 linkQuic(b, exe, quic);
444 b.installArtifact(exe); 372 b.installArtifact(exe);
445 373
446 // The agent-facing client. It speaks frames and owns no terminal, which
447 // is the whole point — it attaches at 0x0 and never claims the grid.
448 // The transport modules are the CLI client's, minus everything that
449 // renders: `quic_client` for the remote arm and `xdg` for the one
450 // key-resolution rule all three binaries obey. Deliberately still no
451 // engine and no replica — muxa has nothing to draw.
452 const muxa_mod = b.createModule(.{
453 .root_source_file = b.path("src/muxa.zig"),
454 .target = target,
455 .optimize = optimize,
456 .link_libc = true,
457 });
458 muxa_mod.addImport("protocol", protocol_mod);
459 muxa_mod.addImport("sockpath", sockpath_mod);
460 muxa_mod.addImport("quic_client", quic_client_mod);
461 muxa_mod.addImport("quic", quic_mod);
462 muxa_mod.addImport("xdg", xdg_mod);
463
464 const mux_exe = b.addExecutable(.{ .name = "mux", .root_module = mux_mod }); 374 const mux_exe = b.addExecutable(.{ .name = "mux", .root_module = mux_mod });
465 mux_exe.use_llvm = true; 375 mux_exe.use_llvm = true;
466 mux_exe.use_lld = true; 376 mux_exe.use_lld = true;
@@ -511,20 +421,30 @@ pub fn build(b: *std.Build) void {
511 .target = wasm_target, 421 .target = wasm_target,
512 .optimize = .ReleaseSmall, 422 .optimize = .ReleaseSmall,
513 }); 423 });
514 const engine_wasm_mod = wasmMod(b, wasm_target, "src/engine.zig"); 424 // Rows with .wasm get wasm32 twins, imports rewired from the SAME table
425 // rows — one source of truth for both instantiations. wasm_core itself
426 // stays explicit: it is wasm-only, never in the native test loop.
427 var wasm_mods = [_]?*std.Build.Module{null} ** mod_table.len;
428 for (&mod_table, 0..) |spec, i| {
429 if (spec.wasm) wasm_mods[i] = wasmMod(b, wasm_target, spec.path);
430 }
431 for (&mod_table, 0..) |spec, i| {
432 if (wasm_mods[i]) |wm| {
433 for (spec.imports) |dep| {
434 wm.addImport(dep, wasm_mods[idx.of(dep)] orelse
435 @panic("wasm module imports a module without the wasm flag"));
436 }
437 }
438 }
439 const engine_wasm_mod = wasm_mods[idx.of("engine")].?;
515 if (ghostty_wasm_dep) |dep| { 440 if (ghostty_wasm_dep) |dep| {
516 engine_wasm_mod.addImport("ghostty-vt", dep.module("ghostty-vt")); 441 engine_wasm_mod.addImport("ghostty-vt", dep.module("ghostty-vt"));
517 } 442 }
518 const protocol_wasm_mod = wasmMod(b, wasm_target, "src/protocol.zig");
519 const replica_wasm_mod = wasmMod(b, wasm_target, "src/replica.zig");
520 replica_wasm_mod.addImport("engine", engine_wasm_mod);
521 replica_wasm_mod.addImport("protocol", protocol_wasm_mod);
522 const keymap_wasm_mod = wasmMod(b, wasm_target, "src/keymap.zig");
523 const wasm_core_mod = wasmMod(b, wasm_target, "src/wasm_core.zig"); 443 const wasm_core_mod = wasmMod(b, wasm_target, "src/wasm_core.zig");
524 wasm_core_mod.addImport("engine", engine_wasm_mod); 444 wasm_core_mod.addImport("engine", engine_wasm_mod);
525 wasm_core_mod.addImport("protocol", protocol_wasm_mod); 445 wasm_core_mod.addImport("protocol", wasm_mods[idx.of("protocol")].?);
526 wasm_core_mod.addImport("replica", replica_wasm_mod); 446 wasm_core_mod.addImport("replica", wasm_mods[idx.of("replica")].?);
527 wasm_core_mod.addImport("keymap", keymap_wasm_mod); 447 wasm_core_mod.addImport("keymap", wasm_mods[idx.of("keymap")].?);
528 const wasm_exe = b.addExecutable(.{ .name = "mux_core", .root_module = wasm_core_mod }); 448 const wasm_exe = b.addExecutable(.{ .name = "mux_core", .root_module = wasm_core_mod });
529 // A wasm reactor, not a command: no _start, and the exports must 449 // A wasm reactor, not a command: no _start, and the exports must
530 // survive the linker's dead-strip. Deliberately NOT use_llvm/use_lld — 450 // survive the linker's dead-strip. Deliberately NOT use_llvm/use_lld —
@@ -535,38 +455,14 @@ pub fn build(b: *std.Build) void {
535 b.installArtifact(wasm_exe); 455 b.installArtifact(wasm_exe);
536 456
537 // ---- muxweb, the hub binary (M-web Task 7) ---- 457 // ---- muxweb, the hub binary (M-web Task 7) ----
538 // The page's three assets arrive as anonymous imports so @embedFile
539 // can name them; the wasm one is the artifact itself, which also
540 // sequences the wasm build before the hub's.
541 const wsclient_mod = b.createModule(.{
542 .root_source_file = b.path("test/wsclient.zig"),
543 .target = target,
544 .optimize = optimize,
545 .link_libc = true,
546 });
547 wsclient_mod.addImport("engine", engine_mod);
548 wsclient_mod.addImport("replica", replica_mod);
549 wsclient_mod.addImport("protocol", protocol_mod);
550 wsclient_mod.addImport("script", script_mod);
551 const wsclient_exe = b.addExecutable(.{ .name = "wsclient", .root_module = wsclient_mod }); 458 const wsclient_exe = b.addExecutable(.{ .name = "wsclient", .root_module = wsclient_mod });
552 wsclient_exe.use_llvm = true; 459 wsclient_exe.use_llvm = true;
553 wsclient_exe.use_lld = true; 460 wsclient_exe.use_lld = true;
554 b.installArtifact(wsclient_exe); 461 b.installArtifact(wsclient_exe);
555 462
556 const webhub_main_mod = b.createModule(.{ 463 // The page's three assets arrive as anonymous imports so @embedFile
557 .root_source_file = b.path("src/webhub_main.zig"), 464 // can name them; the wasm one is the artifact itself, which also
558 .target = target, 465 // sequences the wasm build before the hub's.
559 .optimize = optimize,
560 .link_libc = true,
561 });
562 webhub_main_mod.addImport("client", client_mod);
563 webhub_main_mod.addImport("webhub", webhub_mod);
564 webhub_main_mod.addImport("xdg", xdg_mod);
565 // The HOST tile's recipe comes from the same owner mux_main uses.
566 webhub_main_mod.addImport("handoff", handoff_mod);
567 // ...and the sun_path bound its --sock tiles are refused against.
568 webhub_main_mod.addImport("sockpath", sockpath_mod);
569 webhub_main_mod.addImport("build_options", version_opts.createModule());
570 webhub_main_mod.addAnonymousImport("index.html", .{ .root_source_file = b.path("web/index.html") }); 466 webhub_main_mod.addAnonymousImport("index.html", .{ .root_source_file = b.path("web/index.html") });
571 webhub_main_mod.addAnonymousImport("mux.js", .{ .root_source_file = b.path("web/mux.js") }); 467 webhub_main_mod.addAnonymousImport("mux.js", .{ .root_source_file = b.path("web/mux.js") });
572 webhub_main_mod.addAnonymousImport("mux_core.wasm", .{ .root_source_file = wasm_exe.getEmittedBin() }); 468 webhub_main_mod.addAnonymousImport("mux_core.wasm", .{ .root_source_file = wasm_exe.getEmittedBin() });
@@ -577,35 +473,15 @@ pub fn build(b: *std.Build) void {
577 b.installArtifact(webhub_exe); 473 b.installArtifact(webhub_exe);
578 474
579 const test_step = b.step("test", "Run unit tests"); 475 const test_step = b.step("test", "Run unit tests");
580 // delta_mod, cmd_mod, shellint_mod and sockpath_mod sit BEFORE server_mod, 476 for (test_order) |name| {
581 // deliberately: their tests are seconds-long and socket-free, while a 477 const i = idx.of(name);
582 // regression in any of them can wedge a server test that waits on a 478 const t = b.addTest(.{ .root_module = mods[i] });
583 // client forever — and a wedged step prints nothing at all. Failing
584 // first is what makes the catch legible.
585 //
586 // mux_mod and exe_mod are executable roots, but they carry the argument
587 // parsers, and a test that is never built is not a test. exe_mod's
588 // absence here was a live hazard recorded in decisions.md — muxd's
589 // entrypoint could grow tests that silently never ran, exactly as
590 // mux_main.zig's five did before it was added.
591 //
592 // script_mod leads for the same order-is-legibility reason: its tests
593 // are instant and allocation-only, and the escape pins they carry are
594 // the ones both fixtures inherit.
595 for ([_]*std.Build.Module{ script_mod, protocol_mod, engine_mod, pty_mod, delta_mod, cmd_mod, shellint_mod, replica_mod, keymap_mod, webhub_mod, sockpath_mod, muxa_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, quic_server_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod, spawn_mod, handoff_mod, paint_mod, render_mod, ptyclient_mod, webhub_main_mod, wsclient_mod }) |mod| {
596 const t = b.addTest(.{ .root_module = mod });
597 t.use_llvm = true; 479 t.use_llvm = true;
598 t.use_lld = true; 480 t.use_lld = true;
599 // The server's tests are where the QUIC listener's in-process 481 // quic_tests is also what makes `make test` build the QUIC deps on
600 // loopback test will live (2c), so this test binary needs the stack 482 // a clean checkout — the dependency must reach the test binaries,
601 // — and wiring it here is also what makes `make test` build the 483 // not only muxd (decisions.md, M8).
602 // deps when they are absent. Without it the dependency reached only 484 if (mod_table[i].quic_tests) linkQuic(b, t, quic);
603 // `muxd`, and a clean checkout running `make test` first would have
604 // found no libraries and no explanation.
605 if (mod == server_mod or mod == quic_mod or mod == quic_server_mod or
606 mod == exe_mod or mod == client_mod or mod == mux_mod or
607 mod == quic_client_mod or mod == webhub_mod or
608 mod == muxa_mod or mod == webhub_main_mod) linkQuic(b, t, quic);
609 test_step.dependOn(&b.addRunArtifact(t).step); 485 test_step.dependOn(&b.addRunArtifact(t).step);
610 } 486 }
611 487