Public: anyone can read this; the crew can change it.

Automate a review finding as a lint or test: Both doors missing: the page has no terminal screen in `src/tui/` with its entry #10694

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/10628-automate-a-review-finding-as-a-lint-or-t → main 5 commits fork 3 threads · 3 unresolved

  • Merged. mason/10628-automate-a-review-finding-as-a-lint-or-t was merged into main as d04e65a.

Review

Sign in to review this change.
5 files changed +143 −1
AGENTS.md +1
@@ -3,6 +3,7 @@
33 Read `CLAUDE.md` first: it is the rulebook for agents editing this repo.
44
55 README.md is capped: a new feature's how-to goes in `docs/features/<name>.md`, not a new README section.
6 A pull that adds a page (`src/web/pages/*.ts`) also adds a terminal screen under `src/tui/` and a `docs/features/<name>.md`, or a commit line `No door: <why>`; `bun scripts/gate.ts` refuses it otherwise ("both doors", #10628).
67
78 When you write an issue, end it with a `Files:` line naming the files the work touches (`Files: src/a.ts, docs/b.md`). The hot-file hold reads only that line when it is there; without it, every path the issue mentions counts.
89
scripts/gate.ts +16 −1
@@ -285,10 +285,25 @@ export async function tryItRefusal(cwd: string = process.cwd()): Promise<string[
285285 return tryItProblems(parseNameStatus(diff.out), log.out.split("\0").map((m) => m.trim()).filter(Boolean));
286286 }
287287
288 /** A pull that adds a page adds its terminal screen and how-to too (#10628). */
289 export async function bothDoorsRefusal(cwd: string = process.cwd()): Promise<string[]> {
290 const { parseNameStatus } = await import("../src/kernel/pin_edits.ts");
291 const { bothDoorsProblems } = await import("../src/kernel/both_doors.ts");
292 const base =
293 process.env.FORGE_CHECK_BASE ||
294 git(["merge-base", "origin/main", "HEAD"], cwd).out ||
295 git(["merge-base", "main", "HEAD"], cwd).out;
296 if (!base) return [];
297 const diff = git(["diff", "--name-status", "-M", `${base}..HEAD`], cwd);
298 const log = git(["log", "--format=%B%x00", `${base}..HEAD`], cwd);
299 if (!diff.ok || !log.ok) return [];
300 return bothDoorsProblems(parseNameStatus(diff.out), log.out.split("\0").map((m) => m.trim()).filter(Boolean));
301 }
302
288303 // `bun scripts/gate.ts`: the audit alone, first in `forge.json`'s check, so
289304 // a stale exclusion fails in under a second — not after install and tsc.
290305 if (import.meta.main) {
291 const refusal = [...gateRefusal(presentTests()), ...(await pinRefusal()), ...(await tryItRefusal()), ...reviewRefusal()];
306 const refusal = [...gateRefusal(presentTests()), ...(await pinRefusal()), ...(await tryItRefusal()), ...(await bothDoorsRefusal()), ...reviewRefusal()];
292307 for (const line of refusal) console.error(`gate: ${line}`);
293308 process.exit(refusal.length === 0 ? 0 : 2);
294309 }
src/kernel/both_doors.ts added +33
@@ -0,0 +1,33 @@
1 /**
2 * Four reviews in a week said "both doors missing" (#10628): a new page
3 * without a terminal screen in `src/tui/` (entry where AGENTS.md says) and
4 * without a `docs/features/<name>.md` how-to. A pull that adds a page
5 * (`src/web/pages/*.ts`) therefore adds a terminal screen (a new file under
6 * `src/tui/`) and a features doc, or says in a commit message
7 * `No door: <why this page needs no terminal screen or how-to>`.
8 */
9
10 import type { Change } from "./pin_edits.ts";
11
12 const PAGE = /^src\/web\/pages\/[^/]+\.ts$/;
13 const SCREEN = /^src\/tui\/.+\.ts$/;
14 const DOC = /^docs\/features\/[^/]+\.md$/;
15 const WAIVER = /^No door:\s*\S[^\n]{9,}/im;
16
17 const added = (changes: readonly Change[], re: RegExp) =>
18 changes.filter((c) => c.status.startsWith("A") && re.test(c.path)).map((c) => c.path);
19
20 /** One line per problem: none when no page is added, both doors come with it, or a commit waives them. */
21 export function bothDoorsProblems(changes: readonly Change[], messages: readonly string[]): string[] {
22 const pages = added(changes, PAGE);
23 if (pages.length === 0) return [];
24 if (messages.some((m) => WAIVER.test(m))) return [];
25 const missing: string[] = [];
26 if (added(changes, SCREEN).length === 0) missing.push("terminal screen in src/tui/ (entry where AGENTS.md says)");
27 if (added(changes, DOC).length === 0) missing.push("docs/features/<name>.md how-to");
28 if (missing.length === 0) return [];
29 const shown = pages.slice(0, 3).join(", ") + (pages.length > 3 ? ` and ${pages.length - 3} more` : "");
30 return [
31 `rule "both doors": this pull adds ${shown} but no ${missing.join(" and no ")}; add them, or put a "No door: <why>" line (10+ characters) in a commit message`,

Ungrammatical output: "this pull adds X but no a terminal screen ... and no a docs/features/...". Build the missing list without the leading article, e.g. missing = ["terminal screen in src/tui/ (entry where AGENTS.md says)", "docs/features/<name>.md how-to"] and keep but no ${missing.join(" and no ")}.

32 ];
33 }
test/both-doors-refusal.test.ts added +62
@@ -0,0 +1,62 @@
1 import { afterAll, expect, test } from "bun:test";
2 import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3 import { join } from "node:path";
4 import { tmpdir } from "node:os";
5 import { bothDoorsRefusal } from "../scripts/gate.ts";
6
7 const root = mkdtempSync(join(tmpdir(), "both-doors-"));
8 afterAll(() => rmSync(root, { recursive: true, force: true }));
9
10 const run = (cwd: string, ...args: string[]) => {
11 const r = Bun.spawnSync(["git", "-c", "user.name=t", "-c", "user.email=t@t", ...args], { cwd });
12 if (r.exitCode !== 0) throw new Error(r.stderr.toString());
13 return r.stdout.toString().trim();
14 };
15 const put = (dir: string, file: string, text: string) => {
16 mkdirSync(join(dir, file, ".."), { recursive: true });
17 writeFileSync(join(dir, file), text);
18 };
19 const withBase = async (base: string | undefined, f: () => Promise<string[]>) => {
20 const old = process.env.FORGE_CHECK_BASE;
21 if (base === undefined) delete process.env.FORGE_CHECK_BASE;
22 else process.env.FORGE_CHECK_BASE = base;
23 try {
24 return await f();
25 } finally {
26 if (old === undefined) delete process.env.FORGE_CHECK_BASE;
27 else process.env.FORGE_CHECK_BASE = old;
28 }
29 };
30 function repo(name: string) {
31 const dir = join(root, name);
32 mkdirSync(dir);
33 run(dir, "init", "-q", "-b", "work");
34 put(dir, "README.md", "x");
35 run(dir, "add", "-A");
36 run(dir, "commit", "-qm", "base");
37 return { dir, base: run(dir, "rev-parse", "HEAD") };
38 }
39
40 test("a commit range that adds a page alone is refused; a No door line in its message waives it", async () => {
41 const { dir, base } = repo("page");
42 put(dir, "src/web/pages/traffic.ts", "x");
43 run(dir, "add", "-A");
44 run(dir, "commit", "-qm", "Add traffic");
45 const out = await withBase(base, () => bothDoorsRefusal(dir));
46 expect(out).toHaveLength(1);
47 expect(out[0]).toContain("but no terminal screen in src/tui/");
48 expect(out[0]).toContain("and no docs/features/<name>.md how-to");
49
50 run(dir, "commit", "-q", "--allow-empty", "-m", "Waive\n\nNo door: a public landing page, nothing to administer");
51 expect(await withBase(base, () => bothDoorsRefusal(dir))).toEqual([]);
52 });
53
54 test("a range that adds the page with both doors passes", async () => {
55 const { dir, base } = repo("doors");
56 put(dir, "src/web/pages/traffic.ts", "x");
57 put(dir, "src/tui/screens/traffic.ts", "x");
58 put(dir, "docs/features/traffic.md", "x");
59 run(dir, "add", "-A");
60 run(dir, "commit", "-qm", "Add traffic");
61 expect(await withBase(base, () => bothDoorsRefusal(dir))).toEqual([]);
62 });
test/both-doors.test.ts added +31
@@ -0,0 +1,31 @@
1 import { expect, test } from "bun:test";
2 import { bothDoorsProblems } from "../src/kernel/both_doors.ts";
3
4 const ch = (status: string, path: string) => ({ status, path });
5 const page = ch("A", "src/web/pages/traffic.ts");
6
7 const TUI = "terminal screen in src/tui/ (entry where AGENTS.md says)";
8 const DOCS = "docs/features/<name>.md how-to";
9 const tail = '; add them, or put a "No door: <why>" line (10+ characters) in a commit message';
10
11 test("a new page with neither door is refused, naming the rule and both missing doors", () => {
12 expect(bothDoorsProblems([page], ["Add traffic"])).toEqual([
13 `rule "both doors": this pull adds src/web/pages/traffic.ts but no ${TUI} and no ${DOCS}${tail}`,
14 ]);
15 });
16
17 test("one door is not enough: the other is named alone", () => {
outdated

Vacuous: in this case only the doc is missing, so not.toContain("no a terminal screen") passes regardless. Assert the exact wording that should appear, e.g. toContain("but no docs/features/<name>.md how-to").

18 expect(bothDoorsProblems([page, ch("A", "src/tui/screens/traffic.ts")], [])).toEqual([
19 `rule "both doors": this pull adds src/web/pages/traffic.ts but no ${DOCS}${tail}`,
20 ]);
outdated

Same: not.toContain("and no a docs") cannot fail here, since only one item is missing, and the real message does contain "no a terminal screen". Assert the full expected sentence in the neither-door test and in each single-door test so wording regressions fail.

21 expect(bothDoorsProblems([page, ch("A", "docs/features/traffic.md")], [])).toEqual([
22 `rule "both doors": this pull adds src/web/pages/traffic.ts but no ${TUI}${tail}`,
23 ]);
24 });
25
26 test("both doors pass; so does a No door line; edits and other files are never asked", () => {
27 expect(bothDoorsProblems([page, ch("A", "src/tui/screens/t.ts"), ch("A", "docs/features/t.md")], [])).toEqual([]);
28 expect(bothDoorsProblems([page], ["x\n\nNo door: a public landing page, nothing to administer"])).toEqual([]);
29 expect(bothDoorsProblems([page], ["No door: no"])).toHaveLength(1);
30 expect(bothDoorsProblems([ch("M", "src/web/pages/traffic.ts"), ch("A", "src/core/x.ts")], [])).toEqual([]);
31 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs