a73x

ce7fb793

build: every wall_test_* sibling is reached, or the build says which is not

a73x   2026-08-28 20:58

Commit message
build: every wall_test_* sibling is reached, or the build says which is not

The server's gate was the rule with one root's name in it; the wall's
split needed the same one. A table of (root, dir, prefix) rows, so the
next file to be split adds a row and not a copy.

build.zig
Old New
@@ -522,11 +522,11 @@ fn checkGrantsUsed(b: *std.Build) void {
522 } 522 }
523 } 523 }
524 524
525 /// server.zig's tests live beside it in `src/server/`, and analysis is what 525 /// A split file's tests live beside it, and analysis is what registers a
526 /// registers a test: the `_ = @import(…)` lines in server.zig's test block 526 /// test: the `_ = @import(…)` lines in the root's test block are the only
527 /// are the only thing that reaches them. Drop one line and that whole domain 527 /// thing that reaches them. Drop one line and that whole domain stops
528 /// stops running — against a green tree, because a suite that never ran 528 /// running — against a green tree, because a suite that never ran fails
529 /// fails nothing. Nothing in the toolchain notices; only a test count someone 529 /// nothing. Nothing in the toolchain notices; only a test count someone
530 /// wrote down by hand would, and the count is not written down anywhere. 530 /// wrote down by hand would, and the count is not written down anywhere.
531 /// 531 ///
532 /// Both directions are gated, because either half alone is silent. A file 532 /// Both directions are gated, because either half alone is silent. A file
@@ -536,54 +536,62 @@ fn checkGrantsUsed(b: *std.Build) void {
536 /// where the rule lives. 536 /// where the rule lives.
537 /// 537 ///
538 /// Textual, for checkGrantsUsed's reason and soundly for the same one: the 538 /// Textual, for checkGrantsUsed's reason and soundly for the same one: the
539 /// import is a relative path, so server.zig is the only file that can spell 539 /// import is a relative path, so the root is the only file that can spell
540 /// it. Globbed rather than listed for zigFilesIn's: a domain file added 540 /// it. Globbed rather than listed for zigFilesIn's: a domain file added
541 /// tomorrow is covered without anybody remembering to add it here. 541 /// tomorrow is covered without anybody remembering to add it here.
542 fn checkServerTestsReached(b: *std.Build) void { 542 const sibling_tests = [_]struct { root: []const u8, dir: []const u8, prefix: []const u8 }{
543 const root = "src/server/server.zig"; 543 .{ .root = "src/server/server.zig", .dir = "src/server", .prefix = "server_test_" },
544 .{ .root = "src/tui/wallview.zig", .dir = "src/tui", .prefix = "wall_test_" },
545 };
546
547 fn checkSiblingTestsReached(b: *std.Build) void {
548 for (sibling_tests) |spec| checkOneRootReaches(b, spec.root, spec.dir, spec.prefix);
549 }
550
551 fn checkOneRootReaches(b: *std.Build, root: []const u8, subdir: []const u8, prefix: []const u8) void {
544 const src = b.build_root.handle.readFileAlloc(b.allocator, root, 4 << 20) catch |err| 552 const src = b.build_root.handle.readFileAlloc(b.allocator, root, 4 << 20) catch |err|
545 fatal("server tests: cannot read {s} ({s})", .{ root, @errorName(err) }); 553 fatal("sibling tests: cannot read {s} ({s})", .{ root, @errorName(err) });
546 554
547 var dir = b.build_root.handle.openDir("src/server", .{ .iterate = true }) catch |e| 555 var dir = b.build_root.handle.openDir(subdir, .{ .iterate = true }) catch |e|
548 fatal("server tests: cannot open src/server/ ({s})", .{@errorName(e)}); 556 fatal("sibling tests: cannot open {s}/ ({s})", .{ subdir, @errorName(e) });
549 defer dir.close(); 557 defer dir.close();
550 558
551 var found: usize = 0; 559 var found: usize = 0;
552 var it = dir.iterate(); 560 var it = dir.iterate();
553 while (it.next() catch |e| 561 while (it.next() catch |e|
554 fatal("server tests: cannot list src/server/ ({s})", .{@errorName(e)})) |ent| 562 fatal("sibling tests: cannot list {s}/ ({s})", .{ subdir, @errorName(e) })) |ent|
555 { 563 {
556 if (ent.kind != .file) continue; 564 if (ent.kind != .file) continue;
557 if (!std.mem.startsWith(u8, ent.name, "server_test_")) continue; 565 if (!std.mem.startsWith(u8, ent.name, prefix)) continue;
558 if (!std.mem.endsWith(u8, ent.name, ".zig")) continue; 566 if (!std.mem.endsWith(u8, ent.name, ".zig")) continue;
559 found += 1; 567 found += 1;
560 if (std.mem.indexOf(u8, src, b.fmt("@import(\"{s}\")", .{ent.name})) == null) fatal( 568 if (std.mem.indexOf(u8, src, b.fmt("@import(\"{s}\")", .{ent.name})) == null) fatal(
561 "server tests: src/server/{s} exists, but {s} never writes " ++ 569 "sibling tests: {s}/{s} exists, but {s} never writes " ++
562 "@import(\"{s}\") — add the line to its test block; until then " ++ 570 "@import(\"{s}\") — add the line to its test block; until then " ++
563 "every test in that file is unreached and passes by not running", 571 "every test in that file is unreached and passes by not running",
564 .{ ent.name, root, ent.name }, 572 .{ subdir, ent.name, root, ent.name },
565 ); 573 );
566 } 574 }
567 // An empty glob would make this gate green forever, the doc gate's hazard. 575 // An empty glob would make this gate green forever, the doc gate's hazard.
568 if (found == 0) fatal( 576 if (found == 0) fatal(
569 "server tests: no src/server/server_test_*.zig at all — if the tests moved " ++ 577 "sibling tests: no {s}/{s}*.zig at all — if the tests moved " ++
570 "back into {s}, delete this check rather than leave it passing on " ++ 578 "back into {s}, delete this row rather than leave it passing on " ++
571 "an empty set", 579 "an empty set",
572 .{root}, 580 .{ subdir, prefix, root },
573 ); 581 );
574 582
575 const needle = "@import(\"server_test_"; 583 const needle = b.fmt("@import(\"{s}", .{prefix});
576 var rest = src; 584 var rest = src;
577 while (std.mem.indexOf(u8, rest, needle)) |at| { 585 while (std.mem.indexOf(u8, rest, needle)) |at| {
578 const open = at + "@import(\"".len; 586 const open = at + "@import(\"".len;
579 const close = std.mem.indexOfScalarPos(u8, rest, open, '"') orelse 587 const close = std.mem.indexOfScalarPos(u8, rest, open, '"') orelse
580 fatal("server tests: unterminated @import in {s}", .{root}); 588 fatal("sibling tests: unterminated @import in {s}", .{root});
581 const name = rest[open..close]; 589 const name = rest[open..close];
582 dir.access(name, .{}) catch fatal( 590 dir.access(name, .{}) catch fatal(
583 "server tests: {s} writes @import(\"{s}\"), but src/server/{s} does not " ++ 591 "sibling tests: {s} writes @import(\"{s}\"), but {s}/{s} does not " ++
584 "exist — point the line at the file the tests live in now; a " ++ 592 "exist — point the line at the file the tests live in now; a " ++
585 "line naming nothing is a domain of tests nobody runs", 593 "line naming nothing is a domain of tests nobody runs",
586 .{ root, name, name }, 594 .{ root, name, subdir, name },
587 ); 595 );
588 rest = rest[close..]; 596 rest = rest[close..];
589 } 597 }
@@ -806,8 +814,8 @@ pub fn build(b: *std.Build) void {
806 }; 814 };
807 // No grant outlives its @import (and the message names the row). 815 // No grant outlives its @import (and the message names the row).
808 checkGrantsUsed(b); 816 checkGrantsUsed(b);
809 // ...and no domain of server.zig's tests goes unreached (nor the reverse). 817 // ...and no domain of a split file's tests goes unreached (nor the reverse).
810 checkServerTestsReached(b); 818 checkSiblingTestsReached(b);
811 // ...and every module still sits in the folder of the thing that owns it. 819 // ...and every module still sits in the folder of the thing that owns it.
812 checkFolderRules(b); 820 checkFolderRules(b);
813 821