a73x

8f9fef4e

Remove the prose ratchets from the comment gate

a73x   2026-08-31 10:33

Commit message
Remove the prose ratchets from the comment gate

docscheck had four tiers. Two ask whether a comment still refers to
something real; two asked whether it was short enough. Tiers 3 and 4 are
gone, with docscheck.budget, docscheck.blocks and the doc-report step.

The ratchets came down as designed over four commits, and what they
bought was not brevity but compression — comments reached a passing byte
count by replacing the explanation with a figure of speech. CLAUDE.md's
own rule had become "a comment that will not fit plainly drops the
claim", an instruction to delete true information to satisfy a counter.
Tier 3's ruler was backwards besides: prose weighed against the DECL's
bytes, so the shorter and denser the code, the less might be said of it.

Tiers 1 and 2 stay, having no opinion about length. Tier 1 catches the
citation the src/cli rename orphaned in client.zig, a file that commit
never opened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

CLAUDE.md
Old New
@@ -101,10 +101,9 @@ own. Test fixtures in `test/`:
101 101
102 ## Invariants — do not break, they are load-bearing 102 ## Invariants — do not break, they are load-bearing
103 103
104 - **Transport is dumb.** `proxy.zig` and the QUIC modules carry opaque bytes and 104 - **`proxy.zig` and the QUIC modules carry opaque bytes and know nothing of
105 know nothing of frames. Keep the wire contract — `term` — out of 105 frames.** Keep the wire contract — `term` — out of `proxy.zig`'s imports.
106 `proxy.zig`'s imports. 106 - **`predict.zig` output never enters the replica.** It is an overlay.
107 - **Prediction is an overlay.** `predict.zig` output never enters the replica.
108 - **One replay core.** CLI, wasm, and test fixtures all go through `replica.zig`. 107 - **One replay core.** CLI, wasm, and test fixtures all go through `replica.zig`.
109 Do not hand-roll a second applier. 108 Do not hand-roll a second applier.
110 - **Latest wins.** The grid follows the most recently active client. 109 - **Latest wins.** The grid follows the most recently active client.
@@ -270,10 +269,10 @@ own. Test fixtures in `test/`:
270 - **Detach is a goodbye, not a tear.** A pump that has sent its detach frame 269 - **Detach is a goodbye, not a tear.** A pump that has sent its detach frame
271 never redials — redial checks `detach_ack` — so the daemon's close after 270 never redials — redial checks `detach_ack` — so the daemon's close after
272 a detach is final, not a network event to recover from. 271 a detach is final, not a network event to recover from.
273 - **An upgrade is an exec, not a restart.** `mux d upgrade` execs the candidate 272 - **`mux d upgrade` execs the candidate over the RUNNING daemon**: same pid,
274 over the RUNNING daemon: same pid, same children, same fds — so `waitpid`, 273 same children, same fds — so `waitpid`, the pid-named shim and agent dirs,
275 the pid-named shim and agent dirs, and the socket path all survive untouched, 274 and the socket path all survive untouched, and `Server.deinit` never runs
276 and `Server.deinit` never runs on that path. The manifest memfd carries only 275 on that path. The manifest memfd carries only
277 what cannot be rebuilt (pty fd + child pid, the VT dump and title, the 276 what cannot be rebuilt (pty fd + child pid, the VT dump and title, the
278 tracker, the QUIC arm's key bytes, the cumulative counters); scrollback, 277 tracker, the QUIC arm's key bytes, the cumulative counters); scrollback,
279 clients, delta trackers, agent channels and per-connection QUIC state are 278 clients, delta trackers, agent channels and per-connection QUIC state are
@@ -304,15 +303,17 @@ own. Test fixtures in `test/`:
304 own `$$`. A daemon reporting on itself cannot catch itself being wrong — 303 own `$$`. A daemon reporting on itself cannot catch itself being wrong —
305 which is why `mux d upgrade`'s gate is a shell pid read off the grid. 304 which is why `mux d upgrade`'s gate is a shell pid read off the grid.
306 - Comments say *why*, not *how*. Existing ones are load-bearing — trim noise, 305 - Comments say *why*, not *how*. Existing ones are load-bearing — trim noise,
307 keep rationale. `zig build check` gates the claims (symbol refs must 306 keep rationale. `zig build check` gates the REFERENCES: a cited symbol must
308 resolve, no history codenames in src comments, and every file's flagged 307 resolve, and no project-history codenames. Nothing gates length.
309 prose meets its byte figure in `docscheck.budget` exactly); `zig build 308 - **Write the comment plainly and let it be as long as that takes.** A reader
310 doc-report` is the worklist. Raising a budget line is a diff someone has 309 who has to decode a metaphor is worse off than one who reads four ordinary
311 to sign; lowering one is the point. 310 sentences. Prefer the concrete noun to the figure of speech, and name the
312 - **A comment is at most two lines in place.** Longer rationale goes to 311 failure the rule prevents. Longer rationale still belongs in
313 `docs/decisions.md` or a test's failure message, where it is dated or run. 312 `docs/decisions.md` or a test's failure message, where it is dated or run —
314 Five consecutive comment lines are an essay, and every file meets its count 313 because that is where a reader can find it, not to hit a budget.
315 in `docscheck.blocks` exactly — the same ratchet, down only. 314 (The byte-budget and essay-count ratchets were removed 2026-08-31: holding
315 a comment under a counter bought compression, and compressed English is
316 metaphor. See docs/decisions.md.)
316 - Commit often with `--fixup`/`--squash`, autosquash before delivery. The final 317 - Commit often with `--fixup`/`--squash`, autosquash before delivery. The final
317 history should tell the feature's story, not the development's. 318 history should tell the feature's story, not the development's.
318 - `git-collab` tracks issues (`git-collab issue list`); the commit-msg hook 319 - `git-collab` tracks issues (`git-collab issue list`); the commit-msg hook
build.zig
Old New
@@ -708,7 +708,8 @@ fn zigFilesIn(b: *std.Build, sub: []const u8, paths: *std.ArrayList([]const u8))
708 /// "comments say why, not how" and "code, comments, docs drift" since the 708 /// "comments say why, not how" and "code, comments, docs drift" since the
709 /// first commit; a week of drift showed that prose is instruction and only a 709 /// first commit; a week of drift showed that prose is instruction and only a
710 /// check that RUNS is codification — the same reasoning behind the comptime 710 /// check that RUNS is codification — the same reasoning behind the comptime
711 /// layer laws above. 711 /// layer laws above. It gates REFERENCES, not length: a comment must still
712 /// name something real, and may take as many lines as saying so takes.
712 /// 713 ///
713 /// The tool is a build tool, not part of the program, so it stays out of the 714 /// The tool is a build tool, not part of the program, so it stays out of the
714 /// module table: that table is the program's import graph, and a row there 715 /// module table: that table is the program's import graph, and a row there
@@ -721,7 +722,7 @@ fn zigFilesIn(b: *std.Build, sub: []const u8, paths: *std.ArrayList([]const u8))
721 /// --index group; and `stdio = .inherit` makes the run unconditional, so no 722 /// --index group; and `stdio = .inherit` makes the run unconditional, so no
722 /// cache hit can stand in for a check that did not happen. Inherit also puts 723 /// cache hit can stand in for a check that did not happen. Inherit also puts
723 /// the violations on the terminal at the moment of failure instead of inside a 724 /// the violations on the terminal at the moment of failure instead of inside a
724 /// captured-stderr dump, and lets tier 3's budget lines be seen at all. 725 /// captured-stderr dump.
725 /// Every source file is still passed as a FILE arg: that is what declares the 726 /// Every source file is still passed as a FILE arg: that is what declares the
726 /// dependency and lets the build system resolve the paths. 727 /// dependency and lets the build system resolve the paths.
727 fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Build.Step) void { 728 fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Build.Step) void {
@@ -755,35 +756,17 @@ fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Bui
755 run_unit.setName("test docscheck"); 756 run_unit.setName("test docscheck");
756 check_step.dependOn(&run_unit.step); 757 check_step.dependOn(&run_unit.step);
757 758
758 for ([_]struct { name: []const u8, report: bool }{ 759 const run = b.addRunArtifact(exe);
759 .{ .name = "gate", .report = false }, 760 run.addArg("--check");
760 .{ .name = "report", .report = true }, 761 for (checked.items) |p| run.addFileArg(b.path(p));
761 }) |variant| { 762 run.addArg("--index");
762 const run = b.addRunArtifact(exe); 763 for (indexed.items) |p| run.addFileArg(b.path(p));
763 if (variant.report) run.addArg("--report"); 764 // `.inherit` carries its own term check — a non-zero exit fails the
764 run.addArg("--check"); 765 // step — so this needs no expectExitCode, and adding one would
765 for (checked.items) |p| run.addFileArg(b.path(p)); 766 // silently switch the step back to captured stdio.
766 run.addArg("--index"); 767 run.stdio = .inherit;
767 for (indexed.items) |p| run.addFileArg(b.path(p)); 768 run.setName("docscheck");
768 // A file arg, not a string: the step has to re-run when the budget 769 check_step.dependOn(&run.step);
769 // itself is edited, or lowering a number would never be checked.
770 run.addArg("--budget");
771 run.addFileArg(b.path("docscheck.budget"));
772 run.addArg("--blocks");
773 run.addFileArg(b.path("docscheck.blocks"));
774 // `.inherit` carries its own term check — a non-zero exit fails the
775 // step — so this needs no expectExitCode, and adding one would
776 // silently switch the step back to captured stdio.
777 run.stdio = .inherit;
778 if (variant.report) {
779 run.setName("docscheck --report");
780 const step = b.step("doc-report", "List the prose that outweighs the code it documents");
781 step.dependOn(&run.step);
782 } else {
783 run.setName("docscheck");
784 check_step.dependOn(&run.step);
785 }
786 }
787 } 770 }
788 771
789 /// Test registration order — the order failures ARRIVE in, and deliberately 772 /// Test registration order — the order failures ARRIVE in, and deliberately
docs/decisions.md
Old New
@@ -7621,3 +7621,35 @@ body-capable request that declared no length now closes its connection.
7621 301ms; the tail holds both whales (`pty` 5s, `daemon` 40s, Debug). A 7621 301ms; the tail holds both whales (`pty` 5s, `daemon` 40s, Debug). A
7622 wedged step prints nothing at all, so the verdicts above it are the only 7622 wedged step prints nothing at all, so the verdicts above it are the only
7623 legible catch. 7623 legible catch.
7624
7625 ## 2026-08-31 — the prose ratchets are removed: a counter buys metaphor
7626
7627 `tools/docscheck.zig` had four tiers. Two asked whether a comment still
7628 refers to something real (a cited symbol resolves; no project-history
7629 codenames). Two asked whether it was short enough — tier 3 flagged a doc
7630 block heavier in BYTES than the decl it documents, tier 4 counted five
7631 consecutive comment lines as an "essay", both held to an exact, down-only
7632 baseline in `docscheck.budget` and `docscheck.blocks`. Tiers 3 and 4 are
7633 gone, with their baselines and the `doc-report` step; tiers 1 and 2 stay.
7634
7635 - **The gate worked and the result was unreadable.** Both ratchets came down
7636 as designed over four commits. What they bought was not brevity but
7637 compression, and compressed English is metaphor: comments arrived at a
7638 passing byte count by replacing the explanation with a figure of speech,
7639 which is the one thing a comment cannot afford to be.
7640 - **The rule it produced said so out loud.** CLAUDE.md's own wording had
7641 become "Two lines budgets the CLAIM, not the words: a comment that will
7642 not fit plainly drops the claim, never the subject or the verb" — an
7643 instruction to delete true information to satisfy a counter. A gate whose
7644 documented use is "drop the claim" is measuring the wrong thing.
7645 - **Tier 3's ruler was backwards.** Weight was prose bytes against the
7646 DECL's bytes, so the shorter the code the less might be said about it.
7647 The code that most needs an explanation is short and dense; the code that
7648 can afford a long comment does not need one.
7649 - **Tiers 1 and 2 have no opinion about length**, which is why they stay.
7650 Tier 1 earned its keep the same week: the `src/cli/` rename in 7a530237
7651 (`Opts` → `DaemonArguments`) orphaned a citation in `src/client/client.zig`,
7652 a file that commit never opened. No counter was involved in catching it.
7653 - **What is unguarded now**: nothing stops a comment growing into narration
7654 again. That is a reviewer's job, and the trade is deliberate — a reviewer
7655 can tell an essay from an explanation and a byte count cannot.
docscheck.blocks
Old New
@@ -1,61 +0,0 @@
1 askpass.zig 3
2 client_core_wasm_check.zig 0
3 client_core.zig 0
4 client.zig 15
5 cmd.zig 0
6 delta.zig 2
7 docscheck.zig 0
8 engine.zig 5
9 flags.zig 0
10 handoff.zig 6
11 hosts.zig 1
12 interact.zig 24
13 keymap.zig 0
14 layout.zig 1
15 main.zig 7
16 muxa.zig 1
17 mux_main.zig 1
18 mux.zig 0
19 paint.zig 1
20 predict.zig 5
21 protocol.zig 9
22 proxy.zig 0
23 pty.zig 4
24 quic_server.zig 3
25 quic.zig 6
26 replica.zig 2
27 select.zig 0
28 server_agent.zig 3
29 server_sessions.zig 2
30 server_test_agent.zig 6
31 server_test_attach.zig 5
32 server_test_await.zig 3
33 server_test_clipboard.zig 6
34 server_test_deliver.zig 0
35 server_test_harness.zig 3
36 server_test_modes.zig 2
37 server_test_quic.zig 6
38 server_test_session.zig 5
39 server_test_upgrade.zig 1
40 server.zig 17
41 shellint.zig 1
42 sockpath.zig 1
43 spawn.zig 0
44 term.zig 0
45 testtmp.zig 0
46 upgrade.zig 1
47 wall_host.zig 1
48 wall_layout.zig 1
49 wall_picker.zig 1
50 wall_pump.zig 0
51 wall_test_harness.zig 1
52 wall_test_host.zig 0
53 wall_test_layout.zig 2
54 wall_test_picker.zig 4
55 wall_test_pump.zig 9
56 wall_test_wall.zig 7
57 wallview.zig 0
58 wasm_core.zig 1
59 webhub_main.zig 0
60 webhub.zig 4
61 xdg.zig 1
docscheck.budget
Old New
@@ -1,62 +0,0 @@
1 client_core_wasm_check.zig 0
2 client_core.zig 0
3 client.zig 0
4 cmd.zig 0
5 delta.zig 0
6 docscheck.zig 0
7 engine.zig 0
8 flags.zig 0
9 handoff.zig 0
10 hosts.zig 0
11 interact.zig 0
12 keymap.zig 0
13 layout.zig 623
14 main.zig 0
15 muxa.zig 0
16 mux_main.zig 0
17 paint.zig 0
18 predict.zig 0
19 protocol.zig 0
20 proxy.zig 0
21 pty.zig 0
22 quic_client.zig 0
23 quic_server.zig 0
24 quic.zig 0
25 replica.zig 0
26 select.zig 0
27 server.zig 0
28 shellint.zig 0
29 sockpath.zig 0
30 spawn.zig 0
31 testtmp.zig 0
32 upgrade.zig 269
33 wallview.zig 0
34 wasm_core.zig 0
35 webhub_main.zig 0
36 webhub.zig 0
37 xdg.zig 0
38 server_test_harness.zig 0
39 server_test_attach.zig 0
40 server_test_deliver.zig 0
41 server_test_modes.zig 0
42 server_test_session.zig 0
43 server_test_quic.zig 0
44 server_test_await.zig 0
45 server_test_clipboard.zig 0
46 server_test_agent.zig 0
47 server_test_upgrade.zig 0
48 server_agent.zig 0
49 server_sessions.zig 0
50 wall_host.zig 0
51 wall_picker.zig 0
52 wall_pump.zig 0
53 wall_layout.zig 890
54 wall_test_harness.zig 0
55 wall_test_host.zig 0
56 wall_test_picker.zig 0
57 wall_test_pump.zig 0
58 wall_test_layout.zig 0
59 wall_test_wall.zig 0
60 mux.zig 0
61 askpass.zig 0
62 term.zig 0
src/client/client.zig
Old New
@@ -1150,7 +1150,7 @@ pub fn spellingCap(target: Target) usize {
1150 return "--sock ".len + operand + 1 + proto.session_name_max; 1150 return "--sock ".len + operand + 1 + proto.session_name_max;
1151 } 1151 }
1152 1152
1153 // The grid a birth asks for: `main.Opts`'s own default, the size `mux d start` 1153 // The grid a birth asks for: `main.DaemonArguments`'s own default, the size `mux d start`
1154 // gives session 0. A session created for a client that claims no size has to be 1154 // gives session 0. A session created for a client that claims no size has to be
1155 // born at SOMETHING, and the daemon's own answer needs no explaining. 1155 // born at SOMETHING, and the daemon's own answer needs no explaining.
1156 pub const birth_cols: u16 = 80; 1156 pub const birth_cols: u16 = 80;
tools/docscheck.zig
Old New
@@ -1,4 +1,4 @@
1 //! The comment-discipline gate. Four tiers, all gates: 1 //! The comment-discipline gate. Two tiers, both gates:
2 //! 2 //!
3 //! 1. Every symbol a comment cites must resolve: a `module.symbol` rooted 3 //! 1. Every symbol a comment cites must resolve: a `module.symbol` rooted
4 //! at a module of this repo, or a file reference with no foreign 4 //! at a module of this repo, or a file reference with no foreign
@@ -6,17 +6,15 @@
6 //! 2. No project-history codenames — a bare milestone letter and number, 6 //! 2. No project-history codenames — a bare milestone letter and number,
7 //! or a phase and its ordinal. The EVENT ("the multi-session daemon") 7 //! or a phase and its ordinal. The EVENT ("the multi-session daemon")
8 //! survives; the codename names nothing a reader can look up. 8 //! survives; the codename names nothing a reader can look up.
9 //! 3. Prose that outweighs the code it documents: a doc block heavier in 9 //!
10 //! bytes than its decl, or a `//!` header past the cap. Every file 10 //! Both tiers ask whether a comment still refers to something real. Neither
11 //! meets its figure in docscheck.budget EXACTLY, so the number moves 11 //! has an opinion about length: the byte-budget and comment-block ratchets
12 //! only as a diff someone signed. 12 //! that used to live here rewarded compression, and what compressed English
13 //! 4. Comment essays: five consecutive comment lines are one block, 13 //! turns into is metaphor (docs/decisions.md, 2026-08-31).
14 //! counted per file against docscheck.blocks, exact like tier 3.
15 //! 14 //!
16 //! --check is inspected; --index answers "does this name exist". Both are 15 //! --check is inspected; --index answers "does this name exist". Both are
17 //! build-graph file args, so the step re-runs when their CONTENTS change. 16 //! build-graph file args, so the step re-runs when their CONTENTS change.
18 //! An empty group, or a missing baseline, is fatal: a check that never ran 17 //! An empty group is fatal: a check that never ran is green forever.
19 //! is green forever.
20 18
21 const std = @import("std"); 19 const std = @import("std");
22 20
@@ -25,12 +23,6 @@ fn writeAll(fd: std.posix.fd_t, bytes: []const u8) !void {
25 while (off < bytes.len) off += try std.posix.write(fd, bytes[off..]); 23 while (off < bytes.len) off += try std.posix.write(fd, bytes[off..]);
26 } 24 }
27 25
28 fn out(comptime fmt: []const u8, args: anytype) void {
29 var buf: [4096]u8 = undefined;
30 const s = std.fmt.bufPrint(&buf, fmt, args) catch return;
31 writeAll(std.posix.STDOUT_FILENO, s) catch {};
32 }
33
34 fn err(comptime fmt: []const u8, args: anytype) void { 26 fn err(comptime fmt: []const u8, args: anytype) void {
35 var buf: [4096]u8 = undefined; 27 var buf: [4096]u8 = undefined;
36 const s = std.fmt.bufPrint(&buf, fmt, args) catch return; 28 const s = std.fmt.bufPrint(&buf, fmt, args) catch return;
@@ -38,8 +30,7 @@ fn err(comptime fmt: []const u8, args: anytype) void {
38 } 30 }
39 31
40 fn usage() u8 { 32 fn usage() u8 {
41 err("usage: docscheck [--report] --check FILE... --index FILE... " ++ 33 err("usage: docscheck --check FILE... --index FILE...\n", .{});
42 "--budget FILE --blocks FILE\n", .{});
43 return 2; 34 return 2;
44 } 35 }
45 36
@@ -86,19 +77,6 @@ fn codeOf(line: []u8) []u8 {
86 return line; 77 return line;
87 } 78 }
88 79
89 /// A comment line's weight is its prose: the `///` is syntax, and the
90 /// indentation is the decl's, not the comment's.
91 fn proseBytes(line: []const u8) usize {
92 const t = std.mem.trim(u8, line, " \t\r");
93 return std.mem.trimLeft(u8, t[@min(3, t.len)..], " ").len;
94 }
95
96 fn indentOf(line: []const u8) usize {
97 var i: usize = 0;
98 while (i < line.len and line[i] == ' ') i += 1;
99 return i;
100 }
101
102 const keywords = [_][]const u8{ 80 const keywords = [_][]const u8{
103 "align", "allowzero", "and", "anyframe", "anytype", "asm", 81 "align", "allowzero", "and", "anyframe", "anytype", "asm",
104 "async", "await", "break", "callconv", "catch", "comptime", 82 "async", "await", "break", "callconv", "catch", "comptime",
@@ -235,43 +213,27 @@ pub fn main() !u8 {
235 const args = try std.process.argsAlloc(alloc); 213 const args = try std.process.argsAlloc(alloc);
236 defer std.process.argsFree(alloc, args); 214 defer std.process.argsFree(alloc, args);
237 215
238 var report = false;
239 var check_files: std.ArrayList([]const u8) = .empty; 216 var check_files: std.ArrayList([]const u8) = .empty;
240 defer check_files.deinit(alloc); 217 defer check_files.deinit(alloc);
241 var index_files: std.ArrayList([]const u8) = .empty; 218 var index_files: std.ArrayList([]const u8) = .empty;
242 defer index_files.deinit(alloc); 219 defer index_files.deinit(alloc);
243 220
244 var budget_path: ?[]const u8 = null; 221 var group: enum { none, check, index } = .none;
245 var blocks_path: ?[]const u8 = null;
246 var group: enum { none, check, index, budget, blocks } = .none;
247 var ai: usize = 1; 222 var ai: usize = 1;
248 while (ai < args.len) : (ai += 1) { 223 while (ai < args.len) : (ai += 1) {
249 const a = args[ai]; 224 const a = args[ai];
250 if (std.mem.eql(u8, a, "--report")) { 225 if (std.mem.eql(u8, a, "--check")) {
251 report = true;
252 } else if (std.mem.eql(u8, a, "--check")) {
253 group = .check; 226 group = .check;
254 } else if (std.mem.eql(u8, a, "--index")) { 227 } else if (std.mem.eql(u8, a, "--index")) {
255 group = .index; 228 group = .index;
256 } else if (std.mem.eql(u8, a, "--budget")) {
257 group = .budget;
258 } else if (std.mem.eql(u8, a, "--blocks")) {
259 group = .blocks;
260 } else switch (group) { 229 } else switch (group) {
261 .none => return usage(), 230 .none => return usage(),
262 .check => try check_files.append(alloc, a), 231 .check => try check_files.append(alloc, a),
263 .index => try index_files.append(alloc, a), 232 .index => try index_files.append(alloc, a),
264 .budget => budget_path = a,
265 .blocks => blocks_path = a,
266 } 233 }
267 } 234 }
268 // A gate handed nothing to gate is the "check that never ran" failure 235 // A gate handed nothing to gate is the "check that never ran" failure
269 // mode, and it passes green forever. Refuse instead. 236 // mode, and it passes green forever. Refuse instead.
270 if (budget_path == null or blocks_path == null) {
271 err("docscheck: no --budget or --blocks file — the gate has nothing " ++
272 "to hold the count to\n", .{});
273 return 2;
274 }
275 if (check_files.items.len == 0 or index_files.items.len == 0) { 237 if (check_files.items.len == 0 or index_files.items.len == 0) {
276 err("docscheck: empty --check or --index group ({d} check, {d} index) — " ++ 238 err("docscheck: empty --check or --index group ({d} check, {d} index) — " ++
277 "a gate with no inputs passes green forever\n", .{ check_files.items.len, index_files.items.len }); 239 "a gate with no inputs passes green forever\n", .{ check_files.items.len, index_files.items.len });
@@ -324,20 +286,9 @@ pub fn main() !u8 {
324 defer t1.deinit(alloc); 286 defer t1.deinit(alloc);
325 var t2: std.ArrayList(Finding) = .empty; 287 var t2: std.ArrayList(Finding) = .empty;
326 defer t2.deinit(alloc); 288 defer t2.deinit(alloc);
327 var t3: std.ArrayList([]const u8) = .empty;
328 defer {
329 for (t3.items) |s| alloc.free(s);
330 t3.deinit(alloc);
331 }
332
333 var lines: std.ArrayList([]const u8) = .empty; 289 var lines: std.ArrayList([]const u8) = .empty;
334 defer lines.deinit(alloc); 290 defer lines.deinit(alloc);
335 291
336 var counts: std.ArrayList(Tally) = .empty;
337 defer counts.deinit(alloc);
338 var essays: std.ArrayList(Tally) = .empty;
339 defer essays.deinit(alloc);
340
341 for (check_files.items) |p| { 292 for (check_files.items) |p| {
342 const f = std.fs.cwd().openFile(p, .{}) catch |e| { 293 const f = std.fs.cwd().openFile(p, .{}) catch |e| {
343 err("docscheck: cannot open {s} ({s})\n", .{ p, @errorName(e) }); 294 err("docscheck: cannot open {s} ({s})\n", .{ p, @errorName(e) });
@@ -412,10 +363,6 @@ pub fn main() !u8 {
412 } 363 }
413 } 364 }
414 } 365 }
415
416 const flagged = try heavyBlocks(alloc, lines.items, name, &t3);
417 try counts.append(alloc, .{ .name = name, .n = flagged });
418 try essays.append(alloc, .{ .name = name, .n = essayBlocks(lines.items) });
419 } 366 }
420 367
421 for (t1.items) |v| 368 for (t1.items) |v|
@@ -424,22 +371,6 @@ pub fn main() !u8 {
424 err("{s}:{d}: comment names the project-history codename \"{s}\" — " ++ 371 err("{s}:{d}: comment names the project-history codename \"{s}\" — " ++
425 "name the event instead; codenames belong in docs/decisions.md\n", .{ v.file, v.line, v.text }); 372 "name the event instead; codenames belong in docs/decisions.md\n", .{ v.file, v.line, v.text });
426 373
427 if (report) {
428 out("flagged doc blocks ({d}):\n", .{t3.items.len});
429 for (t3.items) |s| out(" {s}", .{s});
430 out("\nThe worklist. `zig build check` holds each file to its line in the budget.\n", .{});
431 // The baseline printed by the counter that later gates it: a figure
432 // arrived at any other way is a guess this tool will disagree with.
433 var total: usize = 0;
434 for (essays.items) |c| total += c.n;
435 out("\ncomment blocks ({d}) — docscheck.blocks, verbatim:\n", .{total});
436 for (essays.items) |c| out("{s} {d}\n", .{ c.name, c.n });
437 } else {
438 const over_budget = try gate(alloc, budget_path.?, counts.items, .prose);
439 const over_baseline = try gate(alloc, blocks_path.?, essays.items, .blocks);
440 if (over_budget or over_baseline) return 1;
441 }
442
443 if (t1.items.len + t2.items.len > 0) { 374 if (t1.items.len + t2.items.len > 0) {
444 err("docscheck: {d} unresolved citation(s), {d} codename(s)\n", .{ t1.items.len, t2.items.len }); 375 err("docscheck: {d} unresolved citation(s), {d} codename(s)\n", .{ t1.items.len, t2.items.len });
445 return 1; 376 return 1;
@@ -447,208 +378,6 @@ pub fn main() !u8 {
447 return 0; 378 return 0;
448 } 379 }
449 380
450 /// The cap a `//!` module header answers to, having no decl to outweigh.
451 const header_max = 1024;
452
453 const Tally = struct { name: []const u8, n: usize };
454
455 fn parseBudget(alloc: std.mem.Allocator, text: []const u8) ![]Tally {
456 var list: std.ArrayList(Tally) = .empty;
457 errdefer list.deinit(alloc);
458 var it = std.mem.splitScalar(u8, text, '\n');
459 while (it.next()) |raw| {
460 const line = std.mem.trim(u8, raw, " \t\r");
461 if (line.len == 0) continue;
462 var f = std.mem.tokenizeAny(u8, line, " \t");
463 const name = f.next() orelse return error.BadBudgetLine;
464 const num = f.next() orelse return error.BadBudgetLine;
465 if (f.next() != null) return error.BadBudgetLine;
466 const allowed = std.fmt.parseInt(usize, num, 10) catch return error.BadBudgetLine;
467 try list.append(alloc, .{ .name = name, .n = allowed });
468 }
469 return list.toOwnedSlice(alloc);
470 }
471 const Verdict = enum { ok, over, under, unlisted };
472
473 /// What a file's number counts, which is the whole difference between the two
474 /// baselines: bytes of flagged prose, or comment blocks of `block_min` lines.
475 const Unit = enum { prose, blocks };
476
477 /// Each file is held to its own line in the budget: the count may not rise,
478 /// and may not sit below it either. Returns true when the tree is off budget.
479 fn gate(alloc: std.mem.Allocator, path: []const u8, counts: []const Tally, unit: Unit) !bool {
480 const bf = std.fs.cwd().openFile(path, .{}) catch |e| {
481 err("docscheck: cannot open budget {s} ({s})\n", .{ path, @errorName(e) });
482 std.process.exit(2);
483 };
484 const btext = try bf.readToEndAlloc(alloc, 1 << 20);
485 bf.close();
486 defer alloc.free(btext);
487 const budget = parseBudget(alloc, btext) catch |e| {
488 err("docscheck: {s} does not parse ({s}) — one `FILE COUNT` per line\n", .{ path, @errorName(e) });
489 std.process.exit(2);
490 };
491 defer alloc.free(budget);
492
493 var breach: usize = 0;
494 for (counts) |c| switch (verdict(budget, c.name, c.n)) {
495 .ok => {},
496 .over => {
497 breach += 1;
498 switch (unit) {
499 .prose => err("{s}: {d} bytes of flagged prose, budget {d} — shrink a comment or grow the code " ++
500 "into it; raising the budget is a diff line that has to argue for itself\n", .{ c.name, c.n, budgetOf(budget, c.name) }),
501 .blocks => err("{s}: {d} comment blocks of {d}+ lines, baseline {d} — a comment is two lines " ++
502 "in place; the rest belongs in docs/decisions.md or a test's failure message\n", .{ c.name, c.n, block_min, budgetOf(budget, c.name) }),
503 }
504 },
505 .under => {
506 breach += 1;
507 switch (unit) {
508 .prose => err("{s}: {d} bytes of flagged prose, budget {d} — lower the budget to {d}; " ++
509 "slack left in the file is room to regrow for free\n", .{ c.name, c.n, budgetOf(budget, c.name), c.n }),
510 .blocks => err("{s}: {d} comment blocks of {d}+ lines, baseline {d} — lower the baseline to {d}; " ++
511 "slack left in the file is room to regrow for free\n", .{ c.name, c.n, block_min, budgetOf(budget, c.name), c.n }),
512 }
513 },
514 .unlisted => {
515 breach += 1;
516 err("{s}: not in {s} — add `{s} {d}`\n", .{ c.name, path, c.name, c.n });
517 },
518 };
519 if (breach == 0) return false;
520 err("docscheck: {d} file(s) off {s}\n", .{ breach, switch (unit) {
521 .prose => "budget",
522 .blocks => "the comment-block baseline",
523 } });
524 return true;
525 }
526
527 fn budgetOf(budget: []const Tally, name: []const u8) usize {
528 for (budget) |b| if (std.mem.eql(u8, b.name, name)) return b.n;
529 return 0;
530 }
531
532 fn verdict(budget: []const Tally, name: []const u8, flags: usize) Verdict {
533 for (budget) |b| {
534 if (!std.mem.eql(u8, b.name, name)) continue;
535 if (flags > b.n) return .over;
536 if (flags < b.n) return .under;
537 return .ok;
538 }
539 return .unlisted;
540 }
541
542 /// Where a comment stops being a comment and becomes an essay.
543 const block_min = 5;
544
545 /// Tier 4 — comment blocks, counted not weighed. A `//!` header answers to
546 /// tier 3's cap and breaks a run like any other line, so a module contract
547 /// is never an essay. A block counts once however long it runs.
548 fn essayBlocks(lines: []const []const u8) usize {
549 var blocks: usize = 0;
550 var run: usize = 0;
551 for (lines) |line| {
552 const body = commentBody(line) orelse {
553 run = 0;
554 continue;
555 };
556 if (std.mem.startsWith(u8, body, "//!")) {
557 run = 0;
558 continue;
559 }
560 run += 1;
561 if (run == block_min) blocks += 1;
562 }
563 return blocks;
564 }
565
566 fn heavyBlocks(
567 alloc: std.mem.Allocator,
568 lines: []const []const u8,
569 name: []const u8,
570 t3: *std.ArrayList([]const u8),
571 ) !usize {
572 var flagged: usize = 0;
573 // Tier 3 — doc blocks heavier than the decl they document. Line
574 // arithmetic, not a parser, and exact enough because `zig fmt --check`
575 // is already a gate: indentation is canonical, so a decl's closing
576 // brace sits at the decl's own indent and nowhere else.
577
578 // A `//!` header answers to no decl, so it answers to the cap — sized so
579 // the largest module in this tree states its contract in about half of it.
580 var header: usize = 0;
581 var hi: usize = 0;
582 while (hi < lines.len and
583 std.mem.startsWith(u8, std.mem.trimLeft(u8, lines[hi], " \t"), "//!")) : (hi += 1)
584 header += proseBytes(lines[hi]);
585 if (header > header_max) {
586 flagged += header;
587 const s = try std.fmt.allocPrint(alloc, "{s}:1 header={d}b cap={d}b\n", .{ name, header, header_max });
588 try t3.append(alloc, s);
589 }
590
591 var li: usize = 0;
592 while (li < lines.len) {
593 const t = std.mem.trimLeft(u8, lines[li], " \t");
594 // `//!` is a file header, attached to no decl.
595 if (!std.mem.startsWith(u8, t, "///")) {
596 li += 1;
597 continue;
598 }
599 var block: usize = 0;
600 while (li < lines.len and
601 std.mem.startsWith(u8, std.mem.trimLeft(u8, lines[li], " \t"), "///")) : (li += 1)
602 block += proseBytes(lines[li]);
603 if (li >= lines.len) break;
604 const decl = li;
605 const dt = std.mem.trimLeft(u8, lines[decl], " \t");
606 if (dt.len == 0 or std.mem.startsWith(u8, dt, "//")) continue;
607 const indent = indentOf(lines[decl]);
608 // The decl's span: every following line indented deeper, plus the
609 // closing brace that returns to the decl's own indent.
610 var last = decl;
611 var j = decl + 1;
612 while (j < lines.len) : (j += 1) {
613 const lt = std.mem.trim(u8, lines[j], " \t\r");
614 if (lt.len == 0) continue;
615 if (indentOf(lines[j]) > indent) {
616 last = j;
617 continue;
618 }
619 if (indentOf(lines[j]) == indent and lt[0] == '}') last = j;
620 break;
621 }
622 // A decl with no body has nothing for a comment to outweigh, so the
623 // comparison is meaningless there rather than merely lenient.
624 if (last == decl) continue;
625 var span: usize = 0;
626 for (lines[decl .. last + 1]) |l| span += std.mem.trim(u8, l, " \t\r").len;
627 if (block > span) {
628 flagged += block;
629 const nm = declName(dt);
630 const s = try std.fmt.allocPrint(alloc, "{s}:{d} block={d}b decl={d}b {s}\n", .{ name, decl + 1, block, span, nm });
631 try t3.append(alloc, s);
632 }
633 }
634 return flagged;
635 }
636
637 /// Best-effort name for the report's third column. Cosmetic: the file:line is
638 /// what a reader navigates by.
639 fn declName(dt: []const u8) []const u8 {
640 var it = std.mem.tokenizeAny(u8, dt, " \t(:=");
641 while (it.next()) |w| {
642 if (std.mem.eql(u8, w, "pub") or std.mem.eql(u8, w, "export") or
643 std.mem.eql(u8, w, "extern") or std.mem.eql(u8, w, "inline") or
644 std.mem.eql(u8, w, "threadlocal") or std.mem.eql(u8, w, "comptime") or
645 std.mem.eql(u8, w, "fn") or std.mem.eql(u8, w, "const") or
646 std.mem.eql(u8, w, "var")) continue;
647 return w;
648 }
649 return dt;
650 }
651
652 test "codenames are recognised, domain vocabulary is not" { 381 test "codenames are recognised, domain vocabulary is not" {
653 // The corpus that set these rules: `cmd.phase` is a live field with ten 382 // The corpus that set these rules: `cmd.phase` is a live field with ten
654 // comment mentions, so bare "phase" must never fire. 383 // comment mentions, so bare "phase" must never fire.
@@ -748,161 +477,3 @@ test "only leading // is a comment, so string literals are left alone" {
748 try std.testing.expect(commentBody(" const u = \"quic://box:4433\";") == null); 477 try std.testing.expect(commentBody(" const u = \"quic://box:4433\";") == null);
749 try std.testing.expect(commentBody(" ov.setMode(); // raw") == null); 478 try std.testing.expect(commentBody(" ov.setMode(); // raw") == null);
750 } 479 }
751
752 test "a doc block on a one-line decl is never heavy" {
753 const alloc = std.testing.allocator;
754 var t3: std.ArrayList([]const u8) = .empty;
755 defer {
756 for (t3.items) |s| alloc.free(s);
757 t3.deinit(alloc);
758 }
759 // 336 of the original 498 flags looked like this. A field has no body for
760 // a comment to outweigh, so `block > span` was comparing prose against a
761 // line that is pure declaration — the ruler, not the comment, was wrong.
762 const src = [_][]const u8{
763 " /// How long a dial waits before it gives up.",
764 " /// Shorter than the shell's own settle, deliberately.",
765 " /// Measured on the LAN box rather than guessed.",
766 " deadline_ms: u32 = 250,",
767 "};",
768 };
769 _ = try heavyBlocks(alloc, &src, "fixture.zig", &t3);
770 try std.testing.expectEqual(@as(usize, 0), t3.items.len);
771 }
772
773 test "weight is bytes, so a comment cannot get lighter by wrapping" {
774 const alloc = std.testing.allocator;
775 var t3: std.ArrayList([]const u8) = .empty;
776 defer {
777 for (t3.items) |s| alloc.free(s);
778 t3.deinit(alloc);
779 }
780 // Two long lines of prose over three short lines of code. Counting lines
781 // calls that light and rewards a 100-column comment over a wrapped one.
782 const src = [_][]const u8{
783 "/// The reconnect backoff doubles from 50ms and stops at two seconds, which is where a human gives up",
784 "/// waiting and reaches for the keyboard, so climbing past it buys nothing anyone is still there to see.",
785 "fn backoff(n: u32) u32 {",
786 " return @min(50 << n, 2000);",
787 "}",
788 };
789 _ = try heavyBlocks(alloc, &src, "fixture.zig", &t3);
790 try std.testing.expectEqual(@as(usize, 1), t3.items.len);
791 }
792
793 test "a module header is weighed against the cap, not against a decl" {
794 const alloc = std.testing.allocator;
795 var t3: std.ArrayList([]const u8) = .empty;
796 defer {
797 for (t3.items) |s| alloc.free(s);
798 t3.deinit(alloc);
799 }
800 var short = [_][]const u8{"//! Reaches a muxd and says what it came to say."};
801 _ = try heavyBlocks(alloc, &short, "short.zig", &t3);
802 try std.testing.expectEqual(@as(usize, 0), t3.items.len);
803
804 var long: std.ArrayList([]const u8) = .empty;
805 defer long.deinit(alloc);
806 // 40 lines of contract is a header telling a story. server.zig is the
807 // largest module in this tree and states its contract in half the cap.
808 for (0..40) |_| try long.append(alloc, "//! sixty-four bytes of module contract, give or take a word or.");
809 _ = try heavyBlocks(alloc, long.items, "long.zig", &t3);
810 try std.testing.expectEqual(@as(usize, 1), t3.items.len);
811 }
812
813 test "a budget is met exactly, so the number can only move in a diff" {
814 const budget = [_]Tally{
815 .{ .name = "client.zig", .n = 12 },
816 .{ .name = "wall.zig", .n = 0 },
817 };
818 try std.testing.expectEqual(Verdict.ok, verdict(&budget, "client.zig", 12));
819 try std.testing.expectEqual(Verdict.over, verdict(&budget, "client.zig", 13));
820 // Slack is refused too: a file that improved and left its budget high can
821 // regrow into the gap for free, which is how a ratchet stops ratcheting.
822 try std.testing.expectEqual(Verdict.under, verdict(&budget, "client.zig", 11));
823 try std.testing.expectEqual(Verdict.ok, verdict(&budget, "wall.zig", 0));
824 // An unlisted file cannot pass by being unknown.
825 try std.testing.expectEqual(Verdict.unlisted, verdict(&budget, "new.zig", 0));
826 }
827
828 test "a budget line that does not parse fails the gate rather than vanishing" {
829 const alloc = std.testing.allocator;
830 const good = try parseBudget(alloc, "client.zig 12\n\nwall.zig 0\n");
831 defer alloc.free(good);
832 try std.testing.expectEqual(@as(usize, 2), good.len);
833 try std.testing.expectEqualStrings("client.zig", good[0].name);
834 try std.testing.expectEqual(@as(usize, 0), good[1].n);
835
836 try std.testing.expectError(error.BadBudgetLine, parseBudget(alloc, "client.zig twelve\n"));
837 try std.testing.expectError(error.BadBudgetLine, parseBudget(alloc, "client.zig\n"));
838 }
839
840 test "five consecutive comment lines are an essay, four are a comment" {
841 const four = [_][]const u8{
842 " // The dial's deadline is the shell's settle, minus a tick.",
843 " // Any longer and the redial races the daemon's own close.",
844 " // Measured on the LAN box.",
845 " // Not guessed.",
846 " deadline_ms = 250;",
847 };
848 try std.testing.expectEqual(@as(usize, 0), essayBlocks(&four));
849
850 const five = [_][]const u8{
851 " // one", " // two", " /// three", " // four", " // five",
852 " deadline_ms = 250;",
853 };
854 try std.testing.expectEqual(@as(usize, 1), essayBlocks(&five));
855
856 // Length past the fifth line is already an essay, so it cannot cost more:
857 // the gate counts how many there are, not how far each one runs.
858 const long = [_][]const u8{" // narration"} ** 12 ++ [_][]const u8{" deadline_ms = 250;"};
859 try std.testing.expectEqual(@as(usize, 1), essayBlocks(&long));
860 }
861
862 test "a run breaks on anything that is not a comment, a module header included" {
863 const four = [_][]const u8{ "// a", "// b", "// c", "// d" };
864 const blank = four ++ [_][]const u8{""} ++ four;
865 try std.testing.expectEqual(@as(usize, 0), essayBlocks(&blank));
866
867 const code = four ++ [_][]const u8{"const x = 1;"} ++ four;
868 try std.testing.expectEqual(@as(usize, 0), essayBlocks(&code));
869
870 // A module contract is weighed by tier 3's cap and must not be an essay
871 // as well, or every file in the tree would open with one.
872 const header = [_][]const u8{ "//! a", "//! b", "//! c", "//! d", "//! e", "//! f" };
873 try std.testing.expectEqual(@as(usize, 0), essayBlocks(&header));
874 try std.testing.expectEqual(@as(usize, 0), essayBlocks(&(four ++ [_][]const u8{"//! header"} ++ four)));
875
876 // Two essays in one file are two, and the blank between them is what
877 // separates them rather than joining them into one.
878 const five = [_][]const u8{ "// a", "// b", "// c", "// d", "// e" };
879 try std.testing.expectEqual(@as(usize, 2), essayBlocks(&(five ++ [_][]const u8{""} ++ five)));
880 }
881
882 test "an already-flagged block that grows still moves the number" {
883 const alloc = std.testing.allocator;
884 var t3: std.ArrayList([]const u8) = .empty;
885 defer {
886 for (t3.items) |s| alloc.free(s);
887 t3.deinit(alloc);
888 }
889 const small = [_][]const u8{
890 "/// True while marks say a command is open — the window in which the",
891 "/// pgid fallback must NOT race the marks to a verdict.",
892 "pub fn marksOpen(self: *const Tracker) bool {",
893 " return self.marks_seen and self.phase == .running;",
894 "}",
895 };
896 const grown = [_][]const u8{
897 "/// True while marks say a command is open — the window in which the",
898 "/// pgid fallback must NOT race the marks to a verdict.",
899 "/// A paragraph of narration appended to a block that was already the",
900 "/// file's one flag: counting flags calls this free.",
901 "pub fn marksOpen(self: *const Tracker) bool {",
902 " return self.marks_seen and self.phase == .running;",
903 "}",
904 };
905 const a = try heavyBlocks(alloc, &small, "fixture.zig", &t3);
906 const b = try heavyBlocks(alloc, &grown, "fixture.zig", &t3);
907 try std.testing.expect(b > a);
908 }