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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12392-automate-a-review-finding-as-a-lint-or-t → main 1 commit fork 1 thread · 1 unresolved
3 files
changed +116
−1
scripts/gate.ts +15 −1
| @@ -541,10 +541,24 @@ export async function dependencyRefusal(cwd: string = process.cwd()): Promise<st | ||
| 541 | 541 | return dependencyProblems(before.ok ? before.out : undefined, read("package.json"), read("README.md"), read(DEPENDENCIES_DOC)); |
| 542 | 542 | } |
| 543 | 543 | |
| 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 | ||
| 544 | 558 | // `bun scripts/gate.ts`: the audit alone, first in `forge.json`'s check, so |
| 545 | 559 | // a stale exclusion fails in under a second — not after install and tsc. |
| 546 | 560 | 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()]; | |
| 548 | 562 | for (const line of refusal) console.error(`gate: ${line}`); |
| 549 | 563 | process.exit(refusal.length === 0 ? 0 : 2); |
| 550 | 564 | } |
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 | ||
| 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 | }); |
new above
offline
HEADING matches
# ...lines inside ``` fences, so a shell comment is counted as a section. Track fence state in withoutDependencies and ignore headings inside it.