461655ce
build: the folder rule — tui imports client, never the reverse
a73x 2026-08-28 20:18
Commit message
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) { |