a73x

ae70f4ec

check: comment claims are verified, not trusted

a73x   2026-08-20 09:15

Commit message
check: comment claims are verified, not trusted

"Comments say why, not how" and "code, comments, docs drift" have been in
CLAUDE.md since the first commit, and in one week this tree drifted about
fifty findings' worth anyway. Prose is instruction. A check that RUNS is
codification — the move build.zig already makes above, where the layer laws
are comptime and a violation is impossible rather than detected.

tools/docscheck.zig, on `zig build check`. Three tiers, two of them gates.

Tier 1, cited symbols must resolve. A `.zig` reference must name a file this
repo has, unless it carries a directory prefix — client_core.zig already cites
ghostty as osc/parsers/clipboard_operation.zig, and turning that convention
into a rule is what makes the prefix mean something. Symbol citations are
checked only in the `module.symbol` form rooted at one of our own modules: 68
of them, `client.recordOnState` and `proto.MsgType` and friends, where both
halves are knowable. Bare identifiers are NOT checked, and that is measured
rather than lazy — of 1138, thirteen failed, and all thirteen were ngtcp2,
ghostty, POSIX or Zig-stdlib names this tool has no corpus for, plus
`unrecordTile`, which client.zig cites precisely to say it does not exist.
This repo wraps three foreign libraries; a bare name cannot tell "renamed last
week" from "belongs to libc", and false positives are how a gate dies.

Tier 2, no project-history codenames. "M18" names nothing reachable from the
code; "the multi-session daemon" still explains itself in a year. The patterns
were measured first, and one nearly went in wrong: `phase` is a live field
here with ten comment mentions, so `Phase N` needs the digit or the gate would
have fired on `cmd.phase`.

Tier 3, comment weight, report-only and staying that way. 430 doc blocks in
src/ outweigh what they document, which is precisely why a gate on it would be
routed around by Friday. `check` prints the count; `zig build doc-report`
lists them; neither can fail.

Every skip class is named in the tool's header with its reason. A skip nobody
wrote down cannot be told from a bug.

Four ways this fails loud, because a check that never ran is the one failure a
green tree cannot show: the tool is built from source here, so "missing" is a
compile error; the file lists are globbed with a fatal on an empty directory;
the tool refuses an empty --check or --index group; and `stdio = .inherit`
makes the run unconditional and carries its own term check. No expectExitCode
alongside it — that call would switch the step back to captured stdio and bury
the violations in a dump.

Red-tested per tier, each violation introduced, watched, reverted:

  1a  `client.recordOnStateRenamed` in wall.zig  -> rc=1, "src/wall.zig:4:
      comment cites `client.recordOnStateRenamed`"
  1b  a reference to walltiles.zig              -> rc=1, named file:line
  2   "M19, Phase 2b"                           -> rc=1, both codenames named
      separately
  3   a 3-line doc block on a 1-line decl       -> listed as "wall.zig:26
      block=3 decl=1", rc stayed 0 — report-only, proven

Test 1a is the one worth keeping: it did not go red. The corpus was built from
whole files, comments included, so a citation resolved against the very
comment that made it and every invented name passed. Fixed to index code lines
only — which immediately exposed the thirteen foreign-symbol findings that
then decided tier 1's scope. A red test that stays green is not a formality.

The ast-grep spike ran first and is recorded in decisions.md with its
measurements. It works — 0.39.6 prebuilt, tree-sitter-zig v1.1.2 compiled to a
.so ast-grep accepts, one 5-byte parse error in 37,756 lines (`await` as an
enum field, which the grammar still reserves). Rejected anyway: `comment` is a
tree-sitter *extra*, so the grammar offers no comment-to-decl attachment and
the pairing stays positional either way. Line arithmetic is exact here because
`zig fmt --check` is already a gate.

tools/ joins zig fmt --check, so the tool is held to the standard it enforces.

zig build check rc=0 · make test rc=0, 586 tests · make e2e rc=0, 55
scenarios / 35 convergence points, pin unmoved.

