a73x

d22a2aa1

build: folder rule 4 reads production lines, not test bodies

a73x   2026-08-28 20:25

Commit message
build: folder rule 4 reads production lines, not test bodies

A test feeding VT bytes to an engine is how a test speaks to a VT; it is
not the module owning a terminal, and counting it cost delta.zig an
exemption for five lines that ship in nothing. The skip is line
arithmetic on a container-level `test`, sound because zig fmt --check
already gates the indentation, and the message now names the line.

decisions.md gets one line at the top rather than 30 rewritten
citations: the entries record what was decided when it was decided, and
editing them would make the log claim a past that was not so.

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

CLAUDE.md
Old New
@@ -53,10 +53,10 @@ engine and a client can link those folders and paint its own way:
53 53
54 `build.zig`'s `checkFolderRules` enforces it: engine and client name no tui, 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 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/` 56 client and never the reverse; and no line outside a `test` block under
57 spells `termios` or an escape byte without a `// folder rule 4 exemption:` line 57 `src/engine/` or `src/client/` spells `termios` or an escape byte without a
58 saying why. Both known debts are signed in `folder_exemptions` and those 58 `// folder rule 4 exemption:` line saying why. The known debts are signed in
59 markers. 59 `folder_exemptions` and those markers.
60 60
61 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).
62 62
build.zig
Old New
@@ -450,10 +450,16 @@ fn idx0(name: []const u8) usize {
450 /// import graph cannot catch a module that writes the bytes itself — so this 450 /// import graph cannot catch a module that writes the bytes itself — so this
451 /// reads the sources, the way the doc gate does. 451 /// reads the sources, the way the doc gate does.
452 /// 452 ///
453 /// The escape hatch is a line saying `folder rule 4 exemption:` and why. It 453 /// `test` blocks are skipped: driving an engine with VT bytes is how a test
454 /// is per FILE and deliberately blunt: a file that has to spell VT bytes is a 454 /// speaks to a VT, and a fixture that types an escape is not the module owning
455 /// design fact worth one visible line, not a per-site suppression nobody 455 /// a terminal. Line arithmetic rather than a parser, sound for the doc gate's
456 /// reads. 456 /// reason — `zig fmt --check` is already a gate, so a container-level `test`
457 /// opens at column 0 and its `}` closes there and nowhere else.
458 ///
459 /// The escape hatch for what remains is a line saying `folder rule 4
460 /// exemption:` and why. It is per FILE and deliberately blunt: a file that has
461 /// to spell VT bytes is a design fact worth one visible line, not a per-site
462 /// suppression nobody reads.
457 fn checkNoTerminalBytes(b: *std.Build) void { 463 fn checkNoTerminalBytes(b: *std.Build) void {
458 const needles = [_][]const u8{ "termios", "\\x1b[", "\\x1b]" }; 464 const needles = [_][]const u8{ "termios", "\\x1b[", "\\x1b]" };
459 for ([_][]const u8{ "src/engine", "src/client" }) |sub| { 465 for ([_][]const u8{ "src/engine", "src/client" }) |sub| {
@@ -463,14 +469,31 @@ fn checkNoTerminalBytes(b: *std.Build) void {
463 const src = b.build_root.handle.readFileAlloc(b.allocator, path, 4 << 20) catch |e| 469 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) }); 470 fatal("folder rule 4: cannot read {s} ({s})", .{ path, @errorName(e) });
465 if (std.mem.indexOf(u8, src, "folder rule 4 exemption:") != null) continue; 471 if (std.mem.indexOf(u8, src, "folder rule 4 exemption:") != null) continue;
466 for (needles) |n| { 472 var in_test = false;
467 if (std.mem.indexOf(u8, src, n) != null) fatal( 473 var lineno: usize = 0;
468 "folder rule 4 broken: {s} spells `{s}` — driving a terminal " ++ 474 var it = std.mem.splitScalar(u8, src, '\n');
469 "is src/tui/'s job, and {s} must link into an app that " ++ 475 while (it.next()) |line| {
470 "paints its own way. Move the bytes, or write one line " ++ 476 lineno += 1;
471 "`// folder rule 4 exemption: <why>` in the file", 477 if (in_test) {
472 .{ path, n, sub }, 478 if (std.mem.eql(u8, std.mem.trimRight(u8, line, "\r"), "}")) in_test = false;
473 ); 479 continue;
480 }
481 if (std.mem.startsWith(u8, line, "test ") or
482 std.mem.startsWith(u8, line, "test{"))
483 {
484 in_test = true;
485 continue;
486 }
487 for (needles) |n| {
488 if (std.mem.indexOf(u8, line, n) != null) fatal(
489 "folder rule 4 broken: {s}:{d} spells `{s}` outside a test " ++
490 "block — driving a terminal is src/tui/'s job, and {s} " ++
491 "must link into an app that paints its own way. Move the " ++
492 "bytes, or write one line `// folder rule 4 exemption: " ++
493 "<why>` in the file",
494 .{ path, lineno, n, sub },
495 );
496 }
474 } 497 }
475 } 498 }
476 } 499 }
docs/decisions.md
Old New
@@ -1,5 +1,10 @@
1 # Decision log 1 # Decision log
2 2
3 Paths cited in entries before 2026-08-28 predate the folder move: what these
4 call `src/X.zig` now lives under `src/engine/`, `src/server/`, `src/client/`
5 or `src/tui/`. The entries are the record of what was decided and are left as
6 written.
7
3 ## 2026-08-07 (M1) 8 ## 2026-08-07 (M1)
4 9
5 - **Language: Zig.** The engine dependency (ghostty-vt) is a Zig module; a C 10 - **Language: Zig.** The engine dependency (ghostty-vt) is a Zig module; a C
src/engine/delta.zig
Old New
@@ -7,7 +7,6 @@
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.
11 const std = @import("std"); 10 const std = @import("std");
12 const Engine = @import("engine").Engine; 11 const Engine = @import("engine").Engine;
13 const proto = @import("protocol"); 12 const proto = @import("protocol");