a73x

76c34f89

feat: the doc gate holds every file to a byte budget

a73x   2026-08-23 07:52

Commit message
feat: the doc gate holds every file to a byte budget

Tier 3 printed a count of 498 heavy doc blocks and gated nothing, so the
number never moved. A warn that cannot be discharged is a warn everyone
reads past.

The ruler first, because 498 was mostly noise: 336 sat on a decl with no
body, where comparing prose against a pure declaration means nothing.
Weight moved from lines to prose bytes — a comment line runs ~66 bytes
against code's ~20, so counting lines rewarded a 100-column comment and
changed its verdict on rewrap. `//!` headers answered to no decl at all
and now answer to a 1024-byte cap.

The gate is a per-file byte figure in docscheck.budget, met exactly. A
count would have let an already-flagged comment grow for free — a
mutation proved it, growing cmd.zig's marksOpen block without moving the
count. Exact match rather than <= because slack is room to regrow.

tools/ joins the corpus: a gate its author is exempt from is an argument,
not a rule. The fold found two unresolvable citations and three codenames
in this tool, and a message bug that reported src/docscheck.zig for a
file in tools/.

Its header then shed 5889 bytes down to 1021: the part that restated the
skip-class test is gone, the test's name carries the claim instead, and
the measurements behind the skips moved to decisions.md, where dated
findings belong.

Debt at 34 files, 185,744 bytes.

