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

Automate a review finding as a lint or test: README.md is capped and is not where a feature's how-to goes; remove the added s #12487

Merged opened this

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

Merged

scout/forge:scout/12392-automate-a-review-finding-as-a-lint-or-t → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/12392-automate-a-review-finding-as-a-lint-or-t was merged into main as b2b5775.

Review

Sign in to review this change.
3 files changed +116 −1
scripts/gate.ts +15 −1
@@ -541,10 +541,24 @@ export async function dependencyRefusal(cwd: string = process.cwd()): Promise<st
541541 return dependencyProblems(before.ok ? before.out : undefined, read("package.json"), read("README.md"), read(DEPENDENCIES_DOC));
542542 }
543543
544 /** A pull that grows README.md puts the feature's how-to in docs/features instead (#12392). */
545 export async function readmeCapRefusal(cwd: string = process.cwd()): Promise<string[]> {
546 const { readmeCapProblems } = await import("../src/kernel/readme_cap.ts");
547 const base =
548 process.env.FORGE_CHECK_BASE ||
549 git(["merge-base", "origin/main", "HEAD"], cwd).out ||
550 git(["merge-base", "main", "HEAD"], cwd).out;
551 if (!base) return [];
552 const before = git(["show", `${base}:README.md`], cwd);
553 if (!before.ok) return [];
554 const path = join(cwd, "README.md");
555 return readmeCapProblems(before.out, existsSync(path) ? readFileSync(path, "utf8") : undefined);
556 }
557
544558 // `bun scripts/gate.ts`: the audit alone, first in `forge.json`'s check, so
545559 // a stale exclusion fails in under a second — not after install and tsc.
546560 if (import.meta.main) {
547 const refusal = [...gateRefusal(presentTests()), ...slowRefusal(presentTests()), ...(await pinRefusal()), ...(await tryItRefusal()), ...(await bothDoorsRefusal()), ...(await lookRefusal()), ...(await directionRefusal()), ...strayTestRefusal(), ...reviewRefusal(), ...(await addedRefusal()), ...(await dependencyRefusal()), ...billingDeletesRefusal()];
561 const refusal = [...gateRefusal(presentTests()), ...slowRefusal(presentTests()), ...(await pinRefusal()), ...(await tryItRefusal()), ...(await bothDoorsRefusal()), ...(await lookRefusal()), ...(await directionRefusal()), ...strayTestRefusal(), ...reviewRefusal(), ...(await addedRefusal()), ...(await dependencyRefusal()), ...(await readmeCapRefusal()), ...billingDeletesRefusal()];
548562 for (const line of refusal) console.error(`gate: ${line}`);
549563 process.exit(refusal.length === 0 ? 0 : 2);
550564 }
src/kernel/readme_cap.ts added +46
@@ -0,0 +1,46 @@
1 /**
2 * "README.md is capped and is not where a feature's how-to goes" (#12392,
3 * AGENTS.md): a pull may not add a heading to README.md nor grow it outside
4 * its `## Dependencies` section (the one place a new package's line may go).
5 * `bun scripts/gate.ts` (`readmeCapRefusal`) judges the pull against its base.
6 * Plain text in, plain lines out.
7 */
8
9 export const README_RULE = "README.md is capped and is not where a feature's how-to goes";
10
11 const HEADING = /^#{1,6}\s+(.*\S)\s*$/;
12

HEADING matches # ... lines inside ``` fences, so a shell comment is counted as a section. Track fence state in withoutDependencies and ignore headings inside it.

