a73x

66f43541

feat: five comment lines are an essay, and the count only falls

a73x   2026-08-30 13:50

Commit message
feat: five comment lines are an essay, and the count only falls

The gate had a tier for prose that outweighs its decl and nothing at all
for a block that outweighs the reader. docscheck.blocks is tier 3's budget
in another unit: one line per file, met exactly, so an added essay fails
loudly and a removed one demands the number come down.

build.zig
Old New
@@ -769,6 +769,8 @@ fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Bui
769 // itself is edited, or lowering a number would never be checked. 769 // itself is edited, or lowering a number would never be checked.
770 run.addArg("--budget"); 770 run.addArg("--budget");
771 run.addFileArg(b.path("docscheck.budget")); 771 run.addFileArg(b.path("docscheck.budget"));
772 run.addArg("--blocks");
773 run.addFileArg(b.path("docscheck.blocks"));
772 // `.inherit` carries its own term check — a non-zero exit fails the 774 // `.inherit` carries its own term check — a non-zero exit fails the
773 // step — so this needs no expectExitCode, and adding one would 775 // step — so this needs no expectExitCode, and adding one would
774 // silently switch the step back to captured stdio. 776 // silently switch the step back to captured stdio.
docscheck.blocks
Old New
@@ -0,0 +1,61 @@
1 askpass.zig 10
2 client_core_wasm_check.zig 0
3 client_core.zig 1
4 client.zig 56
5 cmd.zig 2
6 delta.zig 8
7 docscheck.zig 4
8 engine.zig 17
9 flags.zig 2
10 handoff.zig 26
11 hosts.zig 5
12 interact.zig 96
13 keymap.zig 1
14 layout.zig 8
15 main.zig 57
16 muxa.zig 22
17 mux_main.zig 16
18 mux.zig 2
19 paint.zig 11
20 predict.zig 15
21 protocol.zig 13
22 proxy.zig 1
23 pty.zig 16
24 quic_server.zig 30
25 quic.zig 20
26 replica.zig 3
27 select.zig 4
28 server_agent.zig 8
29 server_sessions.zig 9
30 server_test_agent.zig 6
31 server_test_attach.zig 5
32 server_test_await.zig 16
33 server_test_clipboard.zig 20
34 server_test_deliver.zig 0
35 server_test_harness.zig 7
36 server_test_modes.zig 11
37 server_test_quic.zig 6
38 server_test_session.zig 18
39 server_test_upgrade.zig 1
40 server.zig 108
41 shellint.zig 7
42 sockpath.zig 5
43 spawn.zig 2
44 term.zig 0
45 testtmp.zig 1
46 upgrade.zig 2
47 wall_host.zig 7
48 wall_layout.zig 4
49 wall_picker.zig 6
50 wall_pump.zig 45
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 64
58 wasm_core.zig 5
59 webhub_main.zig 4
60 webhub.zig 29
61 xdg.zig 4
tools/docscheck.zig
Old New
@@ -1,24 +1,22 @@
1 //! The comment-discipline gate. Three tiers, all gates: 1 //! The comment-discipline gate. Four tiers, all 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
5 //! directory prefix. The name must appear in the indexed CODE. Skip 5 //! directory prefix. The name must appear in the indexed CODE.
6 //! classes are pinned in the tests.
7 //! 2. No project-history codenames — a bare milestone letter and number, 6 //! 2. No project-history codenames — a bare milestone letter and number,
8 //! 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")
9 //! survives; the codename names nothing a reader can look up. 8 //! survives; the codename names nothing a reader can look up.
10 //! 3. Prose that outweighs the code it documents: a doc block heavier in 9 //! 3. Prose that outweighs the code it documents: a doc block heavier in
11 //! bytes than its decl, or a `//!` header past the cap. A bodyless 10 //! bytes than its decl, or a `//!` header past the cap. Every file
12 //! decl is exempt. Every file meets its figure in docscheck.budget 11 //! meets its figure in docscheck.budget EXACTLY, so the number moves
13 //! EXACTLY, so the number moves only as a diff someone signed. 12 //! only as a diff someone signed.
14 //! 13 //! 4. Comment essays: five consecutive comment lines are one block,
15 //! Usage: 14 //! counted per file against docscheck.blocks, exact like tier 3.
16 //! docscheck [--report] --check FILE... --index FILE... --budget FILE
17 //! 15 //!
18 //! --check is inspected; --index answers "does this name exist". Both are 16 //! --check is inspected; --index answers "does this name exist". Both are
19 //! build-graph file args, so the step re-runs when their CONTENTS change. 17 //! build-graph file args, so the step re-runs when their CONTENTS change.
20 //! An empty group, or no --budget, is fatal: a check that never ran is green 18 //! An empty group, or a missing baseline, is fatal: a check that never ran
21 //! forever. 19 //! is green forever.
22 20
23 const std = @import("std"); 21 const std = @import("std");
24 22
@@ -40,7 +38,8 @@ fn err(comptime fmt: []const u8, args: anytype) void {
40 } 38 }
41 39
42 fn usage() u8 { 40 fn usage() u8 {
43 err("usage: docscheck [--report] --check FILE... --index FILE... --budget FILE\n", .{}); 41 err("usage: docscheck [--report] --check FILE... --index FILE... " ++
42 "--budget FILE --blocks FILE\n", .{});
44 return 2; 43 return 2;
45 } 44 }
46 45
@@ -254,7 +253,8 @@ pub fn main() !u8 {
254 defer index_files.deinit(alloc); 253 defer index_files.deinit(alloc);
255 254
256 var budget_path: ?[]const u8 = null; 255 var budget_path: ?[]const u8 = null;
257 var group: enum { none, check, index, budget } = .none; 256 var blocks_path: ?[]const u8 = null;
257 var group: enum { none, check, index, budget, blocks } = .none;
258 var ai: usize = 1; 258 var ai: usize = 1;
259 while (ai < args.len) : (ai += 1) { 259 while (ai < args.len) : (ai += 1) {
260 const a = args[ai]; 260 const a = args[ai];
@@ -266,17 +266,21 @@ pub fn main() !u8 {
266 group = .index; 266 group = .index;
267 } else if (std.mem.eql(u8, a, "--budget")) { 267 } else if (std.mem.eql(u8, a, "--budget")) {
268 group = .budget; 268 group = .budget;
269 } else if (std.mem.eql(u8, a, "--blocks")) {
270 group = .blocks;
269 } else switch (group) { 271 } else switch (group) {
270 .none => return usage(), 272 .none => return usage(),
271 .check => try check_files.append(alloc, a), 273 .check => try check_files.append(alloc, a),
272 .index => try index_files.append(alloc, a), 274 .index => try index_files.append(alloc, a),
273 .budget => budget_path = a, 275 .budget => budget_path = a,
276 .blocks => blocks_path = a,
274 } 277 }
275 } 278 }
276 // A gate handed nothing to gate is the "check that never ran" failure 279 // A gate handed nothing to gate is the "check that never ran" failure
277 // mode, and it passes green forever. Refuse instead. 280 // mode, and it passes green forever. Refuse instead.
278 if (budget_path == null) { 281 if (budget_path == null or blocks_path == null) {
279 err("docscheck: no --budget file — the gate has nothing to hold the count to\n", .{}); 282 err("docscheck: no --budget or --blocks file — the gate has nothing " ++
283 "to hold the count to\n", .{});
280 return 2; 284 return 2;
281 } 285 }
282 if (check_files.items.len == 0 or index_files.items.len == 0) { 286 if (check_files.items.len == 0 or index_files.items.len == 0) {
@@ -347,6 +351,8 @@ pub fn main() !u8 {
347 351
348 var counts: std.ArrayList(Tally) = .empty; 352 var counts: std.ArrayList(Tally) = .empty;
349 defer counts.deinit(alloc); 353 defer counts.deinit(alloc);
354 var essays: std.ArrayList(Tally) = .empty;
355 defer essays.deinit(alloc);
350 356
351 for (check_files.items) |p| { 357 for (check_files.items) |p| {
352 const f = std.fs.cwd().openFile(p, .{}) catch |e| { 358 const f = std.fs.cwd().openFile(p, .{}) catch |e| {
@@ -425,6 +431,7 @@ pub fn main() !u8 {
425 431
426 const flagged = try heavyBlocks(alloc, lines.items, name, &t3); 432 const flagged = try heavyBlocks(alloc, lines.items, name, &t3);
427 try counts.append(alloc, .{ .name = name, .n = flagged }); 433 try counts.append(alloc, .{ .name = name, .n = flagged });
434 try essays.append(alloc, .{ .name = name, .n = essayBlocks(lines.items) });
428 } 435 }
429 436
430 for (t1.items) |v| 437 for (t1.items) |v|
@@ -437,7 +444,17 @@ pub fn main() !u8 {
437 out("flagged doc blocks ({d}):\n", .{t3.items.len}); 444 out("flagged doc blocks ({d}):\n", .{t3.items.len});
438 for (t3.items) |s| out(" {s}", .{s}); 445 for (t3.items) |s| out(" {s}", .{s});
439 out("\nThe worklist. `zig build check` holds each file to its line in the budget.\n", .{}); 446 out("\nThe worklist. `zig build check` holds each file to its line in the budget.\n", .{});
440 } else if (try gate(alloc, budget_path.?, counts.items)) return 1; 447 // The baseline printed by the counter that later gates it: a figure
448 // arrived at any other way is a guess this tool will disagree with.
449 var total: usize = 0;
450 for (essays.items) |c| total += c.n;
451 out("\ncomment blocks ({d}) — docscheck.blocks, verbatim:\n", .{total});
452 for (essays.items) |c| out("{s} {d}\n", .{ c.name, c.n });
453 } else {
454 const over_budget = try gate(alloc, budget_path.?, counts.items, .prose);
455 const over_baseline = try gate(alloc, blocks_path.?, essays.items, .blocks);
456 if (over_budget or over_baseline) return 1;
457 }
441 458
442 if (t1.items.len + t2.items.len > 0) { 459 if (t1.items.len + t2.items.len > 0) {
443 err("docscheck: {d} unresolved citation(s), {d} codename(s)\n", .{ t1.items.len, t2.items.len }); 460 err("docscheck: {d} unresolved citation(s), {d} codename(s)\n", .{ t1.items.len, t2.items.len });
@@ -469,9 +486,13 @@ fn parseBudget(alloc: std.mem.Allocator, text: []const u8) ![]Tally {
469 } 486 }
470 const Verdict = enum { ok, over, under, unlisted }; 487 const Verdict = enum { ok, over, under, unlisted };
471 488
489 /// What a file's number counts, which is the whole difference between the two
490 /// baselines: bytes of flagged prose, or comment blocks of `block_min` lines.
491 const Unit = enum { prose, blocks };
492
472 /// Each file is held to its own line in the budget: the count may not rise, 493 /// Each file is held to its own line in the budget: the count may not rise,
473 /// and may not sit below it either. Returns true when the tree is off budget. 494 /// and may not sit below it either. Returns true when the tree is off budget.
474 fn gate(alloc: std.mem.Allocator, path: []const u8, counts: []const Tally) !bool { 495 fn gate(alloc: std.mem.Allocator, path: []const u8, counts: []const Tally, unit: Unit) !bool {
475 const bf = std.fs.cwd().openFile(path, .{}) catch |e| { 496 const bf = std.fs.cwd().openFile(path, .{}) catch |e| {
476 err("docscheck: cannot open budget {s} ({s})\n", .{ path, @errorName(e) }); 497 err("docscheck: cannot open budget {s} ({s})\n", .{ path, @errorName(e) });
477 std.process.exit(2); 498 std.process.exit(2);
@@ -490,13 +511,21 @@ fn gate(alloc: std.mem.Allocator, path: []const u8, counts: []const Tally) !bool
490 .ok => {}, 511 .ok => {},
491 .over => { 512 .over => {
492 breach += 1; 513 breach += 1;
493 err("{s}: {d} bytes of flagged prose, budget {d} — shrink a comment or grow the code " ++ 514 switch (unit) {
494 "into it; raising the budget is a diff line that has to argue for itself\n", .{ c.name, c.n, budgetOf(budget, c.name) }); 515 .prose => err("{s}: {d} bytes of flagged prose, budget {d} — shrink a comment or grow the code " ++
516 "into it; raising the budget is a diff line that has to argue for itself\n", .{ c.name, c.n, budgetOf(budget, c.name) }),
517 .blocks => err("{s}: {d} comment blocks of {d}+ lines, baseline {d} — a comment is two lines " ++
518 "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) }),
519 }
495 }, 520 },
496 .under => { 521 .under => {
497 breach += 1; 522 breach += 1;
498 err("{s}: {d} bytes of flagged prose, budget {d} — lower the budget to {d}; " ++ 523 switch (unit) {
499 "slack left in the file is room to regrow for free\n", .{ c.name, c.n, budgetOf(budget, c.name), c.n }); 524 .prose => err("{s}: {d} bytes of flagged prose, budget {d} — lower the budget to {d}; " ++
525 "slack left in the file is room to regrow for free\n", .{ c.name, c.n, budgetOf(budget, c.name), c.n }),
526 .blocks => err("{s}: {d} comment blocks of {d}+ lines, baseline {d} — lower the baseline to {d}; " ++
527 "slack left in the file is room to regrow for free\n", .{ c.name, c.n, block_min, budgetOf(budget, c.name), c.n }),
528 }
500 }, 529 },
501 .unlisted => { 530 .unlisted => {
502 breach += 1; 531 breach += 1;
@@ -504,7 +533,10 @@ fn gate(alloc: std.mem.Allocator, path: []const u8, counts: []const Tally) !bool
504 }, 533 },
505 }; 534 };
506 if (breach == 0) return false; 535 if (breach == 0) return false;
507 err("docscheck: {d} file(s) off budget\n", .{breach}); 536 err("docscheck: {d} file(s) off {s}\n", .{ breach, switch (unit) {
537 .prose => "budget",
538 .blocks => "the comment-block baseline",
539 } });
508 return true; 540 return true;
509 } 541 }
510 542
@@ -523,6 +555,30 @@ fn verdict(budget: []const Tally, name: []const u8, flags: usize) Verdict {
523 return .unlisted; 555 return .unlisted;
524 } 556 }
525 557
558 /// Where a comment stops being a comment and becomes an essay.
559 const block_min = 5;
560
561 /// Tier 4 — comment blocks, counted not weighed. A `//!` header answers to
562 /// tier 3's cap and breaks a run like any other line, so a module contract
563 /// is never an essay. A block counts once however long it runs.
564 fn essayBlocks(lines: []const []const u8) usize {
565 var blocks: usize = 0;
566 var run: usize = 0;
567 for (lines) |line| {
568 const body = commentBody(line) orelse {
569 run = 0;
570 continue;
571 };
572 if (std.mem.startsWith(u8, body, "//!")) {
573 run = 0;
574 continue;
575 }
576 run += 1;
577 if (run == block_min) blocks += 1;
578 }
579 return blocks;
580 }
581
526 fn heavyBlocks( 582 fn heavyBlocks(
527 alloc: std.mem.Allocator, 583 alloc: std.mem.Allocator,
528 lines: []const []const u8, 584 lines: []const []const u8,
@@ -797,6 +853,48 @@ test "a budget line that does not parse fails the gate rather than vanishing" {
797 try std.testing.expectError(error.BadBudgetLine, parseBudget(alloc, "client.zig\n")); 853 try std.testing.expectError(error.BadBudgetLine, parseBudget(alloc, "client.zig\n"));
798 } 854 }
799 855
856 test "five consecutive comment lines are an essay, four are a comment" {
857 const four = [_][]const u8{
858 " // The dial's deadline is the shell's settle, minus a tick.",
859 " // Any longer and the redial races the daemon's own close.",
860 " // Measured on the LAN box.",
861 " // Not guessed.",
862 " deadline_ms = 250;",
863 };
864 try std.testing.expectEqual(@as(usize, 0), essayBlocks(&four));
865
866 const five = [_][]const u8{
867 " // one", " // two", " /// three", " // four", " // five",
868 " deadline_ms = 250;",
869 };
870 try std.testing.expectEqual(@as(usize, 1), essayBlocks(&five));
871
872 // Length past the fifth line is already an essay, so it cannot cost more:
873 // the gate counts how many there are, not how far each one runs.
874 const long = [_][]const u8{" // narration"} ** 12 ++ [_][]const u8{" deadline_ms = 250;"};
875 try std.testing.expectEqual(@as(usize, 1), essayBlocks(&long));
876 }
877
878 test "a run breaks on anything that is not a comment, a module header included" {
879 const four = [_][]const u8{ "// a", "// b", "// c", "// d" };
880 const blank = four ++ [_][]const u8{""} ++ four;
881 try std.testing.expectEqual(@as(usize, 0), essayBlocks(&blank));
882
883 const code = four ++ [_][]const u8{"const x = 1;"} ++ four;
884 try std.testing.expectEqual(@as(usize, 0), essayBlocks(&code));
885
886 // A module contract is weighed by tier 3's cap and must not be an essay
887 // as well, or every file in the tree would open with one.
888 const header = [_][]const u8{ "//! a", "//! b", "//! c", "//! d", "//! e", "//! f" };
889 try std.testing.expectEqual(@as(usize, 0), essayBlocks(&header));
890 try std.testing.expectEqual(@as(usize, 0), essayBlocks(&(four ++ [_][]const u8{"//! header"} ++ four)));
891
892 // Two essays in one file are two, and the blank between them is what
893 // separates them rather than joining them into one.
894 const five = [_][]const u8{ "// a", "// b", "// c", "// d", "// e" };
895 try std.testing.expectEqual(@as(usize, 2), essayBlocks(&(five ++ [_][]const u8{""} ++ five)));
896 }
897
800 test "an already-flagged block that grows still moves the number" { 898 test "an already-flagged block that grows still moves the number" {
801 const alloc = std.testing.allocator; 899 const alloc = std.testing.allocator;
802 var t3: std.ArrayList([]const u8) = .empty; 900 var t3: std.ArrayList([]const u8) = .empty;