CLAUDE.md
Old New
@@ -8,8 +8,11 @@ in the client. Attach = one snapshot, then row deltas. Zig, Linux only, prototyp
8 ```sh 8 ```sh
9 ZIG=$HOME/Downloads/zig-x86_64-linux-0.15.2/zig # ghostty pin, 0.15.x only 9 ZIG=$HOME/Downloads/zig-x86_64-linux-0.15.2/zig # ghostty pin, 0.15.x only
10 make build test e2e # Makefile already points at it 10 make build test e2e # Makefile already points at it
11 $ZIG build check # fmt + unit tests + shell syntax — pre-commit gate (no make target) 11 make check # fmt + unit tests + shell syntax + comment-claim refs — pre-commit gate
12 make agent soak bench xversion 12 make ci # check + e2e + agent + throughput — the delivery gate
13 make agent soak bench throughput
14 make install # the four user binaries -> ~/.local/bin, ReleaseSafe
15 make xversion-build xversion # cross-version gate; XVER_OLD_WORKTREE points at the old checkout (default ..)
13 ``` 16 ```
14 17
15 First build after a clean checkout fetches ~30MB of QUIC deps (minutes, once). 18 First build after a clean checkout fetches ~30MB of QUIC deps (minutes, once).
@@ -21,7 +24,7 @@ Files are large and comment-dense (~44% of Zig bytes are `//`). Reading the repo
21 costs ~800k tokens; every token stays in context and is re-billed each turn. 24 costs ~800k tokens; every token stays in context and is re-billed each turn.
22 25
23 - **Never `cat` these:** `src/server.zig` (9.6k lines, ~110k tok), 26 - **Never `cat` these:** `src/server.zig` (9.6k lines, ~110k tok),
24 `test/e2e.sh` (7.0k), `docs/decisions.md` (4.7k), `src/wallview.zig` (3.0k), 27 `test/e2e.sh` (7.0k), `docs/decisions.md` (4.9k), `src/wallview.zig` (3.0k),
25 `src/interact.zig` (2.9k). 28 `src/interact.zig` (2.9k).
26 Use `grep -n` for the symbol, then `sed -n 'A,Bp'` for a window. 29 Use `grep -n` for the symbol, then `sed -n 'A,Bp'` for a window.
27 `src/client.zig` is 1.9k now — cheap enough to read. 30 `src/client.zig` is 1.9k now — cheap enough to read.
@@ -85,20 +88,32 @@ real pty), `wsclient` (browser stand-in), `rawmode`, `delaypipe`, `render`.
85 - Assert behavior, don't assume it. Mocks are assumptions. 88 - Assert behavior, don't assume it. Mocks are assumptions.
86 - Comments say *why*, not *how*. Existing ones are load-bearing — trim noise, 89 - Comments say *why*, not *how*. Existing ones are load-bearing — trim noise,
87 keep rationale. `zig build check` gates the claims (symbol refs must 90 keep rationale. `zig build check` gates the claims (symbol refs must
88 resolve, no history codenames in src comments); `zig build doc-report` 91 resolve, no history codenames in src comments, and every file's flagged
89 lists doc blocks that outweigh their decl. 92 prose meets its byte figure in `docscheck.budget` exactly); `zig build
93 doc-report` is the worklist. Raising a budget line is a diff someone has
94 to sign; lowering one is the point.
90 - Commit often with `--fixup`/`--squash`, autosquash before delivery. The final 95 - Commit often with `--fixup`/`--squash`, autosquash before delivery. The final
91 history should tell the feature's story, not the development's. 96 history should tell the feature's story, not the development's.
92 - `git-collab` tracks issues (`git-collab issue list`); the commit-msg hook 97 - `git-collab` tracks issues (`git-collab issue list`); the commit-msg hook
93 stamps `Patch:` trailers. Each collab write costs a ~15s origin sync — batch. 98 stamps `Patch:` trailers. Each collab write costs a ~15s origin sync — batch.
94 - `zig build check` before commit; capture `$?` before piping (`make test | tail` 99 - `make check` before commit, `make ci` before delivery; capture `$?` before
95 reports tail's exit code, not the build's). 100 piping (`make test | tail` reports tail's exit code, not the build's).
101 - **Any hand-run rig exports an isolated `XDG_STATE_HOME`.** Every attach
102 writes a tile into `$XDG_STATE_HOME/mux/wall`; the `make` scripts all
103 isolate it, but an ad-hoc repro or perf rig with the real `HOME` leaves a
104 dead `--sock /tmp/...` stripe on the user's wall per attach (49 of them
105 found 2026-08-22). `export XDG_STATE_HOME=$SCRATCH/state` first, always.
106 - **Never quote a speed number measured in the dev tree.** `zig build` is
107 Debug, and Debug builds ghostty with its page-integrity check on — a hash
108 map over every cell per mutation, ~600x on output-heavy work. `make test`,
109 `e2e` and `soak` all inherit it. `make throughput` builds its own
110 ReleaseSafe prefix for exactly this reason; so does `make install`.
96 111
97 ## Where the answers live 112 ## Where the answers live
98 113
99 `docs/roadmap.md` (595 ln) the ranked queue, but stale past 2026-08-16 — 114 `docs/roadmap.md` (~650 ln) the ranked queue, but stale past 2026-08-16 —
100 `git-collab issue list` is the live order · `docs/decisions.md` (4.7k ln, grep 115 `git-collab issue list` is the live order · `docs/decisions.md` (4.9k ln, grep
101 only) every decision + measurement · `docs/handoff.md` (199 ln) the original 116 only) every decision + measurement · `docs/handoff.md` (~200 ln) the original
102 design, historical · `README.md` user-facing usage. 117 design, historical · `README.md` user-facing usage.
103 118
104 ## Session hygiene 119 ## Session hygiene
build.zig
Old New
@@ -464,7 +464,7 @@ fn zigFilesIn(b: *std.Build, sub: []const u8, paths: *std.ArrayList([]const u8))
464 /// --index group; and `stdio = .inherit` makes the run unconditional, so no 464 /// --index group; and `stdio = .inherit` makes the run unconditional, so no
465 /// cache hit can stand in for a check that did not happen. Inherit also puts 465 /// cache hit can stand in for a check that did not happen. Inherit also puts
466 /// the violations on the terminal at the moment of failure instead of inside a 466 /// the violations on the terminal at the moment of failure instead of inside a
467 /// captured-stderr dump, and lets tier 3's one-line count be seen at all. 467 /// captured-stderr dump, and lets tier 3's budget lines be seen at all.
468 /// Every source file is still passed as a FILE arg: that is what declares the 468 /// Every source file is still passed as a FILE arg: that is what declares the
469 /// dependency and lets the build system resolve the paths. 469 /// dependency and lets the build system resolve the paths.
470 fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Build.Step) void { 470 fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Build.Step) void {
@@ -477,11 +477,15 @@ fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Bui
477 exe.use_llvm = true; 477 exe.use_llvm = true;
478 exe.use_lld = true; 478 exe.use_lld = true;
479 479
480 // The tool is inside its own corpus: a gate its author is exempt from is
481 // an argument, not a rule.
480 var checked: std.ArrayList([]const u8) = .empty; 482 var checked: std.ArrayList([]const u8) = .empty;
481 zigFilesIn(b, "src", &checked); 483 zigFilesIn(b, "src", &checked);
484 zigFilesIn(b, "tools", &checked);
482 var indexed: std.ArrayList([]const u8) = .empty; 485 var indexed: std.ArrayList([]const u8) = .empty;
483 zigFilesIn(b, "src", &indexed); 486 zigFilesIn(b, "src", &indexed);
484 zigFilesIn(b, "test", &indexed); 487 zigFilesIn(b, "test", &indexed);
488 zigFilesIn(b, "tools", &indexed);
485 // build.zig is cited by name in src/main.zig's comments and is a real 489 // build.zig is cited by name in src/main.zig's comments and is a real
486 // file of this repo, so it belongs in the corpus even though it is not 490 // file of this repo, so it belongs in the corpus even though it is not
487 // under src/ or test/. 491 // under src/ or test/.
@@ -504,13 +508,17 @@ fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Bui
504 for (checked.items) |p| run.addFileArg(b.path(p)); 508 for (checked.items) |p| run.addFileArg(b.path(p));
505 run.addArg("--index"); 509 run.addArg("--index");
506 for (indexed.items) |p| run.addFileArg(b.path(p)); 510 for (indexed.items) |p| run.addFileArg(b.path(p));
511 // A file arg, not a string: the step has to re-run when the budget
512 // itself is edited, or lowering a number would never be checked.
513 run.addArg("--budget");
514 run.addFileArg(b.path("docscheck.budget"));
507 // `.inherit` carries its own term check — a non-zero exit fails the 515 // `.inherit` carries its own term check — a non-zero exit fails the
508 // step — so this needs no expectExitCode, and adding one would 516 // step — so this needs no expectExitCode, and adding one would
509 // silently switch the step back to captured stdio. 517 // silently switch the step back to captured stdio.
510 run.stdio = .inherit; 518 run.stdio = .inherit;
511 if (variant.report) { 519 if (variant.report) {
512 run.setName("docscheck --report"); 520 run.setName("docscheck --report");
513 const step = b.step("doc-report", "List doc blocks heavier than the decl they document"); 521 const step = b.step("doc-report", "List the prose that outweighs the code it documents");
514 step.dependOn(&run.step); 522 step.dependOn(&run.step);
515 } else { 523 } else {
516 run.setName("docscheck"); 524 run.setName("docscheck");
docs/decisions.md
Old New
@@ -5355,3 +5355,82 @@ would have left nvim on the SIGWINCH race during drags. Measured: nvim
5355 `&lines x &columns` 26x80 → 60x180 after a 10-step grow burst; the e2e 5355 `&lines x &columns` 26x80 → 60x180 after a 10-step grow burst; the e2e
5356 witness is `printf '\e[?2048h'; cat -v`, no nvim dependency. Fix is in 5356 witness is `printf '\e[?2048h'; cat -v`, no nvim dependency. Fix is in
5357 muxd: every daemon (the LAN box included) needs the new build. 5357 muxd: every daemon (the LAN box included) needs the new build.
5358
5359 ## 2026-08-23 — the doc gate grows teeth, and indicts its own author
5360
5361 Tier 3 was a printed count nobody owned: 498 flagged blocks, report-only by
5362 design, unchanged week to week. A warn that cannot be discharged is a warn
5363 everyone learns to read past, so it became a gate.
5364
5365 Ruler first, because 498 was mostly noise. 336 of them sat on a decl with no
5366 body — a field, an enum tag, a bare const — where `block > span` compares
5367 prose against a line that is pure declaration. Exempting bodyless decls left
5368 162. Weight then moved from lines to prose bytes: a comment line runs ~66
5369 bytes and a code line ~20, so counting lines rewarded a 100-column comment
5370 over a wrapped one, and any rewrap changed the verdict. Bytes took it to 334.
5371 The median flagged block is 2.17x its decl, p90 is 7.3x — after the noise
5372 class is gone the density is real, not measurement error, so no threshold
5373 above 1.0 was chosen to make the number look better.
5374
5375 `//!` headers were skipped entirely (they answer to no decl) and are now held
5376 to an absolute 1024-byte cap. The cap is not taste: server.zig is the largest
5377 module in this tree and states its contract in 532 bytes. Zig's own stdlib is
5378 the outside oracle — 150 of 540 files carry a `//!` at all, median 170 bytes,
5379 and the largest header in the whole library is 4262 bytes (os/linux/seccomp.zig,
5380 documenting a kernel ABI). Eight files here are over the cap. Two of them,
5381 wallview.zig at 5310 and this tool at 5889, were larger than anything in std.
5382
5383 The gate is a per-file byte figure in docscheck.budget, met EXACTLY. The first
5384 version budgeted flag COUNTS and a mutation killed it: growing cmd.zig's
5385 `marksOpen` block — already that file's only flag — moved no count and passed
5386 green. A count lets flagged prose grow forever, which is the same warn-and-
5387 ignore failure one level down. Exact match rather than `<=` because slack left
5388 in a file is room to regrow for free; every change to the number is now a diff
5389 line someone signed. Starting debt: 34 files, 191,592 bytes.
5390
5391 tools/ went into the corpus at the same time. A gate its author is exempt from
5392 is an argument, not a rule, and the fold immediately found three violations in
5393 this tool (two illustrative `foo.zig` citations that resolved to nothing, three
5394 codenames spelled out as examples of what tier 2 forbids) plus a message bug —
5395 tier 1 and 2 errors were formatted with a hardcoded `src/` prefix and reported
5396 `src/docscheck.zig` for a file in tools/.
5397
5398 ### What moved out of docscheck.zig's header
5399
5400 Its header was 5889 bytes, most of it arguing for its own existence and
5401 restating `test "a CLI, wire or enum spelling is not a citation; a bare name
5402 is"` in prose — ~1400 bytes describing eight skip classes that the test
5403 already pins executably. Deleted. The measurements behind the skips are the
5404 part worth keeping, and they belong here:
5405
5406 - Skip classes were read off the corpus, not guessed: 1138 of 2049
5407 backtick-quoted tokens in src/ survive the filters.
5408 - Bare identifiers are eligible by class but dropped by the `module.symbol`
5409 root filter, and the reason is measured. Of 1138 bare citations in src/,
5410 1125 resolved and 13 did not — and every one of the 13 was a name this tool
5411 cannot verify: ngtcp2's C API (`ngtcp2_vec_copy`,
5412 `ngtcp2_conn_writev_stream`), ghostty's `max_title_len`, POSIX's
5413 `sockaddr_un` and `isig`, Zig std's `keep_sigpipe` and `NameTooLong`, plus
5414 `unrecordTile`, cited by client.zig precisely to say it does NOT exist. This
5415 repo wraps three foreign libraries and names their symbols constantly.
5416 Widening the corpus to ghostty, ngtcp2 and the Zig stdlib would fix it and
5417 was rejected: it puts machine-specific, sometimes-absent paths inside a
5418 pre-commit gate.
5419 - `.zig` file references are checked backticked or not — 10 backticked against
5420 99 bare, so backticking is not the discriminator. A reference carrying a
5421 directory prefix that is not src or test names a foreign tree and is skipped;
5422 that prefix is load-bearing documentation.
5423 - Trailing comments are not inspected: the 219 candidate lines in src/ are
5424 overwhelmingly `quic://` inside a string literal. They are still stripped
5425 from the corpus, which matters more — see `codeOf`.
5426 - The gap tier 1 leaves: renaming a MODULE silently un-checks every citation
5427 rooted at its old name. The file-reference rule covers module renames from
5428 the other side, which is why it is not narrowed the same way.
5429
5430 Doctrine the session settled on, and the reason none of this was written as a
5431 comment beside the code it justifies: a justification is a test. A comment
5432 arguing that behavior is correct should be a `test` whose NAME is the claim —
5433 same file, executable, and it fails when the claim stops being true. Writing
5434 text to justify text (an inline "reviewed" marker, an accept-list of forgiven
5435 blocks) was considered and rejected: its existence should be justification
5436 enough.
docscheck.budget
Old New
@@ -0,0 +1,34 @@
1 client_core_wasm_check.zig 0
2 client_core.zig 756
3 client.zig 16274
4 cmd.zig 117
5 delta.zig 1659
6 docscheck.zig 209
7 engine.zig 6185
8 handoff.zig 700
9 interact.zig 21074
10 keymap.zig 302
11 main.zig 4422
12 muxa.zig 13402
13 mux_main.zig 3597
14 paint.zig 3631
15 predict.zig 5894
16 protocol.zig 7237
17 proxy.zig 1300
18 pty.zig 497
19 quic_client.zig 2917
20 quic_server.zig 5847
21 quic.zig 1901
22 replica.zig 620
23 select.zig 3029
24 server.zig 35479
25 shellint.zig 2945
26 sockpath.zig 1119
27 spawn.zig 2471
28 testtmp.zig 0
29 wallview.zig 27094
30 wall.zig 3731
31 wasm_core.zig 2841
32 webhub_main.zig 496
33 webhub.zig 5751
34 xdg.zig 2247
tools/docscheck.zig
Old New
@@ -1,107 +1,24 @@
1 //! The comment-discipline gate: "comments say why, not how" and "code, 1 //! The comment-discipline gate. Three tiers, all gates:
2 //! comments, docs drift" were prose in CLAUDE.md for a week and drifted about
3 //! fifty findings' worth. Prose is instruction; a check that RUNS is
4 //! codification, which is the same move build.zig's comptime layer laws make.
5 //! 2 //!
6 //! Three tiers, two of them gates: 3 //! 1. Every symbol a comment cites must resolve: a `module.symbol` rooted
7 //! 4 //! at a module of this repo, or a file reference with no foreign
8 //! 1. Every symbol a comment cites must resolve. A drift tripwire, not a 5 //! directory prefix. The name must appear in the indexed CODE. Skip
9 //! compiler: a cited name has to appear as a word SOMEWHERE in the CODE 6 //! classes are pinned in the tests.
10 //! of src/ or test/ (definition or use — comment lines contribute 7 //! 2. No project-history codenames — a bare milestone letter and number,
11 //! nothing, or a citation would resolve against itself). Renaming 8 //! or a phase and its ordinal. The EVENT ("the multi-session daemon")
12 //! `sendResync` and leaving the old spelling in a comment is the drift 9 //! survives; the codename names nothing a reader can look up.
13 //! this catches. 10 //! 3. Prose that outweighs the code it documents: a doc block heavier in
14 //! 2. No project-history codenames in src/*.zig comments. "M18" and 11 //! bytes than its decl, or a `//!` header past the cap. A bodyless
15 //! "Phase 3c" name nothing a reader can look up from the code; the EVENT 12 //! decl is exempt. Every file meets its figure in docscheck.budget
16 //! ("the multi-session daemon") survives. Dated journals — decisions.md, 13 //! EXACTLY, so the number moves only as a diff someone signed.
17 //! roadmap.md, handoff.md, commit messages — are where codenames belong
18 //! and are deliberately out of scope, as are test/ and web/.
19 //! 3. Doc blocks that outweigh the decl they document. REPORT ONLY, never a
20 //! gate: long comments are often earned in this repo, and the report
21 //! exists so a human looks. `zig build check` prints the count and
22 //! nothing else; `zig build doc-report` lists them.
23 //! 14 //!
24 //! Usage: 15 //! Usage:
25 //! docscheck [--report] --check FILE... --index FILE... 16 //! docscheck [--report] --check FILE... --index FILE... --budget FILE
26 //!
27 //! --check names the files whose comments are inspected (src/*.zig).
28 //! --index names the files that make up the "does this name exist" corpus
29 //! (src/*.zig, test/*.zig, build.zig). Both groups arrive as build-graph file
30 //! args so the step re-runs when any of their CONTENTS change — the lesson
31 //! web/verify.js paid for. Zero files in either group is a hard error: a check
32 //! that never ran passes green forever (decisions.md).
33 //!
34 //! ## What tier 1 checks, and what it deliberately skips
35 //!
36 //! Skip classes were chosen by reading the corpus, not guessed — 1138 of 2049
37 //! backtick-quoted tokens in src/ survive the filters. False positives are the
38 //! death of a gate, so when a class was ambiguous it was dropped and named
39 //! here:
40 //!
41 //! phrase anything with whitespace: shell commands (`zig build test`),
42 //! CLI invocations (`mux wall`), key chords (`Ctrl-\ w`),
43 //! argument spellings (`mux TARGET`), prose emphasis.
44 //! flag leading `-`: `--via`, `--sock`. A flag is a CLI spelling,
45 //! not an identifier, and lives in main.zig's parser as a
46 //! string literal that grep -w would match by accident anyway.
47 //! wire contains any of / : # ? \ and friends: `quic://`,
48 //! `HOST#SESSION`, `?2004l`. Wire and protocol spellings.
49 //! enumlit leading `.`: `.none`, `.never`. Resolving an enum tag needs
50 //! the type, which this tool does not have.
51 //! numeric leading digit: `0x1b`, `2004`.
52 //! short under 3 characters: `x`, `n`, `fd`. Too short to be a drift
53 //! signal — every 2-letter word matches something.
54 //! keyword Zig keywords and primitive types. `else` is not a citation.
55 //! placeholder ALL-CAPS: `HOST`, `PATH`, `CMDLINE`. Usage-text
56 //! placeholders; Zig code has no SCREAMING_CASE consts.
57 //!
58 //! ...and one more, the largest and the one that cost the most to decide:
59 //! 17 //!
60 //! bare an unqualified identifier — `sendResync`, `paint_mu`, 18 //! --check is inspected; --index answers "does this name exist". Both are
61 //! `deinit`. NOT CHECKED, and the reason is measured. Of 1138 19 //! build-graph file args, so the step re-runs when their CONTENTS change.
62 //! such citations in src/, 1125 resolved and 13 did not, and 20 //! An empty group, or no --budget, is fatal: a check that never ran is green
63 //! every one of the 13 was a name this tool cannot possibly 21 //! forever.
64 //! verify: ngtcp2's C API (`ngtcp2_vec_copy`,
65 //! `ngtcp2_conn_writev_stream`), ghostty's `max_title_len`,
66 //! POSIX's `sockaddr_un` and `isig`, Zig std's
67 //! `keep_sigpipe` and `NameTooLong` — plus `unrecordTile`,
68 //! cited by client.zig precisely to say it does NOT exist.
69 //! This repo wraps three foreign libraries and names their
70 //! symbols constantly; a bare identifier gives the tool
71 //! nothing to tell "renamed last week" from "belongs to
72 //! libc". Widening the corpus to ghostty, ngtcp2 and the Zig
73 //! stdlib would fix it and was rejected: it puts
74 //! machine-specific, sometimes-absent paths inside a
75 //! pre-commit gate, which is the skip hazard again.
76 //!
77 //! What IS checked is `module.symbol` rooted at one of this repo's own
78 //! modules — `client.lostMsg`, `protocol.MsgType`, `quic.default_port`. Both
79 //! halves are then knowable: the root is a src/*.zig this tool was handed,
80 //! and the tail must appear in code. That form is also exactly the citation
81 //! that goes stale when a module's member is renamed, which is the drift
82 //! worth catching. A citation rooted anywhere else — `std.options.x`,
83 //! `error.BadPayload`, `conn.r` — names something outside this repo's
84 //! jurisdiction and is skipped whole. So is a backticked `wall.zig`, which
85 //! reads as `module.symbol` with `zig` for a symbol: the rule below owns
86 //! file references, and this tier must not answer for them.
87 //!
88 //! The gap this leaves: renaming a MODULE silently un-checks every citation
89 //! rooted at its old name. The `.zig` file rule below covers module renames
90 //! from the other side, which is why it is not narrowed the same way.
91 //!
92 //! `.zig` file references are checked whether or not they are backticked —
93 //! measured: 10 backticked against 99 bare, and the bare ones are this repo's
94 //! own modules, so backticking is not the discriminator. A reference carrying
95 //! a directory prefix that is not `src` or `test` (`osc/parsers/foo.zig`,
96 //! `lib/types.zig`) names a foreign tree — ghostty-vt, the Zig stdlib — and is
97 //! skipped. That prefix is therefore load-bearing documentation: it is how a
98 //! reader, and this tool, tell "not in this repo" from a stale name.
99 //!
100 //! Trailing comments (code, then `//` on the same line) are not INSPECTED:
101 //! the 219 candidate lines in src/ are overwhelmingly `quic://` inside a
102 //! string literal, so only lines whose first non-space characters are `//`
103 //! are read for citations. They are still stripped from the corpus, which is
104 //! the other direction and matters more — see `codeOf`.
105 22
106 const std = @import("std"); 23 const std = @import("std");
107 24
@@ -134,8 +51,8 @@ fn isWordByte(c: u8) bool {
134 } 51 }
135 52
136 /// A comment line for this tool's purposes: the first non-space bytes are 53 /// A comment line for this tool's purposes: the first non-space bytes are
137 /// `//`. Covers `//`, `///` and `//!` alike; excludes trailing comments, for 54 /// `//`. Covers `//`, `///` and `//!` alike. Trailing comments are excluded
138 /// the reason in the header. 55 /// because src/'s are overwhelmingly a wire spelling inside a literal.
139 fn commentBody(line: []const u8) ?[]const u8 { 56 fn commentBody(line: []const u8) ?[]const u8 {
140 const t = std.mem.trimLeft(u8, line, " \t"); 57 const t = std.mem.trimLeft(u8, line, " \t");
141 if (!std.mem.startsWith(u8, t, "//")) return null; 58 if (!std.mem.startsWith(u8, t, "//")) return null;
@@ -146,7 +63,7 @@ fn commentBody(line: []const u8) ?[]const u8 {
146 /// everything from an unquoted `//` onward is cut off. A word that exists 63 /// everything from an unquoted `//` onward is cut off. A word that exists
147 /// only inside quotes is no evidence that a symbol exists — `zig` resolved 64 /// only inside quotes is no evidence that a symbol exists — `zig` resolved
148 /// for exactly that reason, off `b.path("src/main.zig")` in build.zig, and 65 /// for exactly that reason, off `b.path("src/main.zig")` in build.zig, and
149 /// `zig` is the tail of every `foo.zig` citation. 66 /// `zig` is the tail of every module citation.
150 /// 67 ///
151 /// Blanking IN PLACE is deliberate: the corpus stores slices of these 68 /// Blanking IN PLACE is deliberate: the corpus stores slices of these
152 /// buffers, so a stripped copy would hand it keys that dangle one line later. 69 /// buffers, so a stripped copy would hand it keys that dangle one line later.
@@ -175,6 +92,13 @@ fn codeOf(line: []u8) []u8 {
175 return line; 92 return line;
176 } 93 }
177 94
95 /// A comment line's weight is its prose: the `///` is syntax, and the
96 /// indentation is the decl's, not the comment's.
97 fn proseBytes(line: []const u8) usize {
98 const t = std.mem.trim(u8, line, " \t\r");
99 return std.mem.trimLeft(u8, t[@min(3, t.len)..], " ").len;
100 }
101
178 fn indentOf(line: []const u8) usize { 102 fn indentOf(line: []const u8) usize {
179 var i: usize = 0; 103 var i: usize = 0;
180 while (i < line.len and line[i] == ' ') i += 1; 104 while (i < line.len and line[i] == ' ') i += 1;
@@ -249,8 +173,8 @@ const Finding = struct {
249 /// "phase" is domain vocabulary here — `cmd.phase` is a live 173 /// "phase" is domain vocabulary here — `cmd.phase` is a live
250 /// field with 10 comment mentions — so the digit is required. 174 /// field with 10 comment mentions — so the digit is required.
251 fn codenameAt(s: []const u8, i: usize) usize { 175 fn codenameAt(s: []const u8, i: usize) usize {
252 // M-web: the browser-client milestone. Both ends are checked, or 176 // The browser-client milestone spells its letter with a word after the
253 // "M-website" would be a codename. 177 // dash, so both ends are checked or a real word would read as a codename.
254 if (std.ascii.startsWithIgnoreCase(s[i..], "M-web")) { 178 if (std.ascii.startsWithIgnoreCase(s[i..], "M-web")) {
255 if ((i == 0 or !isWordByte(s[i - 1])) and 179 if ((i == 0 or !isWordByte(s[i - 1])) and
256 (i + 5 == s.len or !isWordByte(s[i + 5]))) return 5; 180 (i + 5 == s.len or !isWordByte(s[i + 5]))) return 5;
@@ -322,7 +246,8 @@ pub fn main() !u8 {
322 var index_files: std.ArrayList([]const u8) = .empty; 246 var index_files: std.ArrayList([]const u8) = .empty;
323 defer index_files.deinit(alloc); 247 defer index_files.deinit(alloc);
324 248
325 var group: enum { none, check, index } = .none; 249 var budget_path: ?[]const u8 = null;
250 var group: enum { none, check, index, budget } = .none;
326 var ai: usize = 1; 251 var ai: usize = 1;
327 while (ai < args.len) : (ai += 1) { 252 while (ai < args.len) : (ai += 1) {
328 const a = args[ai]; 253 const a = args[ai];
@@ -332,14 +257,21 @@ pub fn main() !u8 {
332 group = .check; 257 group = .check;
333 } else if (std.mem.eql(u8, a, "--index")) { 258 } else if (std.mem.eql(u8, a, "--index")) {
334 group = .index; 259 group = .index;
260 } else if (std.mem.eql(u8, a, "--budget")) {
261 group = .budget;
335 } else switch (group) { 262 } else switch (group) {
336 .none => return usage(), 263 .none => return usage(),
337 .check => try check_files.append(alloc, a), 264 .check => try check_files.append(alloc, a),
338 .index => try index_files.append(alloc, a), 265 .index => try index_files.append(alloc, a),
266 .budget => budget_path = a,
339 } 267 }
340 } 268 }
341 // A gate handed nothing to gate is the "check that never ran" failure 269 // A gate handed nothing to gate is the "check that never ran" failure
342 // mode, and it passes green forever. Refuse instead. 270 // mode, and it passes green forever. Refuse instead.
271 if (budget_path == null) {
272 err("docscheck: no --budget file — the gate has nothing to hold the count to\n", .{});
273 return 2;
274 }
343 if (check_files.items.len == 0 or index_files.items.len == 0) { 275 if (check_files.items.len == 0 or index_files.items.len == 0) {
344 err("docscheck: empty --check or --index group ({d} check, {d} index) — " ++ 276 err("docscheck: empty --check or --index group ({d} check, {d} index) — " ++
345 "a gate with no inputs passes green forever\n", .{ check_files.items.len, index_files.items.len }); 277 "a gate with no inputs passes green forever\n", .{ check_files.items.len, index_files.items.len });
@@ -406,6 +338,9 @@ pub fn main() !u8 {
406 var lines: std.ArrayList([]const u8) = .empty; 338 var lines: std.ArrayList([]const u8) = .empty;
407 defer lines.deinit(alloc); 339 defer lines.deinit(alloc);
408 340
341 var counts: std.ArrayList(Tally) = .empty;
342 defer counts.deinit(alloc);
343
409 for (check_files.items) |p| { 344 for (check_files.items) |p| {
410 const f = std.fs.cwd().openFile(p, .{}) catch |e| { 345 const f = std.fs.cwd().openFile(p, .{}) catch |e| {
411 err("docscheck: cannot open {s} ({s})\n", .{ p, @errorName(e) }); 346 err("docscheck: cannot open {s} ({s})\n", .{ p, @errorName(e) });
@@ -459,8 +394,9 @@ pub fn main() !u8 {
459 // above already owns those. Left here it reads as 394 // above already owns those. Left here it reads as
460 // `module.symbol` and asks whether `zig` is a symbol. 395 // `module.symbol` and asks whether `zig` is a symbol.
461 if (std.mem.endsWith(u8, tok, ".zig")) continue; 396 if (std.mem.endsWith(u8, tok, ".zig")) continue;
462 // Only `module.symbol`, rooted at one of THIS repo's modules. 397 // Only `module.symbol`, rooted at one of THIS repo's modules:
463 // See the header: a bare identifier is unverifiable here. 398 // a bare identifier gives no way to tell "renamed last week"
399 // from "belongs to a library this repo wraps" (decisions.md).
464 var segs = std.mem.splitScalar(u8, tok, '.'); 400 var segs = std.mem.splitScalar(u8, tok, '.');
465 const root = segs.next() orelse continue; 401 const root = segs.next() orelse continue;
466 var modfile: [64]u8 = undefined; 402 var modfile: [64]u8 = undefined;
@@ -480,64 +416,21 @@ pub fn main() !u8 {
480 } 416 }
481 } 417 }
482 418
483 // Tier 3 — doc blocks heavier than the decl they document. Line 419 const flagged = try heavyBlocks(alloc, lines.items, name, &t3);
484 // arithmetic, not a parser, and exact enough because `zig fmt --check` 420 try counts.append(alloc, .{ .name = name, .n = flagged });
485 // is already a gate: indentation is canonical, so a decl's closing
486 // brace sits at the decl's own indent and nowhere else.
487 var li: usize = 0;
488 while (li < lines.items.len) {
489 const t = std.mem.trimLeft(u8, lines.items[li], " \t");
490 // `//!` is a file header, attached to no decl.
491 if (!std.mem.startsWith(u8, t, "///")) {
492 li += 1;
493 continue;
494 }
495 const block_start = li;
496 while (li < lines.items.len and
497 std.mem.startsWith(u8, std.mem.trimLeft(u8, lines.items[li], " \t"), "///")) li += 1;
498 const block = li - block_start;
499 if (li >= lines.items.len) break;
500 const decl = li;
501 const dt = std.mem.trimLeft(u8, lines.items[decl], " \t");
502 if (dt.len == 0 or std.mem.startsWith(u8, dt, "//")) continue;
503 const indent = indentOf(lines.items[decl]);
504 // The decl's span: every following line indented deeper, plus the
505 // closing brace that returns to the decl's own indent.
506 var last = decl;
507 var j = decl + 1;
508 while (j < lines.items.len) : (j += 1) {
509 const lt = std.mem.trim(u8, lines.items[j], " \t\r");
510 if (lt.len == 0) continue;
511 if (indentOf(lines.items[j]) > indent) {
512 last = j;
513 continue;
514 }
515 if (indentOf(lines.items[j]) == indent and lt[0] == '}') last = j;
516 break;
517 }
518 const span = last - decl + 1;
519 if (block > span) {
520 const nm = declName(dt);
521 const s = try std.fmt.allocPrint(alloc, "{s}:{d} block={d} decl={d} {s}\n", .{ name, decl + 1, block, span, nm });
522 try t3.append(alloc, s);
523 }
524 }
525 } 421 }
526 422
527 for (t1.items) |v| 423 for (t1.items) |v|
528 err("src/{s}:{d}: comment cites `{s}`, which appears nowhere in src/ or test/\n", .{ v.file, v.line, v.text }); 424 err("{s}:{d}: comment cites `{s}`, which appears nowhere in src/ or test/\n", .{ v.file, v.line, v.text });
529 for (t2.items) |v| 425 for (t2.items) |v|
530 err("src/{s}:{d}: comment names the project-history codename \"{s}\" — " ++ 426 err("{s}:{d}: comment names the project-history codename \"{s}\" — " ++
531 "name the event instead; codenames belong in docs/decisions.md\n", .{ v.file, v.line, v.text }); 427 "name the event instead; codenames belong in docs/decisions.md\n", .{ v.file, v.line, v.text });
532 428
533 if (report) { 429 if (report) {
534 out("doc blocks heavier than the decl they document ({d}):\n", .{t3.items.len}); 430 out("flagged doc blocks ({d}):\n", .{t3.items.len});
535 for (t3.items) |s| out(" {s}", .{s}); 431 for (t3.items) |s| out(" {s}", .{s});
536 out("\nReport only, never a gate: a long comment is often earned here.\n", .{}); 432 out("\nThe worklist. `zig build check` holds each file to its line in the budget.\n", .{});
537 } else { 433 } else if (try gate(alloc, budget_path.?, counts.items)) return 1;
538 out("docscheck: {d} files, {d} doc blocks outweigh their decl " ++
539 "(`zig build doc-report` lists them)\n", .{ check_files.items.len, t3.items.len });
540 }
541 434
542 if (t1.items.len + t2.items.len > 0) { 435 if (t1.items.len + t2.items.len > 0) {
543 err("docscheck: {d} unresolved citation(s), {d} codename(s)\n", .{ t1.items.len, t2.items.len }); 436 err("docscheck: {d} unresolved citation(s), {d} codename(s)\n", .{ t1.items.len, t2.items.len });
@@ -548,6 +441,153 @@ pub fn main() !u8 {
548 441
549 /// Best-effort name for the report's third column. Cosmetic: the file:line is 442 /// Best-effort name for the report's third column. Cosmetic: the file:line is
550 /// what a reader navigates by. 443 /// what a reader navigates by.
444 const header_max = 1024;
445
446 const Tally = struct { name: []const u8, n: usize };
447
448 fn parseBudget(alloc: std.mem.Allocator, text: []const u8) ![]Tally {
449 var list: std.ArrayList(Tally) = .empty;
450 errdefer list.deinit(alloc);
451 var it = std.mem.splitScalar(u8, text, '\n');
452 while (it.next()) |raw| {
453 const line = std.mem.trim(u8, raw, " \t\r");
454 if (line.len == 0) continue;
455 var f = std.mem.tokenizeAny(u8, line, " \t");
456 const name = f.next() orelse return error.BadBudgetLine;
457 const num = f.next() orelse return error.BadBudgetLine;
458 if (f.next() != null) return error.BadBudgetLine;
459 const allowed = std.fmt.parseInt(usize, num, 10) catch return error.BadBudgetLine;
460 try list.append(alloc, .{ .name = name, .n = allowed });
461 }
462 return list.toOwnedSlice(alloc);
463 }
464 const Verdict = enum { ok, over, under, unlisted };
465
466 /// Each file is held to its own line in the budget: the count may not rise,
467 /// and may not sit below it either. Returns true when the tree is off budget.
468 fn gate(alloc: std.mem.Allocator, path: []const u8, counts: []const Tally) !bool {
469 const bf = std.fs.cwd().openFile(path, .{}) catch |e| {
470 err("docscheck: cannot open budget {s} ({s})\n", .{ path, @errorName(e) });
471 std.process.exit(2);
472 };
473 const btext = try bf.readToEndAlloc(alloc, 1 << 20);
474 bf.close();
475 defer alloc.free(btext);
476 const budget = parseBudget(alloc, btext) catch |e| {
477 err("docscheck: {s} does not parse ({s}) — one `FILE COUNT` per line\n", .{ path, @errorName(e) });
478 std.process.exit(2);
479 };
480 defer alloc.free(budget);
481
482 var breach: usize = 0;
483 for (counts) |c| switch (verdict(budget, c.name, c.n)) {
484 .ok => {},
485 .over => {
486 breach += 1;
487 err("{s}: {d} bytes of flagged prose, budget {d} — shrink a comment or grow the code " ++
488 "into it; raising the budget is a diff line that has to argue for itself\n", .{ c.name, c.n, budgetOf(budget, c.name) });
489 },
490 .under => {
491 breach += 1;
492 err("{s}: {d} bytes of flagged prose, budget {d} — lower the budget to {d}; " ++
493 "slack left in the file is room to regrow for free\n", .{ c.name, c.n, budgetOf(budget, c.name), c.n });
494 },
495 .unlisted => {
496 breach += 1;
497 err("{s}: not in {s} — add `{s} {d}`\n", .{ c.name, path, c.name, c.n });
498 },
499 };
500 if (breach == 0) return false;
501 err("docscheck: {d} file(s) off budget\n", .{breach});
502 return true;
503 }
504
505 fn budgetOf(budget: []const Tally, name: []const u8) usize {
506 for (budget) |b| if (std.mem.eql(u8, b.name, name)) return b.n;
507 return 0;
508 }
509
510 fn verdict(budget: []const Tally, name: []const u8, flags: usize) Verdict {
511 for (budget) |b| {
512 if (!std.mem.eql(u8, b.name, name)) continue;
513 if (flags > b.n) return .over;
514 if (flags < b.n) return .under;
515 return .ok;
516 }
517 return .unlisted;
518 }
519
520 fn heavyBlocks(
521 alloc: std.mem.Allocator,
522 lines: []const []const u8,
523 name: []const u8,
524 t3: *std.ArrayList([]const u8),
525 ) !usize {
526 var flagged: usize = 0;
527 // Tier 3 — doc blocks heavier than the decl they document. Line
528 // arithmetic, not a parser, and exact enough because `zig fmt --check`
529 // is already a gate: indentation is canonical, so a decl's closing
530 // brace sits at the decl's own indent and nowhere else.
531
532 // A `//!` header answers to no decl, so it answers to the cap: server.zig
533 // states the contract of the largest module in this tree in 532 bytes.
534 var header: usize = 0;
535 var hi: usize = 0;
536 while (hi < lines.len and
537 std.mem.startsWith(u8, std.mem.trimLeft(u8, lines[hi], " \t"), "//!")) : (hi += 1)
538 header += proseBytes(lines[hi]);
539 if (header > header_max) {
540 flagged += header;
541 const s = try std.fmt.allocPrint(alloc, "{s}:1 header={d}b cap={d}b\n", .{ name, header, header_max });
542 try t3.append(alloc, s);
543 }
544
545 var li: usize = 0;
546 while (li < lines.len) {
547 const t = std.mem.trimLeft(u8, lines[li], " \t");
548 // `//!` is a file header, attached to no decl.
549 if (!std.mem.startsWith(u8, t, "///")) {
550 li += 1;
551 continue;
552 }
553 var block: usize = 0;
554 while (li < lines.len and
555 std.mem.startsWith(u8, std.mem.trimLeft(u8, lines[li], " \t"), "///")) : (li += 1)
556 block += proseBytes(lines[li]);
557 if (li >= lines.len) break;
558 const decl = li;
559 const dt = std.mem.trimLeft(u8, lines[decl], " \t");
560 if (dt.len == 0 or std.mem.startsWith(u8, dt, "//")) continue;
561 const indent = indentOf(lines[decl]);
562 // The decl's span: every following line indented deeper, plus the
563 // closing brace that returns to the decl's own indent.
564 var last = decl;
565 var j = decl + 1;
566 while (j < lines.len) : (j += 1) {
567 const lt = std.mem.trim(u8, lines[j], " \t\r");
568 if (lt.len == 0) continue;
569 if (indentOf(lines[j]) > indent) {
570 last = j;
571 continue;
572 }
573 if (indentOf(lines[j]) == indent and lt[0] == '}') last = j;
574 break;
575 }
576 // A decl with no body has nothing for a comment to outweigh, so the
577 // comparison is meaningless there rather than merely lenient.
578 if (last == decl) continue;
579 var span: usize = 0;
580 for (lines[decl .. last + 1]) |l| span += std.mem.trim(u8, l, " \t\r").len;
581 if (block > span) {
582 flagged += block;
583 const nm = declName(dt);
584 const s = try std.fmt.allocPrint(alloc, "{s}:{d} block={d}b decl={d}b {s}\n", .{ name, decl + 1, block, span, nm });
585 try t3.append(alloc, s);
586 }
587 }
588 return flagged;
589 }
590
551 fn declName(dt: []const u8) []const u8 { 591 fn declName(dt: []const u8) []const u8 {
552 var it = std.mem.tokenizeAny(u8, dt, " \t(:="); 592 var it = std.mem.tokenizeAny(u8, dt, " \t(:=");
553 while (it.next()) |w| { 593 while (it.next()) |w| {
@@ -580,7 +620,7 @@ test "codenames are recognised, domain vocabulary is not" {
580 620
581 test "the corpus is code: quotes and trailing comments contribute nothing" { 621 test "the corpus is code: quotes and trailing comments contribute nothing" {
582 // `zig` is the one that mattered: it reached the corpus only through 622 // `zig` is the one that mattered: it reached the corpus only through
583 // path literals, and it is the tail of every `foo.zig` citation. 623 // path literals, and it is the tail of every module citation.
584 var path = " const p = b.path(\"src/main.zig\");".*; 624 var path = " const p = b.path(\"src/main.zig\");".*;
585 const c0 = codeOf(&path); 625 const c0 = codeOf(&path);
586 try std.testing.expect(std.mem.indexOf(u8, c0, "path") != null); 626 try std.testing.expect(std.mem.indexOf(u8, c0, "path") != null);
@@ -606,7 +646,7 @@ test "the corpus is code: quotes and trailing comments contribute nothing" {
606 try std.testing.expectEqual(@as(usize, 0), codeOf(&ml).len); 646 try std.testing.expectEqual(@as(usize, 0), codeOf(&ml).len);
607 } 647 }
608 648
609 test "citation classes match what the corpus actually contains" { 649 test "a CLI, wire or enum spelling is not a citation; a bare name is" {
610 try std.testing.expectEqual(Class.check, classify("sendResync")); 650 try std.testing.expectEqual(Class.check, classify("sendResync"));
611 try std.testing.expectEqual(Class.check, classify("state_since_attach")); 651 try std.testing.expectEqual(Class.check, classify("state_since_attach"));
612 try std.testing.expectEqual(Class.check, classify("MsgType")); 652 try std.testing.expectEqual(Class.check, classify("MsgType"));
@@ -654,3 +694,119 @@ test "only leading // is a comment, so string literals are left alone" {
654 try std.testing.expect(commentBody(" const u = \"quic://box:4433\";") == null); 694 try std.testing.expect(commentBody(" const u = \"quic://box:4433\";") == null);
655 try std.testing.expect(commentBody(" ov.setMode(); // raw") == null); 695 try std.testing.expect(commentBody(" ov.setMode(); // raw") == null);
656 } 696 }
697
698 test "a doc block on a one-line decl is never heavy" {
699 const alloc = std.testing.allocator;
700 var t3: std.ArrayList([]const u8) = .empty;
701 defer {
702 for (t3.items) |s| alloc.free(s);
703 t3.deinit(alloc);
704 }
705 // 336 of the original 498 flags looked like this. A field has no body for
706 // a comment to outweigh, so `block > span` was comparing prose against a
707 // line that is pure declaration — the ruler, not the comment, was wrong.
708 const src = [_][]const u8{
709 " /// How long a dial waits before it gives up.",
710 " /// Shorter than the shell's own settle, deliberately.",
711 " /// Measured on the LAN box rather than guessed.",
712 " deadline_ms: u32 = 250,",
713 "};",
714 };
715 _ = try heavyBlocks(alloc, &src, "fixture.zig", &t3);
716 try std.testing.expectEqual(@as(usize, 0), t3.items.len);
717 }
718
719 test "weight is bytes, so a comment cannot get lighter by wrapping" {
720 const alloc = std.testing.allocator;
721 var t3: std.ArrayList([]const u8) = .empty;
722 defer {
723 for (t3.items) |s| alloc.free(s);
724 t3.deinit(alloc);
725 }
726 // Two long lines of prose over three short lines of code. Counting lines
727 // calls that light and rewards a 100-column comment over a wrapped one.
728 const src = [_][]const u8{
729 "/// The reconnect backoff doubles from 50ms and stops at two seconds, which is where a human gives up",
730 "/// waiting and reaches for the keyboard, so climbing past it buys nothing anyone is still there to see.",
731 "fn backoff(n: u32) u32 {",
732 " return @min(50 << n, 2000);",
733 "}",
734 };
735 _ = try heavyBlocks(alloc, &src, "fixture.zig", &t3);
736 try std.testing.expectEqual(@as(usize, 1), t3.items.len);
737 }
738
739 test "a module header is weighed against the cap, not against a decl" {
740 const alloc = std.testing.allocator;
741 var t3: std.ArrayList([]const u8) = .empty;
742 defer {
743 for (t3.items) |s| alloc.free(s);
744 t3.deinit(alloc);
745 }
746 var short = [_][]const u8{"//! Reaches a muxd and says what it came to say."};
747 _ = try heavyBlocks(alloc, &short, "short.zig", &t3);
748 try std.testing.expectEqual(@as(usize, 0), t3.items.len);
749
750 var long: std.ArrayList([]const u8) = .empty;
751 defer long.deinit(alloc);
752 // 40 lines of contract is a header telling a story. server.zig is the
753 // largest module in this tree and states its contract in half the cap.
754 for (0..40) |_| try long.append(alloc, "//! sixty-four bytes of module contract, give or take a word or.");
755 _ = try heavyBlocks(alloc, long.items, "long.zig", &t3);
756 try std.testing.expectEqual(@as(usize, 1), t3.items.len);
757 }
758
759 test "a budget is met exactly, so the number can only move in a diff" {
760 const budget = [_]Tally{
761 .{ .name = "client.zig", .n = 12 },
762 .{ .name = "wall.zig", .n = 0 },
763 };
764 try std.testing.expectEqual(Verdict.ok, verdict(&budget, "client.zig", 12));
765 try std.testing.expectEqual(Verdict.over, verdict(&budget, "client.zig", 13));
766 // Slack is refused too: a file that improved and left its budget high can
767 // regrow into the gap for free, which is how a ratchet stops ratcheting.
768 try std.testing.expectEqual(Verdict.under, verdict(&budget, "client.zig", 11));
769 try std.testing.expectEqual(Verdict.ok, verdict(&budget, "wall.zig", 0));
770 // An unlisted file cannot pass by being unknown.
771 try std.testing.expectEqual(Verdict.unlisted, verdict(&budget, "new.zig", 0));
772 }
773
774 test "a budget line that does not parse fails the gate rather than vanishing" {
775 const alloc = std.testing.allocator;
776 const good = try parseBudget(alloc, "client.zig 12\n\nwall.zig 0\n");
777 defer alloc.free(good);
778 try std.testing.expectEqual(@as(usize, 2), good.len);
779 try std.testing.expectEqualStrings("client.zig", good[0].name);
780 try std.testing.expectEqual(@as(usize, 0), good[1].n);
781
782 try std.testing.expectError(error.BadBudgetLine, parseBudget(alloc, "client.zig twelve\n"));
783 try std.testing.expectError(error.BadBudgetLine, parseBudget(alloc, "client.zig\n"));
784 }
785
786 test "an already-flagged block that grows still moves the number" {
787 const alloc = std.testing.allocator;
788 var t3: std.ArrayList([]const u8) = .empty;
789 defer {
790 for (t3.items) |s| alloc.free(s);
791 t3.deinit(alloc);
792 }
793 const small = [_][]const u8{
794 "/// True while marks say a command is open — the window in which the",
795 "/// pgid fallback must NOT race the marks to a verdict.",
796 "pub fn marksOpen(self: *const Tracker) bool {",
797 " return self.marks_seen and self.phase == .running;",
798 "}",
799 };
800 const grown = [_][]const u8{
801 "/// True while marks say a command is open — the window in which the",
802 "/// pgid fallback must NOT race the marks to a verdict.",
803 "/// A paragraph of narration appended to a block that was already the",
804 "/// file's one flag: counting flags calls this free.",
805 "pub fn marksOpen(self: *const Tracker) bool {",
806 " return self.marks_seen and self.phase == .running;",
807 "}",
808 };
809 const a = try heavyBlocks(alloc, &small, "fixture.zig", &t3);
810 const b = try heavyBlocks(alloc, &grown, "fixture.zig", &t3);
811 try std.testing.expect(b > a);
812 }