build.zig
Old New
@@ -417,6 +417,101 @@ fn shellGate(b: *std.Build, step: *std.Build.Step) void {
417 } else |_| {} 417 } else |_| {}
418 } 418 }
419 419
420 /// Collect `<sub>/*.zig`, sorted, into `paths`. Globbed rather than listed for
421 /// shellGate's reason: a module added tomorrow is covered without anybody
422 /// remembering to add it.
423 fn zigFilesIn(b: *std.Build, sub: []const u8, paths: *std.ArrayList([]const u8)) void {
424 var dir = b.build_root.handle.openDir(sub, .{ .iterate = true }) catch |e|
425 fatal("doc gate: cannot open {s}/ ({s})", .{ sub, @errorName(e) });
426 defer dir.close();
427 const first = paths.items.len;
428 var it = dir.iterate();
429 while (it.next() catch |e|
430 fatal("doc gate: cannot list {s}/ ({s})", .{ sub, @errorName(e) })) |ent|
431 {
432 if (ent.kind != .file or !std.mem.endsWith(u8, ent.name, ".zig")) continue;
433 paths.append(b.allocator, b.fmt("{s}/{s}", .{ sub, ent.name })) catch @panic("OOM");
434 }
435 if (paths.items.len == first) fatal("doc gate: no .zig files in {s}/", .{sub});
436 std.mem.sort([]const u8, paths.items[first..], {}, struct {
437 fn lt(_: void, a: []const u8, c: []const u8) bool {
438 return std.mem.lessThan(u8, a, c);
439 }
440 }.lt);
441 }
442
443 /// The comment-discipline gate (tools/docscheck.zig). CLAUDE.md has said
444 /// "comments say why, not how" and "code, comments, docs drift" since the
445 /// first commit; a week of drift showed that prose is instruction and only a
446 /// check that RUNS is codification — the same reasoning behind the comptime
447 /// layer laws above.
448 ///
449 /// The tool is a build tool, not part of the program, so it stays out of the
450 /// module table: that table is the program's import graph, and a row there
451 /// would claim docscheck is something muxd links.
452 ///
453 /// Nothing about this step can silently skip, which is the whole hazard with
454 /// a gate: the tool is built from source in this repo, so "not installed" is a
455 /// compile error rather than a green tree; the file lists are globbed with a
456 /// fatal on an empty directory; the tool itself refuses an empty --check or
457 /// --index group; and `stdio = .inherit` makes the run unconditional, so no
458 /// cache hit can stand in for a check that did not happen. Inherit also puts
459 /// the violations on the terminal at the moment of failure instead of inside a
460 /// captured-stderr dump, and lets tier 3's one-line count be seen at all.
461 /// Every source file is still passed as a FILE arg: that is what declares the
462 /// dependency and lets the build system resolve the paths.
463 fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Build.Step) void {
464 const mod = b.createModule(.{
465 .root_source_file = b.path("tools/docscheck.zig"),
466 .target = target,
467 .optimize = .Debug,
468 });
469 const exe = b.addExecutable(.{ .name = "docscheck", .root_module = mod });
470 exe.use_llvm = true;
471 exe.use_lld = true;
472
473 var checked: std.ArrayList([]const u8) = .empty;
474 zigFilesIn(b, "src", &checked);
475 var indexed: std.ArrayList([]const u8) = .empty;
476 zigFilesIn(b, "src", &indexed);
477 zigFilesIn(b, "test", &indexed);
478 // build.zig is cited by name in src/main.zig's comments and is a real
479 // file of this repo, so it belongs in the corpus even though it is not
480 // under src/ or test/.
481 indexed.append(b.allocator, "build.zig") catch @panic("OOM");
482
483 // The tool's own rules are asserted, not assumed: its unit tests pin the
484 // codename patterns against the domain vocabulary they must not catch.
485 const unit = b.addTest(.{ .root_module = mod });
486 const run_unit = b.addRunArtifact(unit);
487 run_unit.setName("test docscheck");
488 check_step.dependOn(&run_unit.step);
489
490 for ([_]struct { name: []const u8, report: bool }{
491 .{ .name = "gate", .report = false },
492 .{ .name = "report", .report = true },
493 }) |variant| {
494 const run = b.addRunArtifact(exe);
495 if (variant.report) run.addArg("--report");
496 run.addArg("--check");
497 for (checked.items) |p| run.addFileArg(b.path(p));
498 run.addArg("--index");
499 for (indexed.items) |p| run.addFileArg(b.path(p));
500 // `.inherit` carries its own term check — a non-zero exit fails the
501 // step — so this needs no expectExitCode, and adding one would
502 // silently switch the step back to captured stdio.
503 run.stdio = .inherit;
504 if (variant.report) {
505 run.setName("docscheck --report");
506 const step = b.step("doc-report", "List doc blocks heavier than the decl they document");
507 step.dependOn(&run.step);
508 } else {
509 run.setName("docscheck");
510 check_step.dependOn(&run.step);
511 }
512 }
513 }
514
420 /// Test registration order. Doctrine-laden and deliberately NOT derived 515 /// Test registration order. Doctrine-laden and deliberately NOT derived
421 /// from the layers: delta, cmd, shellint and sockpath run BEFORE server 516 /// from the layers: delta, cmd, shellint and sockpath run BEFORE server
422 /// because their tests are seconds-long and socket-free, while a 517 /// because their tests are seconds-long and socket-free, while a
@@ -775,7 +870,7 @@ pub fn build(b: *std.Build) void {
775 // The format gate. `check = true` makes this a --check run: it fails 870 // The format gate. `check = true` makes this a --check run: it fails
776 // naming the offending files and rewrites nothing. 871 // naming the offending files and rewrites nothing.
777 const fmt_step = b.step("fmt", "Check formatting (zig fmt --check)"); 872 const fmt_step = b.step("fmt", "Check formatting (zig fmt --check)");
778 const fmt = b.addFmt(.{ .paths = &.{ "build.zig", "build.zig.zon", "src", "test" }, .check = true }); 873 const fmt = b.addFmt(.{ .paths = &.{ "build.zig", "build.zig.zon", "src", "test", "tools" }, .check = true });
779 fmt_step.dependOn(&fmt.step); 874 fmt_step.dependOn(&fmt.step);
780 875
781 // The seconds-long pre-commit gate: fmt + unit tests + the shell scripts' 876 // The seconds-long pre-commit gate: fmt + unit tests + the shell scripts'
@@ -793,4 +888,5 @@ pub fn build(b: *std.Build) void {
793 check_step.dependOn(fmt_step); 888 check_step.dependOn(fmt_step);
794 check_step.dependOn(test_step); 889 check_step.dependOn(test_step);
795 shellGate(b, check_step); 890 shellGate(b, check_step);
891 docGate(b, target, check_step);
796 } 892 }
docs/decisions.md
Old New
@@ -4721,3 +4721,158 @@ and now names both shapes.
4721 The rule this cost re-learning is the one already in these notes: a mutation 4721 The rule this cost re-learning is the one already in these notes: a mutation
4722 that fails the suite somewhere is not a mutation that fails the leg you aimed 4722 that fails the suite somewhere is not a mutation that fails the leg you aimed
4723 it at. When the two differ, go get the targeted answer, and quote THAT. 4723 it at. When the two differ, go get the targeted answer, and quote THAT.
4724
4725 ## 2026-08-20 (the comment-discipline gate: prose became a check)
4726
4727 CLAUDE.md has said "comments say *why*, not *how*" and "code, comments, docs
4728 drift" since the first commit. In one week the tree accumulated about fifty
4729 findings' worth of exactly that drift, and a manual sweep cleaned it. This
4730 entry is the *next* move: text in CLAUDE.md is instruction, and only a check
4731 that RUNS is codification — the same reasoning that put the layer laws in
4732 build.zig's comptime block, where violations are impossible rather than
4733 detected.
4734
4735 `tools/docscheck.zig`, wired into `zig build check`. Three tiers, two of them
4736 gates.
4737
4738 ### The ast-grep spike, and why it was rejected
4739
4740 Timeboxed, and run before any line of the fallback was written, because
4741 structural comment-to-decl pairing would have been strictly better than line
4742 arithmetic if it worked. Every question got a measured answer:
4743
4744 Installs pinned? YES. ast-grep 0.39.6, prebuilt
4745 app-x86_64-unknown-linux-gnu.zip, 7.4MB
4746 compressed / 47MB binary. No build needed.
4747 Zig grammar exists? YES. tree-sitter-grammars/tree-sitter-zig v1.1.2
4748 (2025-09-10), pregenerated parser.c, no external
4749 scanner. `cc -shared -fPIC -O2` produced a 713KB
4750 zig.so exporting tree_sitter_zig, first try, and
4751 ast-grep accepted it as a customLanguage.
4752 Parses Zig 0.15 cleanly? ESSENTIALLY. One ERROR node across all 32 modules
4753 and 37,756 lines: `await` used as an enum field
4754 name in muxa.zig:37 (`verb: enum { …, await }`),
4755 a word the grammar still reserves and Zig 0.15
4756 does not. The error span is 5 bytes on one line —
4757 tree-sitter recovered, and 47 function_declaration
4758 nodes were still found in that file.
4759
4760 So the tooling works. It was rejected anyway, on the one question that
4761 actually decides it: **`comment` is a tree-sitter *extra*.** It floats; it is
4762 not a child of the declaration it documents. The grammar therefore does not
4763 offer comment-to-decl attachment at all — pairing a doc block with its decl is
4764 positional line arithmetic under ast-grep exactly as it is without it. Nor
4765 does the grammar distinguish `///` from `//`, so telling a doc comment from an
4766 ordinary one means re-reading the raw text either way. What ast-grep would
4767 have contributed is decl ranges, in exchange for a 47MB pinned binary, a
4768 vendored grammar, a compile step and a custom-language config inside a
4769 pre-commit gate.
4770
4771 The fallback is also *better* here, not merely cheaper, and for a reason
4772 specific to this repo: `zig fmt --check` is already a gate. Indentation is
4773 canonical, so a decl's closing brace sits at the decl's own indent and nowhere
4774 else, and the line arithmetic is exact rather than approximate.
4775
4776 ### Tier 1 — cited symbols must resolve
4777
4778 Two halves, both tuned against the corpus rather than guessed.
4779
4780 `.zig` file references, backticked or bare: measured 10 backticked against 99
4781 bare, and the bare ones are overwhelmingly this repo's own modules — so
4782 backticking is NOT the discriminator, and both are checked. What discriminates
4783 is a directory prefix: `osc/parsers/clipboard_operation.zig` and
4784 `lib/types.zig` name foreign trees (ghostty-vt, the Zig stdlib) and are
4785 skipped. The repo already wrote references that way; the gate turns the
4786 convention into a rule, which is what makes the prefix load-bearing — it is
4787 how a reader, and now the tool, tell "not in this repo" from "renamed last
4788 week".
4789
4790 Symbol citations: **only `module.symbol` rooted at one of this repo's own
4791 modules is checked** — 68 citations, 41 distinct, `client.recordOnState`,
4792 `proto.MsgType`, `quic.default_port`. Both halves are knowable there: the root
4793 is a src/*.zig the tool was handed, the tail must appear in code.
4794
4795 Bare identifiers are NOT checked, and that decision is the measured one. Of
4796 1138 bare citations, 1125 resolved and 13 did not — and all 13 were names the
4797 tool cannot possibly verify:
4798
4799 ngtcp2_conn_writev_stream, ngtcp2_vec_copy, ngtcp2's C API
4800 ngtcp2_pkt_encode_stream_frame, writev_stream, ndatalen
4801 max_title_len ghostty
4802 sockaddr_un, isig POSIX
4803 keep_sigpipe, NameTooLong Zig stdlib
4804 scroll_pages a field described in the
4805 past tense, deliberately
4806 unrecordTile cited by client.zig
4807 precisely to say it does
4808 NOT exist
4809
4810 This repo wraps three foreign libraries and names their symbols constantly. A
4811 bare identifier gives the tool nothing to tell "renamed last week" from
4812 "belongs to libc", and false positives are the death of a gate. Widening the
4813 corpus to ghostty, ngtcp2 and the Zig stdlib would fix all eleven foreign
4814 cases and was rejected for a reason already in these notes: it puts
4815 machine-specific, sometimes-absent paths inside a pre-commit gate, which is
4816 the skip hazard wearing a new hat. The gap accepted in exchange is that
4817 renaming a MODULE silently un-checks every citation rooted at its old name;
4818 the `.zig` file rule covers module renames from the other side, which is why
4819 that half is not narrowed the same way.
4820
4821 Every skip class — phrase, flag, wire, enumlit, numeric, short, keyword,
4822 placeholder, bare — is named in the tool's own header with its reason. A skip
4823 nobody wrote down is indistinguishable from a bug.
4824
4825 ### Tier 2 — no project-history codenames in src/*.zig comments
4826
4827 "M18" and "Phase 3c" name nothing a reader can look up from the code. The
4828 EVENT survives: "which is why the multi-session daemon is where it surfaced"
4829 still explains itself in a year. Scope is src/*.zig comment lines only —
4830 decisions.md, roadmap.md, handoff.md, test/, web/ and commit messages are
4831 dated journals, and history is what they are for.
4832
4833 The patterns were measured before being turned on, and one of them nearly went
4834 in wrong. `M[0-9]{1,2}`: 13 occurrences in src/, every one a milestone, zero
4835 collisions — safe. But `phase` is *live domain vocabulary* here: `cmd.phase`
4836 is a real field with ten comment mentions ("phase is already back to
4837 `at_prompt`"). A pattern on the bare word would have fired on all of them. The
4838 digit is what makes it a codename, so the rule is `Phase N` / `Task N`, and
4839 `Tasks 6-7` is caught by the plural.
4840
4841 The brief said one known leftover, in replica.zig. The gate found **24 sites
4842 across 12 of the 32 modules** — including the one named. That gap is the
4843 entry's real content: a sweep that had just been through this tree by hand saw
4844 one of twenty-four.
4845
4846 ### Tier 3 — comment weight, and deliberately not a gate
4847
4848 Per doc block: block lines against the decl's own line span. 430 blocks in
4849 src/ outweigh what they document. That number is exactly why this is a report:
4850 long comments are earned here often enough that a gate on it would be
4851 retrained-around within a day. `zig build check` prints the count; `zig build
4852 doc-report` lists them; neither can fail on it.
4853
4854 ### The red tests, and the one that found a bug
4855
4856 Each tier got a deliberate violation, watched, and reverted. The tier 1 red
4857 test failed to go red — and the reason was a real defect: the word corpus was
4858 built from whole files, comments included, so a citation resolved against **the
4859 very comment that made it**. Every invented name passed. The corpus is now
4860 built from code lines only, and re-running on the swept tree immediately
4861 surfaced the 13 foreign-symbol findings above, which is what forced tier 1's
4862 scope decision. A red test that stays green is not a formality.
4863
4864 ### Wiring
4865
4866 `stdio = .inherit` on both runs. Inherit carries its own term check, so a
4867 non-zero exit fails the step without `expectExitCode` — and adding one would
4868 have silently switched the step back to captured stdio, hiding the violations
4869 inside a dump. Inherit also makes the run unconditional: no cache hit can
4870 stand in for a check that did not happen. The tool is built from source in
4871 this repo, so "not installed" is a compile error rather than a green tree; the
4872 file lists are globbed with a fatal on an empty directory; and the tool
4873 refuses an empty `--check` or `--index` group. Four separate ways for this
4874 gate to fail loud, because the one failure mode a green tree cannot show is a
4875 check that never ran.
4876
4877 `tools/` joined `zig fmt --check`'s paths, which is how the tool's own
4878 formatting is held to the same standard as the code it inspects.
tools/docscheck.zig
Old New
@@ -0,0 +1,572 @@
1 //! The comment-discipline gate: "comments say why, not how" and "code,
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 //!
6 //! Three tiers, two of them gates:
7 //!
8 //! 1. Every symbol a comment cites must resolve. A drift tripwire, not a
9 //! compiler: a cited name has to appear as a word SOMEWHERE in the CODE
10 //! of src/ or test/ (definition or use — comment lines contribute
11 //! nothing, or a citation would resolve against itself). Renaming
12 //! `sendResync` and leaving the old spelling in a comment is the drift
13 //! this catches.
14 //! 2. No project-history codenames in src/*.zig comments. "M18" and
15 //! "Phase 3c" name nothing a reader can look up from the code; the EVENT
16 //! ("the multi-session daemon") survives. Dated journals — decisions.md,
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 //!
24 //! Usage:
25 //! docscheck [--report] --check FILE... --index 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 //!
60 //! bare an unqualified identifier — `sendResync`, `paint_mu`,
61 //! `deinit`. NOT CHECKED, and the reason is measured. Of 1138
62 //! such citations in src/, 1125 resolved and 13 did not, and
63 //! every one of the 13 was a name this tool cannot possibly
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`, `proto.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.
85 //!
86 //! The gap this leaves: renaming a MODULE silently un-checks every citation
87 //! rooted at its old name. The `.zig` file rule below covers module renames
88 //! from the other side, which is why it is not narrowed the same way.
89 //!
90 //! `.zig` file references are checked whether or not they are backticked —
91 //! measured: 10 backticked against 99 bare, and the bare ones are this repo's
92 //! own modules, so backticking is not the discriminator. A reference carrying
93 //! a directory prefix that is not `src` or `test` (`osc/parsers/foo.zig`,
94 //! `lib/types.zig`) names a foreign tree — ghostty-vt, the Zig stdlib — and is
95 //! skipped. That prefix is therefore load-bearing documentation: it is how a
96 //! reader, and this tool, tell "not in this repo" from a stale name.
97 //!
98 //! Trailing comments (code, then `//` on the same line) are skipped: telling
99 //! one from `//` inside a string literal needs a lexer, and the 219 candidate
100 //! lines in src/ are overwhelmingly `quic://` inside string literals. Only
101 //! lines whose first non-space characters are `//` are inspected.
102
103 const std = @import("std");
104
105 fn writeAll(fd: std.posix.fd_t, bytes: []const u8) !void {
106 var off: usize = 0;
107 while (off < bytes.len) off += try std.posix.write(fd, bytes[off..]);
108 }
109
110 fn out(comptime fmt: []const u8, args: anytype) void {
111 var buf: [4096]u8 = undefined;
112 const s = std.fmt.bufPrint(&buf, fmt, args) catch return;
113 writeAll(std.posix.STDOUT_FILENO, s) catch {};
114 }
115
116 fn err(comptime fmt: []const u8, args: anytype) void {
117 var buf: [4096]u8 = undefined;
118 const s = std.fmt.bufPrint(&buf, fmt, args) catch return;
119 writeAll(std.posix.STDERR_FILENO, s) catch {};
120 }
121
122 fn usage() u8 {
123 err("usage: docscheck [--report] --check FILE... --index FILE...\n", .{});
124 return 2;
125 }
126
127 // ---------------------------------------------------------------- lexical
128
129 fn isWordByte(c: u8) bool {
130 return std.ascii.isAlphanumeric(c) or c == '_';
131 }
132
133 /// A comment line for this tool's purposes: the first non-space bytes are
134 /// `//`. Covers `//`, `///` and `//!` alike; excludes trailing comments, for
135 /// the reason in the header.
136 fn commentBody(line: []const u8) ?[]const u8 {
137 const t = std.mem.trimLeft(u8, line, " \t");
138 if (!std.mem.startsWith(u8, t, "//")) return null;
139 return t;
140 }
141
142 fn indentOf(line: []const u8) usize {
143 var i: usize = 0;
144 while (i < line.len and line[i] == ' ') i += 1;
145 return i;
146 }
147
148 const keywords = [_][]const u8{
149 "align", "allowzero", "and", "anyframe", "anytype", "asm",
150 "async", "await", "break", "callconv", "catch", "comptime",
151 "const", "continue", "defer", "else", "enum", "errdefer",
152 "error", "export", "extern", "false", "fn", "for",
153 "if", "inline", "linksection", "noalias", "noreturn", "nosuspend",
154 "null", "opaque", "or", "orelse", "packed", "pub",
155 "resume", "return", "struct", "suspend", "switch", "test",
156 "threadlocal", "true", "try", "undefined", "union", "unreachable",
157 "usingnamespace", "var", "volatile", "while", "void", "bool",
158 "type", "usize", "isize", "anyerror", "anyopaque", "comptime_int",
159 "comptime_float",
160 };
161
162 fn isKeyword(tok: []const u8) bool {
163 for (keywords) |k| if (std.mem.eql(u8, k, tok)) return true;
164 // Primitive integer/float types are generated, not listed: u8, i32, f64…
165 if (tok.len >= 2 and (tok[0] == 'u' or tok[0] == 'i' or tok[0] == 'f')) {
166 var all_digits = true;
167 for (tok[1..]) |c| if (!std.ascii.isDigit(c)) {
168 all_digits = false;
169 };
170 if (all_digits) return true;
171 }
172 return false;
173 }
174
175 const Class = enum { check, phrase, flag, wire, enumlit, numeric, short, keyword, placeholder, other };
176
177 fn classify(tok: []const u8) Class {
178 if (tok.len == 0) return .other;
179 for (tok) |c| if (c == ' ' or c == '\t') return .phrase;
180 if (tok[0] == '-') return .flag;
181 for (tok) |c| switch (c) {
182 '/', ':', '#', '?', '\\', '@', '%', '"', '\'', '(', ')', '[', ']', '{', '}', '<', '>', '|', '&', '*', '+', '=', ',', ';', '!', '~', '^', '$' => return .wire,
183 else => {},
184 };
185 if (tok[0] == '.') return .enumlit;
186 if (std.ascii.isDigit(tok[0])) return .numeric;
187 if (tok.len < 3) return .short;
188 if (isKeyword(tok)) return .keyword;
189 var has_lower = false;
190 for (tok) |c| if (std.ascii.isLower(c)) {
191 has_lower = true;
192 };
193 if (!has_lower) return .placeholder;
194 for (tok) |c| if (!isWordByte(c) and c != '.') return .other;
195 return .check;
196 }
197
198 // ------------------------------------------------------------------ tiers
199
200 const Finding = struct {
201 file: []const u8,
202 line: usize,
203 text: []const u8,
204 };
205
206 /// Tier 2's codename patterns. Each returns the matched span's length at `i`,
207 /// or 0. Measured against the corpus before being turned on:
208 ///
209 /// `M<digits>` 13 occurrences in src/, every one a milestone name, zero
210 /// collisions — uppercase M only, because that is the
211 /// convention and lowercase `m8` would be a coin flip.
212 /// `Phase N` / `Task N` the NUMBER is what makes it a codename. Bare
213 /// "phase" is domain vocabulary here — `cmd.phase` is a live
214 /// field with 10 comment mentions — so the digit is required.
215 fn codenameAt(s: []const u8, i: usize) usize {
216 // M-web: the browser-client milestone.
217 if (std.ascii.startsWithIgnoreCase(s[i..], "M-web")) {
218 if (i == 0 or !isWordByte(s[i - 1])) return 5;
219 }
220 // M<digit><digit?> as a milestone name.
221 if (s[i] == 'M' and (i == 0 or !isWordByte(s[i - 1]))) {
222 var n: usize = 1;
223 while (i + n < s.len and n <= 2 and std.ascii.isDigit(s[i + n])) n += 1;
224 if (n > 1 and (i + n == s.len or !isWordByte(s[i + n]))) return n;
225 }
226 // Phase N / Task N, plural or not, any case, optional a/b/c suffix.
227 for ([_][]const u8{ "phases", "phase", "tasks", "task" }) |w| {
228 if (!std.ascii.startsWithIgnoreCase(s[i..], w)) continue;
229 if (i != 0 and isWordByte(s[i - 1])) continue;
230 var j = i + w.len;
231 const before_space = j;
232 while (j < s.len and (s[j] == ' ' or s[j] == '\t')) j += 1;
233 if (j == before_space or j >= s.len or !std.ascii.isDigit(s[j])) continue;
234 while (j < s.len and std.ascii.isDigit(s[j])) j += 1;
235 if (j < s.len and s[j] >= 'a' and s[j] <= 'c') j += 1;
236 return j - i;
237 }
238 return 0;
239 }
240
241 /// Where a `.zig` reference sits: the basename, and the directory prefix that
242 /// precedes it (empty when there is none). `replica.zig/wasm_core.zig` — the
243 /// repo's way of writing "both of these" — yields an empty prefix for the
244 /// second name, because a prefix segment that is itself a .zig file is not a
245 /// directory.
246 const ZigRef = struct { name: []const u8, prefix: []const u8 };
247
248 fn zigRefAt(s: []const u8, dot: usize) ?ZigRef {
249 if (dot + 4 > s.len or !std.mem.eql(u8, s[dot .. dot + 4], ".zig")) return null;
250 if (dot + 4 < s.len and isWordByte(s[dot + 4])) return null; // .zigzag
251 var start = dot;
252 while (start > 0 and isWordByte(s[start - 1])) start -= 1;
253 if (start == dot) return null;
254 var prefix: []const u8 = "";
255 if (start > 0 and s[start - 1] == '/') {
256 var p = start - 1;
257 while (p > 0 and (isWordByte(s[p - 1]) or s[p - 1] == '.' or s[p - 1] == '-' or s[p - 1] == '/')) p -= 1;
258 const cand = s[p .. start - 1];
259 if (!std.mem.endsWith(u8, cand, ".zig")) prefix = cand;
260 }
261 return .{ .name = s[start .. dot + 4], .prefix = prefix };
262 }
263
264 fn prefixIsOurs(prefix: []const u8) bool {
265 return prefix.len == 0 or std.mem.eql(u8, prefix, "src") or
266 std.mem.eql(u8, prefix, "test") or std.mem.eql(u8, prefix, "./src") or
267 std.mem.eql(u8, prefix, "./test");
268 }
269
270 // ------------------------------------------------------------------- main
271
272 pub fn main() !u8 {
273 var gpa: std.heap.DebugAllocator(.{}) = .init;
274 defer _ = gpa.deinit();
275 const alloc = gpa.allocator();
276
277 const args = try std.process.argsAlloc(alloc);
278 defer std.process.argsFree(alloc, args);
279
280 var report = false;
281 var check_files: std.ArrayList([]const u8) = .empty;
282 defer check_files.deinit(alloc);
283 var index_files: std.ArrayList([]const u8) = .empty;
284 defer index_files.deinit(alloc);
285
286 var group: enum { none, check, index } = .none;
287 var ai: usize = 1;
288 while (ai < args.len) : (ai += 1) {
289 const a = args[ai];
290 if (std.mem.eql(u8, a, "--report")) {
291 report = true;
292 } else if (std.mem.eql(u8, a, "--check")) {
293 group = .check;
294 } else if (std.mem.eql(u8, a, "--index")) {
295 group = .index;
296 } else switch (group) {
297 .none => return usage(),
298 .check => try check_files.append(alloc, a),
299 .index => try index_files.append(alloc, a),
300 }
301 }
302 // A gate handed nothing to gate is the "check that never ran" failure
303 // mode, and it passes green forever. Refuse instead.
304 if (check_files.items.len == 0 or index_files.items.len == 0) {
305 err("docscheck: empty --check or --index group ({d} check, {d} index) — " ++
306 "a gate with no inputs passes green forever\n", .{ check_files.items.len, index_files.items.len });
307 return 2;
308 }
309
310 // The corpus: every word in src/ and test/, and every .zig basename.
311 var words: std.StringHashMapUnmanaged(void) = .empty;
312 defer words.deinit(alloc);
313 var basenames: std.StringHashMapUnmanaged(void) = .empty;
314 defer basenames.deinit(alloc);
315 var bufs: std.ArrayList([]u8) = .empty;
316 defer {
317 for (bufs.items) |b| alloc.free(b);
318 bufs.deinit(alloc);
319 }
320
321 for (index_files.items) |p| {
322 const f = std.fs.cwd().openFile(p, .{}) catch |e| {
323 err("docscheck: cannot open index file {s} ({s})\n", .{ p, @errorName(e) });
324 return 2;
325 };
326 defer f.close();
327 const src = try f.readToEndAlloc(alloc, 64 * 1024 * 1024);
328 try bufs.append(alloc, src);
329 try basenames.put(alloc, std.fs.path.basename(p), {});
330 // CODE only: a comment line contributes no words. Found by the red
331 // test — with comments indexed, a citation resolved against the very
332 // comment that made it, and every invented name passed. A comment
333 // that cites itself is not evidence that the symbol exists.
334 var il = std.mem.splitScalar(u8, src, '\n');
335 while (il.next()) |line| {
336 if (commentBody(line) != null) continue;
337 var i: usize = 0;
338 while (i < line.len) {
339 if (!isWordByte(line[i]) or std.ascii.isDigit(line[i])) {
340 i += 1;
341 continue;
342 }
343 const s = i;
344 while (i < line.len and isWordByte(line[i])) i += 1;
345 try words.put(alloc, line[s..i], {});
346 }
347 }
348 }
349
350 var t1: std.ArrayList(Finding) = .empty;
351 defer t1.deinit(alloc);
352 var t2: std.ArrayList(Finding) = .empty;
353 defer t2.deinit(alloc);
354 var t3: std.ArrayList([]const u8) = .empty;
355 defer {
356 for (t3.items) |s| alloc.free(s);
357 t3.deinit(alloc);
358 }
359
360 var lines: std.ArrayList([]const u8) = .empty;
361 defer lines.deinit(alloc);
362
363 for (check_files.items) |p| {
364 const f = std.fs.cwd().openFile(p, .{}) catch |e| {
365 err("docscheck: cannot open {s} ({s})\n", .{ p, @errorName(e) });
366 return 2;
367 };
368 defer f.close();
369 const src = try f.readToEndAlloc(alloc, 64 * 1024 * 1024);
370 try bufs.append(alloc, src);
371 const name = std.fs.path.basename(p);
372
373 lines.clearRetainingCapacity();
374 var it = std.mem.splitScalar(u8, src, '\n');
375 while (it.next()) |l| try lines.append(alloc, l);
376
377 for (lines.items, 1..) |line, lineno| {
378 const body = commentBody(line) orelse continue;
379
380 // Tier 2 — history codenames.
381 var i: usize = 0;
382 while (i < body.len) {
383 const n = codenameAt(body, i);
384 if (n == 0) {
385 i += 1;
386 continue;
387 }
388 try t2.append(alloc, .{ .file = name, .line = lineno, .text = body[i .. i + n] });
389 i += n;
390 }
391
392 // Tier 1a — .zig file references, backticked or bare.
393 i = 0;
394 while (i < body.len) : (i += 1) {
395 const r = zigRefAt(body, i) orelse continue;
396 if (!prefixIsOurs(r.prefix)) continue; // foreign tree, deliberate
397 if (!basenames.contains(r.name))
398 try t1.append(alloc, .{ .file = name, .line = lineno, .text = r.name });
399 }
400
401 // Tier 1b — backtick-quoted symbol citations.
402 i = 0;
403 while (i < body.len) {
404 if (body[i] != '`') {
405 i += 1;
406 continue;
407 }
408 const close = std.mem.indexOfScalarPos(u8, body, i + 1, '`') orelse break;
409 const tok = body[i + 1 .. close];
410 i = close + 1;
411 if (classify(tok) != .check) continue;
412 // Only `module.symbol`, rooted at one of THIS repo's modules.
413 // See the header: a bare identifier is unverifiable here.
414 var segs = std.mem.splitScalar(u8, tok, '.');
415 const root = segs.next() orelse continue;
416 var modfile: [64]u8 = undefined;
417 if (root.len + 4 > modfile.len) continue;
418 @memcpy(modfile[0..root.len], root);
419 @memcpy(modfile[root.len..][0..4], ".zig");
420 if (!basenames.contains(modfile[0 .. root.len + 4])) continue;
421 while (segs.next()) |seg| {
422 if (classify(seg) != .check) continue;
423 if (!words.contains(seg))
424 try t1.append(alloc, .{ .file = name, .line = lineno, .text = tok });
425 }
426 }
427 }
428
429 // Tier 3 — doc blocks heavier than the decl they document. Line
430 // arithmetic, not a parser, and exact enough because `zig fmt --check`
431 // is already a gate: indentation is canonical, so a decl's closing
432 // brace sits at the decl's own indent and nowhere else.
433 var li: usize = 0;
434 while (li < lines.items.len) {
435 const t = std.mem.trimLeft(u8, lines.items[li], " \t");
436 // `//!` is a file header, attached to no decl.
437 if (!std.mem.startsWith(u8, t, "///")) {
438 li += 1;
439 continue;
440 }
441 const block_start = li;
442 while (li < lines.items.len and
443 std.mem.startsWith(u8, std.mem.trimLeft(u8, lines.items[li], " \t"), "///")) li += 1;
444 const block = li - block_start;
445 if (li >= lines.items.len) break;
446 const decl = li;
447 const dt = std.mem.trimLeft(u8, lines.items[decl], " \t");
448 if (dt.len == 0 or std.mem.startsWith(u8, dt, "//")) continue;
449 const indent = indentOf(lines.items[decl]);
450 // The decl's span: every following line indented deeper, plus the
451 // closing brace that returns to the decl's own indent.
452 var last = decl;
453 var j = decl + 1;
454 while (j < lines.items.len) : (j += 1) {
455 const lt = std.mem.trim(u8, lines.items[j], " \t\r");
456 if (lt.len == 0) continue;
457 if (indentOf(lines.items[j]) > indent) {
458 last = j;
459 continue;
460 }
461 if (indentOf(lines.items[j]) == indent and lt[0] == '}') last = j;
462 break;
463 }
464 const span = last - decl + 1;
465 if (block > span) {
466 const nm = declName(dt);
467 const s = try std.fmt.allocPrint(alloc, "{s}:{d} block={d} decl={d} {s}\n", .{ name, decl + 1, block, span, nm });
468 try t3.append(alloc, s);
469 }
470 }
471 }
472
473 for (t1.items) |v|
474 err("src/{s}:{d}: comment cites `{s}`, which appears nowhere in src/ or test/\n", .{ v.file, v.line, v.text });
475 for (t2.items) |v|
476 err("src/{s}:{d}: comment names the project-history codename \"{s}\" — " ++
477 "name the event instead; codenames belong in docs/decisions.md\n", .{ v.file, v.line, v.text });
478
479 if (report) {
480 out("doc blocks heavier than the decl they document ({d}):\n", .{t3.items.len});
481 for (t3.items) |s| out(" {s}", .{s});
482 out("\nReport only, never a gate: a long comment is often earned here.\n", .{});
483 } else {
484 out("docscheck: {d} files, {d} doc blocks outweigh their decl " ++
485 "(`zig build doc-report` lists them)\n", .{ check_files.items.len, t3.items.len });
486 }
487
488 if (t1.items.len + t2.items.len > 0) {
489 err("docscheck: {d} unresolved citation(s), {d} codename(s)\n", .{ t1.items.len, t2.items.len });
490 return 1;
491 }
492 return 0;
493 }
494
495 /// Best-effort name for the report's third column. Cosmetic: the file:line is
496 /// what a reader navigates by.
497 fn declName(dt: []const u8) []const u8 {
498 var it = std.mem.tokenizeAny(u8, dt, " \t(:=");
499 while (it.next()) |w| {
500 if (std.mem.eql(u8, w, "pub") or std.mem.eql(u8, w, "export") or
501 std.mem.eql(u8, w, "extern") or std.mem.eql(u8, w, "inline") or
502 std.mem.eql(u8, w, "threadlocal") or std.mem.eql(u8, w, "comptime") or
503 std.mem.eql(u8, w, "fn") or std.mem.eql(u8, w, "const") or
504 std.mem.eql(u8, w, "var")) continue;
505 return w;
506 }
507 return dt;
508 }
509
510 test "codenames are recognised, domain vocabulary is not" {
511 // The corpus that set these rules: `cmd.phase` is a live field with ten
512 // comment mentions, so bare "phase" must never fire.
513 try std.testing.expect(codenameAt("M18 landed", 0) == 3);
514 try std.testing.expect(codenameAt("pre-M18 payload", 4) == 3);
515 try std.testing.expect(codenameAt("M-web Task 1", 0) == 5);
516 try std.testing.expect(codenameAt("Phase 3c of the wall", 0) == 8);
517 try std.testing.expect(codenameAt("Tasks 6-7", 0) == 7);
518 try std.testing.expect(codenameAt("phase is back to at_prompt", 0) == 0);
519 try std.testing.expect(codenameAt("the attach-or-create task on", 24) == 0);
520 // A word that merely starts with M, and a symbol that embeds one.
521 try std.testing.expect(codenameAt("MAX8 is a bound", 0) == 0);
522 try std.testing.expect(codenameAt("frame_M18", 6) == 0);
523 }
524
525 test "citation classes match what the corpus actually contains" {
526 try std.testing.expectEqual(Class.check, classify("sendResync"));
527 try std.testing.expectEqual(Class.check, classify("state_since_attach"));
528 try std.testing.expectEqual(Class.check, classify("MsgType"));
529 try std.testing.expectEqual(Class.check, classify("deinit"));
530 try std.testing.expectEqual(Class.phrase, classify("zig build test"));
531 try std.testing.expectEqual(Class.flag, classify("--sock"));
532 try std.testing.expectEqual(Class.wire, classify("quic://"));
533 try std.testing.expectEqual(Class.wire, classify("HOST#SESSION"));
534 try std.testing.expectEqual(Class.enumlit, classify(".none"));
535 try std.testing.expectEqual(Class.short, classify("fd"));
536 try std.testing.expectEqual(Class.keyword, classify("unreachable"));
537 try std.testing.expectEqual(Class.keyword, classify("u16"));
538 try std.testing.expectEqual(Class.placeholder, classify("CMDLINE"));
539 try std.testing.expectEqual(Class.numeric, classify("0x1b"));
540 }
541
542 /// Resolve the LAST `.zig` in `s`, the way the scanner reaches it.
543 fn lastRef(s: []const u8) ZigRef {
544 return zigRefAt(s, std.mem.lastIndexOf(u8, s, ".zig").?).?;
545 }
546
547 test "a .zig reference knows whose tree it names" {
548 // The prefix is the discriminator, and this repo already writes it:
549 // client_core.zig cites ghostty as osc/parsers/clipboard_operation.zig.
550 const ours = lastRef("see server.zig for");
551 try std.testing.expectEqualStrings("server.zig", ours.name);
552 try std.testing.expect(prefixIsOurs(ours.prefix));
553
554 const foreign = lastRef("(osc/parsers/clipboard_operation.zig)");
555 try std.testing.expectEqualStrings("clipboard_operation.zig", foreign.name);
556 try std.testing.expectEqualStrings("osc/parsers", foreign.prefix);
557 try std.testing.expect(!prefixIsOurs(foreign.prefix));
558
559 // "both of these" is written with a slash and must not read as a path.
560 const pair = lastRef("replica.zig/wasm_core.zig call");
561 try std.testing.expectEqualStrings("wasm_core.zig", pair.name);
562 try std.testing.expect(prefixIsOurs(pair.prefix));
563
564 try std.testing.expectEqualStrings("src", lastRef("src/protocol.zig").prefix);
565 }
566
567 test "only leading // is a comment, so string literals are left alone" {
568 try std.testing.expect(commentBody(" /// why, not how") != null);
569 try std.testing.expect(commentBody("//! module header") != null);
570 try std.testing.expect(commentBody(" const u = \"quic://box:4433\";") == null);
571 try std.testing.expect(commentBody(" ov.setMode(); // raw") == null);
572 }