13 /** README's lines with the `## Dependencies` section left out. */
14 function withoutDependencies(text: string): string[] {
15 const out: string[] = [];
16 let skipping = false;
17 for (const line of text.split("\n")) {
18 const h = HEADING.exec(line);
19 if (h) skipping = h[1] === "Dependencies" && line.startsWith("## ");
20 if (!skipping) out.push(line);
21 }
22 while (out.length > 0 && out[out.length - 1] === "") out.pop();
23 return out;
24 }
25
26 /** One line per rule broken by the head README against the base's; none when it did not grow. */
27 export function readmeCapProblems(base: string | undefined, head: string | undefined): string[] {
28 if (base === undefined || head === undefined) return [];
29 const before = withoutDependencies(base);
30 const after = withoutDependencies(head);
31 const known = new Set(before.filter((l) => HEADING.test(l)));
32 const problems: string[] = [];
33 for (const line of after) {
34 if (HEADING.test(line) && !known.has(line)) {
35 problems.push(
36 `${README_RULE}: this pull adds the section "${line}" to README.md; move it to docs/features/<name>.md and link it from docs/README.md`,
37 );
38 }
39 }
40 if (problems.length === 0 && after.length > before.length) {
41 problems.push(
42 `${README_RULE}: this pull grows README.md by ${after.length - before.length} lines outside Dependencies; put the how-to in docs/features/<name>.md and link it from docs/README.md`,
43 );
44 }
45 return problems;
46 }
test/gate-readme-cap.test.ts added +55
@@ -0,0 +1,55 @@
1 /** The gate refuses a pull that adds a section to, or grows, README.md (#12392). */
2 import { afterEach, expect, test } from "bun:test";
3 import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { readmeCapRefusal } from "../scripts/gate.ts";
7 import { README_RULE, readmeCapProblems } from "../src/kernel/readme_cap.ts";
8
9 const BASE = "# R\n\nintro\n\n## Try it\n\nrun\n\n## Dependencies\n\n- `a` — why\n";
10
11 test("an added section is refused, naming the rule", () => {
12 const out = readmeCapProblems(BASE, BASE + "\n## Agent card\n\nhow to\n");
13 expect(out).toHaveLength(1);
14 expect(out[0]).toContain(README_RULE);
15 expect(out[0]).toContain("## Agent card");
16 expect(out[0]).toContain("docs/features/<name>.md");
17 });
18
19 test("growth without a heading is refused", () => {
20 const head = BASE.replace("run\n", "run\nmore\nlines\n");
21 expect(readmeCapProblems(BASE, head)[0]).toContain("grows README.md by 2 lines");
22 });
23
24 test("a new Dependencies bullet, an edit in place and a shrink pass", () => {
25 expect(readmeCapProblems(BASE, BASE + "- `b` — because\n")).toEqual([]);
26 expect(readmeCapProblems(BASE, BASE.replace("run", "go"))).toEqual([]);
27 expect(readmeCapProblems(BASE, BASE.replace("\nrun\n", ""))).toEqual([]);
28 });
29
30 const dirs: string[] = [];
31 const saved = process.env.FORGE_CHECK_BASE;
32 afterEach(() => {
33 for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
34 if (saved === undefined) delete process.env.FORGE_CHECK_BASE;
35 else process.env.FORGE_CHECK_BASE = saved;
36 });
37
38 test("the gate judges README.md against the pull's base in a real repository", async () => {
39 const dir = mkdtempSync(join(tmpdir(), "forge-readme-"));
40 dirs.push(dir);
41 const sh = (...a: string[]) => {
42 const r = Bun.spawnSync(["git", "-c", "user.email=t@t", "-c", "user.name=t", "-c", "commit.gpgsign=false", ...a], { cwd: dir });
43 if (r.exitCode !== 0) throw new Error(r.stderr.toString());
44 return r.stdout.toString().trim();
45 };
46 sh("init", "-q", "-b", "trunk");
47 writeFileSync(join(dir, "README.md"), BASE);
48 sh("add", "-A");
49 sh("commit", "-qm", "base");
50 process.env.FORGE_CHECK_BASE = sh("rev-parse", "HEAD");
51 expect(await readmeCapRefusal(dir)).toEqual([]);
52 writeFileSync(join(dir, "README.md"), BASE + "\n## Feature\n\nhow\n");
53 sh("commit", "-qam", "head");
54 expect((await readmeCapRefusal(dir))[0]).toContain(README_RULE);
55 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs