76c34f89
feat: the doc gate holds every file to a byte budget
a73x 2026-08-23 07:52
Commit message
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 | } | ||