66f43541
feat: five comment lines are an essay, and the count only falls
a73x 2026-08-30 13:50
Commit message
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; |