ae70f4ec
check: comment claims are verified, not trusted
a73x 2026-08-20 09:15
Commit message
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 | } | ||