a73x

461655ce

build: the folder rule — tui imports client, never the reverse

a73x   2026-08-28 20:18

Commit message
build: the folder rule — tui imports client, never the reverse

A directory is a suggestion until something refuses to build. Layers say
which way an import may point; folders say which domain may know the
other exists, and the two are different questions — client importing tui
points strictly downward and is exactly what must not compile into an
app that paints its own way.

Rule 4 reads the sources, because an import graph cannot catch a module
that writes the escape bytes itself. Both debts the rules find are
signed rather than hidden: folder_exemptions carries client -> interact,
and four files under engine/ and client/ carry a one-line marker saying
why they spell VT bytes.

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

CLAUDE.md
Old New
@@ -51,6 +51,13 @@ engine and a client can link those folders and paint its own way:
51 | `src/cli/` | `main`(muxd) `mux_main` `muxa` `webhub_main` `flags` | 51 | `src/cli/` | `main`(muxd) `mux_main` `muxa` `webhub_main` `flags` |
52 | `src/` | `xdg` `sockpath` `proxy` `quic` `testtmp` — what both sides link | 52 | `src/` | `xdg` `sockpath` `proxy` `quic` `testtmp` — what both sides link |
53 53
54 `build.zig`'s `checkFolderRules` enforces it: engine and client name no tui,
55 server or cli module; server names no client and no terminal; tui imports
56 client and never the reverse; and nothing under `src/engine/` or `src/client/`
57 spells `termios` or an escape byte without a `// folder rule 4 exemption:` line
58 saying why. Both known debts are signed in `folder_exemptions` and those
59 markers.
60
54 Layers are enforced in the same module table (grep `.layer =` for the graph). 61 Layers are enforced in the same module table (grep `.layer =` for the graph).
55 62
56 | Layer | Modules | 63 | Layer | Modules |
build.zig
Old New
@@ -356,6 +356,126 @@ comptime {
356 } 356 }
357 } 357 }
358 358
359 /// The domain folders under `src/`. A module lives in the folder of the thing
360 /// that owns it, and this is what "owns" costs: an app that links `engine` and
361 /// `client` to paint its own way must be able to do so without dragging a
362 /// terminal in. `mux_core.wasm` already links that set with no tty anywhere,
363 /// which is the proof the rule generalises past this repo's own binaries.
364 ///
365 /// `root` is the shared layer-0 utilities both sides link; `foreign` is the
366 /// test fixtures under `test/`, which are nobody's domain and unconstrained.
367 const Folder = enum { root, engine, server, client, tui, cli, foreign };
368
369 fn folderOf(path: []const u8) Folder {
370 if (!std.mem.startsWith(u8, path, "src/")) return .foreign;
371 const rest = path["src/".len..];
372 const slash = std.mem.indexOfScalar(u8, rest, '/') orelse return .root;
373 const dir = rest[0..slash];
374 inline for (@typeInfo(Folder).@"enum".fields) |f| {
375 if (std.mem.eql(u8, dir, f.name)) return @field(Folder, f.name);
376 }
377 fatal("folder rule: src/{s}/ is not a domain folder — add it to Folder " ++
378 "or put the module in one that exists", .{dir});
379 }
380
381 /// The folders the doc gate and the folder rules both walk. Listed rather
382 /// than globbed: a new domain folder is a decision about who owns what, and
383 /// it must be made here, in the enum above, and in the rules below together.
384 const src_dirs = [_][]const u8{ "src", "src/engine", "src/server", "src/client", "src/tui", "src/cli" };
385
386 /// A folder edge the rules forbid and this repo still has, signed with its
387 /// reason. Not a waiver mechanism to reach for: an entry here is a stated
388 /// debt, and the rule's whole value is that removing one is a visible diff.
389 const FolderExemption = struct { from: []const u8, to: []const u8, why: []const u8 };
390 const folder_exemptions = [_]FolderExemption{
391 .{
392 .from = "client",
393 .to = "interact",
394 // The attach loop drives the terminal-facing machinery directly, so
395 // today `client` cannot be linked without a tty — exactly the thing
396 // the rule exists to make visible. Splitting interact into the
397 // transport-driving half and the tty-owning half is the fix, and it
398 // is bigger than a folder move.
399 .why = "client.attach drives interact directly; linking client still pulls a tty in",
400 },
401 };
402
403 fn folderExempt(from: []const u8, to: []const u8) bool {
404 for (folder_exemptions) |e| {
405 if (std.mem.eql(u8, e.from, from) and std.mem.eql(u8, e.to, to)) return true;
406 }
407 return false;
408 }
409
410 /// The folder rules, checked over the same table the layers are. Layers say
411 /// which way an import may point; folders say which domain may know the other
412 /// exists — two different questions, and a legal layer edge can still be an
413 /// illegal domain edge (`client` importing `tui` points downward and is
414 /// exactly what must not compile into a headless app).
415 fn checkFolderRules(b: *std.Build) void {
416 for (&mod_table) |spec| {
417 const from = folderOf(spec.path);
418 for (spec.imports) |dep| {
419 const to = folderOf(mod_table[idx0(dep)].path);
420 if (folderExempt(spec.name, dep)) continue;
421 if (from == .client and to == .tui) fatal(
422 "folder rule 3 broken: {s} (src/client/) imports {s} (src/tui/) — " ++
423 "tui imports client, never the reverse",
424 .{ spec.name, dep },
425 );
426 if ((from == .engine or from == .client) and
427 (to == .tui or to == .server or to == .cli)) fatal(
428 "folder rule 1 broken: {s} (src/{s}/) imports {s} (src/{s}/) — " ++
429 "engine and client name no tui, server or cli module",
430 .{ spec.name, @tagName(from), dep, @tagName(to) },
431 );
432 if (from == .server and (to == .client or to == .tui)) fatal(
433 "folder rule 2 broken: {s} (src/server/) imports {s} (src/{s}/) — " ++
434 "the daemon knows of no client and no terminal",
435 .{ spec.name, dep, @tagName(to) },
436 );
437 }
438 }
439 checkNoTerminalBytes(b);
440 }
441
442 /// Runtime twin of `idxOf`, for the loops above that read the table as data.
443 fn idx0(name: []const u8) usize {
444 for (&mod_table, 0..) |m, i| if (std.mem.eql(u8, m.name, name)) return i;
445 fatal("module table: unknown module '{s}'", .{name});
446 }
447
448 /// Folder rule 4: nothing under `src/engine/` or `src/client/` spells a
449 /// terminal. Emitting escapes and driving termios is the tui's job, and an
450 /// import graph cannot catch a module that writes the bytes itself — so this
451 /// reads the sources, the way the doc gate does.
452 ///
453 /// The escape hatch is a line saying `folder rule 4 exemption:` and why. It
454 /// is per FILE and deliberately blunt: a file that has to spell VT bytes is a
455 /// design fact worth one visible line, not a per-site suppression nobody
456 /// reads.
457 fn checkNoTerminalBytes(b: *std.Build) void {
458 const needles = [_][]const u8{ "termios", "\\x1b[", "\\x1b]" };
459 for ([_][]const u8{ "src/engine", "src/client" }) |sub| {
460 var paths: std.ArrayList([]const u8) = .empty;
461 zigFilesIn(b, sub, &paths);
462 for (paths.items) |path| {
463 const src = b.build_root.handle.readFileAlloc(b.allocator, path, 4 << 20) catch |e|
464 fatal("folder rule 4: cannot read {s} ({s})", .{ path, @errorName(e) });
465 if (std.mem.indexOf(u8, src, "folder rule 4 exemption:") != null) continue;
466 for (needles) |n| {
467 if (std.mem.indexOf(u8, src, n) != null) fatal(
468 "folder rule 4 broken: {s} spells `{s}` — driving a terminal " ++
469 "is src/tui/'s job, and {s} must link into an app that " ++
470 "paints its own way. Move the bytes, or write one line " ++
471 "`// folder rule 4 exemption: <why>` in the file",
472 .{ path, n, sub },
473 );
474 }
475 }
476 }
477 }
478
359 /// Every grant the table hands out must be one the source asked for: for 479 /// Every grant the table hands out must be one the source asked for: for
360 /// each name in a row's `imports` / `test_imports`, that row's root source 480 /// each name in a row's `imports` / `test_imports`, that row's root source
361 /// file has to contain `@import("<name>")`. The layer rules cannot see a 481 /// file has to contain `@import("<name>")`. The layer rules cannot see a
@@ -564,12 +684,10 @@ fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Bui
564 // The tool is inside its own corpus: a gate its author is exempt from is 684 // The tool is inside its own corpus: a gate its author is exempt from is
565 // an argument, not a rule. 685 // an argument, not a rule.
566 var checked: std.ArrayList([]const u8) = .empty; 686 var checked: std.ArrayList([]const u8) = .empty;
567 for ([_][]const u8{ "src", "src/engine", "src/server", "src/client", "src/tui", "src/cli" }) |d| 687 for (src_dirs) |d| zigFilesIn(b, d, &checked);
568 zigFilesIn(b, d, &checked);
569 zigFilesIn(b, "tools", &checked); 688 zigFilesIn(b, "tools", &checked);
570 var indexed: std.ArrayList([]const u8) = .empty; 689 var indexed: std.ArrayList([]const u8) = .empty;
571 for ([_][]const u8{ "src", "src/engine", "src/server", "src/client", "src/tui", "src/cli" }) |d| 690 for (src_dirs) |d| zigFilesIn(b, d, &indexed);
572 zigFilesIn(b, d, &indexed);
573 zigFilesIn(b, "test", &indexed); 691 zigFilesIn(b, "test", &indexed);
574 zigFilesIn(b, "tools", &indexed); 692 zigFilesIn(b, "tools", &indexed);
575 // build.zig is cited by name in src/cli/main.zig's comments and is a real 693 // build.zig is cited by name in src/cli/main.zig's comments and is a real
@@ -677,6 +795,8 @@ pub fn build(b: *std.Build) void {
677 checkGrantsUsed(b); 795 checkGrantsUsed(b);
678 // ...and no domain of server.zig's tests goes unreached (nor the reverse). 796 // ...and no domain of server.zig's tests goes unreached (nor the reverse).
679 checkServerTestsReached(b); 797 checkServerTestsReached(b);
798 // ...and every module still sits in the folder of the thing that owns it.
799 checkFolderRules(b);
680 800
681 // Two instances per row that has test grants: `mods[i]` is production — 801 // Two instances per row that has test grants: `mods[i]` is production —
682 // what the exes are built from and what every importer sees — and 802 // what the exes are built from and what every importer sees — and
src/client/keymap.zig
Old New
@@ -10,6 +10,7 @@
10 //! 10 //!
11 //! Deliberately platform-free — no posix, no fds, no clocks — this module 11 //! Deliberately platform-free — no posix, no fds, no clocks — this module
12 //! must compile for wasm32-freestanding. 12 //! must compile for wasm32-freestanding.
13 // folder rule 4 exemption: turning a key event into VT bytes is this module's whole contract, and it does so with no terminal in sight.
13 14
14 const std = @import("std"); 15 const std = @import("std");
15 16
src/engine/delta.zig
Old New
@@ -7,6 +7,7 @@
7 //! Engine and protocol are the whole of its world — no daemon, no clients, 7 //! Engine and protocol are the whole of its world — no daemon, no clients,
8 //! no sockets — which is what lets the tracker be driven directly by a test 8 //! no sockets — which is what lets the tracker be driven directly by a test
9 //! holding nothing but an engine. 9 //! holding nothing but an engine.
10 // folder rule 4 exemption: the tests drive the engine with VT bytes; no production line here writes an escape.
10 const std = @import("std"); 11 const std = @import("std");
11 const Engine = @import("engine").Engine; 12 const Engine = @import("engine").Engine;
12 const proto = @import("protocol"); 13 const proto = @import("protocol");
src/engine/engine.zig
Old New
@@ -1,5 +1,6 @@
1 //! Authoritative headless terminal engine. Wraps ghostty-vt's Terminal 1 //! Authoritative headless terminal engine. Wraps ghostty-vt's Terminal
2 //! and TerminalStream behind the small surface mux needs. 2 //! and TerminalStream behind the small surface mux needs.
3 // folder rule 4 exemption: ghostty-vt's grid is fed and read in VT bytes — this module IS the VT, and its selection formatter emits SGR.
3 const std = @import("std"); 4 const std = @import("std");
4 const vt = @import("ghostty-vt"); 5 const vt = @import("ghostty-vt");
5 6
src/engine/protocol.zig
Old New
@@ -7,6 +7,7 @@
7 //! readInt/writeInt cover without a dependency. A serialization library 7 //! readInt/writeInt cover without a dependency. A serialization library
8 //! (msgpack) becomes due when payloads turn truly structured — cell runs, 8 //! (msgpack) becomes due when payloads turn truly structured — cell runs,
9 //! multi-rect damage, capability negotiation. 9 //! multi-rect damage, capability negotiation.
10 // folder rule 4 exemption: a delta row IS painted bytes on the wire — composeDelta stamps CUP and EL around a row the far side replays.
10 const std = @import("std"); 11 const std = @import("std");
11 12
12 pub const MsgType = enum(u8) { 13 pub const MsgType = enum(u8) {