8f9fef4e
Remove the prose ratchets from the comment gate
a73x 2026-08-31 10:33
Commit message
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 | } | ||