diff --git a/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md index f8da02905f..71b720eacc 100644 --- a/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md +++ b/docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -The tree's layout is uniform — [the classification scheme](2026-06-20-rfc-classification.md) path-encodes lifecycle and class and gates both — but the file insides never were. The corpus the format decision faced had two H1 spellings; some fifteen `Status:` line spellings, from bare enums through dated parentheticals duplicating what the filename and git already carry, to four files with no status at all; two body genres side by side (ADR-style `Context`/`Decision`/`Consequences` beside proposal-style `Problem`/`Proposal`/`Risks`), so every new RFC guessed its shape from whichever neighbor its author opened; thirty-nine files carrying a debt comment that flagged them as "legacy ADR/RFC body format" awaiting a unified template that was never actually defined; and over twenty implemented RFCs still carrying the proposal-era headings (`Acceptance criteria`, `Plan`, `Migration plan`) that the [documentation standard's slop checklist](../../../AGENTS.md) outlaws for `implemented/` — outlawed, but enforced by nothing, so the `proposed/` → `implemented/` move could silently skip the rewrite [implemented/AGENTS.md](../AGENTS.md) requires. +The tree's layout is uniform — [the classification scheme](2026-06-20-rfc-classification.md) path-encodes lifecycle and class and gates both — but the file insides never were. The corpus the format decision faced had two H1 spellings; some twenty-seven `Status:` line spellings once free-text rejection reasons are collapsed — bare enums, dated parentheticals duplicating what the filename and git already carry — plus three English files (and the zh counterpart of one of them) with no status at all; two body genres side by side (ADR-style `Context`/`Decision`/`Consequences` beside proposal-style `Problem`/`Proposal`/`Risks`), so every new RFC guessed its shape from whichever neighbor its author opened; thirty-nine files carrying a debt comment that flagged them as "legacy ADR/RFC body format" awaiting a unified template that was never actually defined; and nineteen implemented RFCs still carrying thirty occurrences of the proposal-era headings (`Acceptance criteria`, `Plan`, `Migration plan`, `Proposal`) that the [documentation standard's slop checklist](../../../AGENTS.md) outlaws for `implemented/` — outlawed, but enforced by nothing, so the `proposed/` → `implemented/` move could silently skip the rewrite [implemented/AGENTS.md](../AGENTS.md) requires. ## Decision @@ -20,7 +20,7 @@ The whole corpus was normalized in the same change that defined the format — t - **Dated status** (`Status: implemented (accepted YYYY-MM-DD)`) — rejected: the acceptance date is narrated history the writing rules keep out of docs; the filename carries first-proposed, git carries the rest, and the gate could check a date's format but never its truth. - **A bare `# ` H1** — rejected: the `RFC: ` prefix is the corpus-majority form and self-describes the genre when a file is read outside its tree; the index generator strips it, so index rows are identical either way. - **`## What we give up` as the implemented closer** (the README's own phrase for what an RFC records) — rejected: it names only costs, and an honest consequences section records what the trade-off bought as well. -- **Convention without a gate** (write the contract down, enforce by review) — rejected: the slop checklist already outlawed spec-speak in `implemented/` by convention, and over twenty files show what convention alone achieves here. +- **Convention without a gate** (write the contract down, enforce by review) — rejected: the slop checklist already outlawed spec-speak in `implemented/` by convention, and nineteen files show what convention alone achieves here. - **A standalone `FORMAT.md` contract file** — the first landed home; folded into README.md once the generated index moved out to [INDEX.md](../../INDEX.md): with the tables gone the README regained the room, and one front door carrying layout, classification, and format beats splitting the contract across two files. ## Consequences diff --git a/scripts/verify-rfc-format.ts b/scripts/verify-rfc-format.ts index 1b22f349b3..99347e4764 100644 --- a/scripts/verify-rfc-format.ts +++ b/scripts/verify-rfc-format.ts @@ -65,6 +65,17 @@ for (const rfc of rfcs) { errors.push(`format: ${rfc.rel} — ${msg}`) } const lines = readFileSync(resolve(rfcRoot, rfc.rel), 'utf8').split('\n') + // Content scans ignore fenced code blocks: an RFC may legitimately QUOTE a + // status line, a banned heading, or the grandfather comment inside a fence + // (the README's own format section does), and only real prose counts. + let inFence = false + const prose = lines.filter((l) => { + if (l.startsWith('```')) { + inFence = !inFence + return false + } + return !inFence + }) if (!/^# RFC: \S/.test(lines[0] ?? '')) fail('line 1 must be `# RFC: <title>`') if (lines[1] !== '') fail('line 2 must be blank') @@ -73,10 +84,12 @@ for (const rfc of rfcs) { fail(`line 3 must match the ${rfc.lifecycle} status grammar (${String(status)})`) } if (lines[3] !== '') fail('line 4 must be blank') - const statusLines = lines.filter((l, i) => i !== 2 && l.startsWith('Status:')) - if (statusLines.length > 0) fail('the line-3 `Status:` line must be the only one in the file') + const statusLines = prose.filter(l => l.startsWith('Status:') && l !== lines[2]) + if (statusLines.length > 0 || prose.filter(l => l === lines[2]).length > 1) { + fail('the line-3 `Status:` line must be the only one in the file') + } - const h2s = lines.filter(l => l.startsWith('## ')).map(l => l.trimEnd()) + const h2s = prose.filter(l => l.startsWith('## ')).map(l => l.trimEnd()) if (h2s[0] !== '## Problem') fail(`the first section must be \`## Problem\` (got ${JSON.stringify(h2s[0] ?? '<none>')})`) for (const required of REQUIRED[rfc.lifecycle] ?? []) { if (!h2s.includes(required)) fail(`missing the required \`${required}\` section`) @@ -88,12 +101,12 @@ for (const rfc of rfcs) { } const hasSection = h2s.includes('## Alternatives considered') - const hasGrandfather = lines.includes(GRANDFATHER) + const hasGrandfather = prose.includes(GRANDFATHER) if (hasSection && hasGrandfather) fail('carries both `## Alternatives considered` and the grandfather comment — drop the comment') if (!hasSection && !hasGrandfather) fail('missing `## Alternatives considered` (a pre-format RFC whose alternatives are not reconstructible carries the grandfather comment instead — see docs/rfc/README.md § The file format)') if (hasGrandfather && rfc.date >= FORMAT_ADOPTED) fail(`the grandfather comment is only valid for RFCs dated before ${FORMAT_ADOPTED}`) - if (lines.some(l => l.includes(LEGACY_MARKER))) fail('carries the retired legacy-format debt marker') + if (prose.some(l => l.includes(LEGACY_MARKER))) fail('carries the retired legacy-format debt marker') } if (errors.length === 0) {