feat(scripts): generate the RFC index tables from the tree

docs/rfc/README.md's per-lifecycle tables are now generated between
gen-rfc-index marker comments from each RFC's path (lifecycle/class),
H1 title (optional 'RFC: ' prefix stripped), and filename date, sorted
by date then filename — the one docs region every proposal wave edits
and every concurrent branch conflicts on becomes derived state.

scripts/rfc-index.ts owns the shared walker (closed lifecycle/class
sets, structure rules, parseable-H1 requirement) and the renderer;
gen-rfc-index.ts is the writer CLI; verify-rfc-classification.ts keeps
the structure check and asserts the committed regions byte-match a
fresh render (freshness subsumes the index-completeness check, since a
generated-from-disk table is definitionally complete and correctly
headed). A malformed H1 is a hard error in both directions, so the H1
is now load-bearing as the title source — the one nonconforming H1
(a status suffix duplicating the path) is normalized.

Implements docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md
(moved from proposed/ and amended to the shipped mechanics); the
classification RFC's verify-only stance carries the supersession
cross-link per implemented/AGENTS.md.
This commit is contained in:
Tianyi Cui
2026-07-04 14:47:51 +08:00
parent 049e04f4a2
commit 5611f772b1
10 changed files with 269 additions and 198 deletions

30
scripts/gen-rfc-index.ts Normal file
View File

@@ -0,0 +1,30 @@
/**
* Regenerate the RFC index tables in `docs/rfc/README.md` from the RFC tree
* (see [rfc-index.ts](./rfc-index.ts) for the layout contract and rendering
* rules). Rewrites ONLY the marker-delimited regions; the curated prose is
* untouched. Freshness is asserted by `verify-rfc-classification.ts` (a
* `doc-sync` member), so a stale committed index fails CI.
*
* Run: `pnpm run gen-rfc-index`.
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { rfcRoot, spliceReadme, walkRfcTree } from './rfc-index.ts'
const { rfcs, errors } = walkRfcTree()
if (errors.length > 0) {
console.error('gen-rfc-index: refusing to generate from a structurally invalid tree:')
for (const e of errors) console.error(` ${e}`)
process.exit(1)
}
const readmePath = resolve(rfcRoot, 'README.md')
const readme = readFileSync(readmePath, 'utf8')
const next = spliceReadme(readme, rfcs)
if (next === readme) {
console.log(`gen-rfc-index: docs/rfc/README.md is up to date (${rfcs.length} RFCs).`)
} else {
writeFileSync(readmePath, next)
console.log(`gen-rfc-index: docs/rfc/README.md regenerated (${rfcs.length} RFCs).`)
}

141
scripts/rfc-index.ts Normal file
View File

@@ -0,0 +1,141 @@
/**
* Shared source of truth for the RFC index: the tree walker (structure rules)
* and the README table renderer. `gen-rfc-index.ts` writes the generated
* regions; `verify-rfc-classification.ts` checks structure and asserts the
* committed regions are fresh. Pure module — no side effects on import.
*
* The layout contract ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md)):
* every RFC lives at `docs/rfc/{lifecycle}/{class}/yyyy-mm-dd-topic.md`, the
* folder IS the label, and both sets are CLOSED — extending either means
* amending this module AND the README's Classification prose.
*
* The README's per-lifecycle tables are GENERATED between marker comments
* (`<!-- gen-rfc-index:begin {lifecycle} -->` … `end`): section headings and
* rows are derived from each RFC's path (lifecycle/class), H1 (title, with an
* optional `RFC: ` prefix stripped), and filename date, sorted by date then
* filename. Prose outside the markers is curated by hand and never touched.
*/
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { globSync } from 'node:fs'
export const rfcRoot = resolve(import.meta.dirname, '../docs/rfc')
/** The closed set of RFC lifecycles (top-level folders under docs/rfc/). */
export const LIFECYCLES = ['proposed', 'implemented', 'rejected'] as const
/**
* The closed set of RFC classes (nested folder under each lifecycle). Adding a
* class is a deliberate act: extend this list AND the README's Classification
* section. The gate rejects any folder not listed here.
*/
export const CLASSES = ['feature', 'bug-fix', 'simplification', 'architecture', 'process', 'testing'] as const
/** Non-RFC Markdown allowed to sit directly at a lifecycle root. */
const ROOT_ALLOWLIST = new Set(['AGENTS.md', 'CLAUDE.md'])
/** Title-case a class/lifecycle folder name for a README heading. */
export const heading = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1)
/** One RFC file, as discovered by the walker. */
export interface Rfc {
lifecycle: string
cls: string
base: string
/** Path relative to docs/rfc — the README link target. */
rel: string
/** H1 text with any `RFC: ` prefix stripped — the README row title. */
title: string
/** `yyyy-mm-dd` from the filename — the "First proposed" column. */
date: string
}
/**
* Walk the RFC tree, enforcing the structure rules. Returns every valid RFC
* plus one error string per violation (unknown class folder, bad depth, bad
* filename, missing/malformed H1). Callers treat a non-empty error list as
* fatal — the index is only generated from a structurally valid tree.
*/
export function walkRfcTree(): { rfcs: Rfc[]; errors: string[] } {
const rfcs: Rfc[] = []
const errors: string[] = []
for (const lifecycle of LIFECYCLES) {
for (const match of globSync(`${lifecycle}/**/*.md`, { cwd: rfcRoot }).sort()) {
const segs = match.split('/')
// Allowlisted file directly at the lifecycle root (e.g. implemented/AGENTS.md).
if (segs.length === 2 && ROOT_ALLOWLIST.has(segs[1] ?? '')) continue
// A Chinese counterpart (foo.zh.md, docs/i18n/README.md) is the SAME RFC,
// indexed via its English filename; the pairing gate owns its consistency.
if (match.endsWith('.zh.md')) continue
const cls = segs[1]
const base = segs[2]
if (segs.length !== 3 || cls === undefined || base === undefined) {
errors.push(`structure: ${match} — expected {lifecycle}/{class}/file.md (got depth ${segs.length})`)
continue
}
if (!(CLASSES as readonly string[]).includes(cls)) {
errors.push(`structure: ${match} — unknown class folder "${cls}" (allowed: ${CLASSES.join(', ')})`)
continue
}
if (!/^\d{4}-\d{2}-\d{2}-.+\.md$/.test(base)) {
errors.push(`structure: ${match} — filename must be yyyy-mm-dd-topic.md`)
continue
}
const firstLine = readFileSync(resolve(rfcRoot, match), 'utf8').split('\n', 1)[0] ?? ''
const h1 = /^#\s+(?:RFC:\s+)?(.+?)\s*$/.exec(firstLine)
if (!h1?.[1]) {
errors.push(`title: ${match} — first line must be an H1 (\`# RFC: <title>\` or \`# <title>\`), got: ${JSON.stringify(firstLine)}`)
continue
}
rfcs.push({ lifecycle, cls, base, rel: match, title: h1[1], date: base.slice(0, 10) })
}
}
return { rfcs, errors }
}
/** The begin/end marker lines that delimit one lifecycle's generated region. */
export const markers = (lifecycle: string): { begin: string; end: string } => ({
begin: `<!-- gen-rfc-index:begin ${lifecycle} -->`,
end: `<!-- gen-rfc-index:end ${lifecycle} -->`,
})
/**
* Render one lifecycle's generated region body: a `### {Class}` heading plus a
* `| Title | First proposed |` table for every non-empty class, in CLASSES
* order, rows sorted by date then filename.
*/
export function renderLifecycle(rfcs: Rfc[], lifecycle: string): string {
const sections: string[] = []
for (const cls of CLASSES) {
const rows = rfcs
.filter(r => r.lifecycle === lifecycle && r.cls === cls)
.sort((a, b) => a.date.localeCompare(b.date) || a.base.localeCompare(b.base))
if (rows.length === 0) continue
const table = rows.map(r => `| [${r.title}](${r.rel}) | ${r.date} |`).join('\n')
sections.push(`### ${heading(cls)}\n\n| Title | First proposed |\n|---|---|\n${table}`)
}
return sections.join('\n\n')
}
/**
* Splice freshly rendered regions into the README text. Throws when a marker
* pair is missing, duplicated, or out of order — the markers are part of the
* curated prose and must exist exactly once per lifecycle.
*/
export function spliceReadme(readme: string, rfcs: Rfc[]): string {
let out = readme
for (const lifecycle of LIFECYCLES) {
const { begin, end } = markers(lifecycle)
const beginAt = out.indexOf(begin)
const endAt = out.indexOf(end)
if (beginAt === -1 || endAt === -1 || endAt < beginAt) {
throw new Error(`README.md is missing the ${JSON.stringify(begin)}${JSON.stringify(end)} marker pair`)
}
if (out.indexOf(begin, beginAt + 1) !== -1 || out.indexOf(end, endAt + 1) !== -1) {
throw new Error(`README.md has a duplicated ${lifecycle} index marker`)
}
out = `${out.slice(0, beginAt + begin.length)}\n${renderLifecycle(rfcs, lifecycle)}\n${out.slice(endAt)}`
}
return out
}

View File

@@ -1,158 +1,50 @@
/**
* Doc-sync gate: enforce the RFC classification scheme
* ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md)).
* ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md))
* and the freshness of the generated index tables
* ([the index-generation RFC](../docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md)).
* Every RFC is filed at `docs/rfc/{lifecycle}/{class}/yyyy-mm-dd-topic.md`; the
* folder IS the label. This gate is the machine source of truth for the closed
* class set and keeps the README index honest.
*
* Two checks:
* Two checks (both against [rfc-index.ts](./rfc-index.ts), the shared walker
* and renderer):
*
* 1. STRUCTURE — every `.md` under a lifecycle folder lives in a class folder
* from CLASSES, named `yyyy-mm-dd-*.md`. A loose `.md` directly under a
* lifecycle root (other than the README/AGENTS allowlist) fails; an unknown
* class folder fails; a stray file at an unexpected depth fails. This is what
* makes the set CLOSED: a new class folder can't appear without amending
* CLASSES here (and the README's Classification section, per the RFC).
* from CLASSES, is named `yyyy-mm-dd-*.md`, and opens with a parseable H1.
* A loose `.md` directly under a lifecycle root (other than the
* README/AGENTS allowlist) fails; an unknown class folder fails; a stray
* file at an unexpected depth fails. This is what makes the set CLOSED: a
* new class folder can't appear without amending CLASSES (and the README's
* Classification section, per the RFC).
*
* 2. COMPLETENESS — `docs/rfc/README.md` lists every RFC exactly once, under the
* `### {Class}` heading inside the `## {Lifecycle}` section that matches the
* file's path. A missing entry, a duplicate, or an entry under the wrong
* heading fails. This mirrors `verify-event-taxonomy`: a curated doc table
* checked against the on-disk source of truth, so the index can't drift.
*
* The class DESCRIPTIONS in the README prose are not checked (they are
* explanatory text); only the per-class index tables are. This is checker, not
* fixer: it reports and never rewrites.
* 2. FRESHNESS — the marker-delimited index regions in `docs/rfc/README.md`
* byte-match a fresh render from the tree, so every RFC is listed exactly
* once, under the heading matching its path, with its H1 title and filename
* date. The fix for a stale index is `pnpm run gen-rfc-index`, never a hand
* edit. This is checker, not fixer: it reports and never rewrites.
*
* Run: `tsx scripts/verify-rfc-classification.ts`.
*/
import { readFileSync } from 'node:fs'
import { relative, resolve } from 'node:path'
import { glob } from 'node:fs/promises'
import { resolve } from 'node:path'
import { rfcRoot, spliceReadme, walkRfcTree } from './rfc-index.ts'
const root = resolve(import.meta.dirname, '..')
const rfcRoot = resolve(root, 'docs/rfc')
const { rfcs, errors } = walkRfcTree()
/** The closed set of RFC lifecycles (top-level folders under docs/rfc/). */
const LIFECYCLES = ['proposed', 'implemented', 'rejected'] as const
/**
* The closed set of RFC classes (nested folder under each lifecycle). Adding a
* class is a deliberate act: extend this list AND the README's Classification
* section. The gate rejects any folder not listed here.
*/
const CLASSES = ['feature', 'bug-fix', 'simplification', 'architecture', 'process', 'testing'] as const
/** Non-RFC Markdown allowed to sit directly at a lifecycle root. */
const ROOT_ALLOWLIST = new Set(['AGENTS.md', 'CLAUDE.md'])
/** Title-case a class/lifecycle folder name for README heading comparison. */
const heading = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1)
const errors: string[] = []
// --- Check 1: structure -----------------------------------------------------
// Every Markdown file anywhere under a lifecycle folder, at any depth.
interface Rfc {
lifecycle: string
cls: string
base: string
/** Path relative to docs/rfc, for the README link check. */
rel: string
}
const rfcs: Rfc[] = []
for (const lifecycle of LIFECYCLES) {
for await (const match of glob(`${lifecycle}/**/*.md`, { cwd: rfcRoot })) {
const segs = match.split('/')
// Allowlisted file directly at the lifecycle root (e.g. implemented/AGENTS.md).
if (segs.length === 2 && ROOT_ALLOWLIST.has(segs[1] ?? '')) continue
// A Chinese counterpart (foo.zh.md, docs/i18n/README.md) is the SAME RFC,
// indexed via its English filename; the pairing gate owns its consistency.
if (match.endsWith('.zh.md')) continue
const cls = segs[1]
const base = segs[2]
if (segs.length !== 3 || cls === undefined || base === undefined) {
errors.push(`structure: ${match} — expected {lifecycle}/{class}/file.md (got depth ${segs.length})`)
continue
}
if (!(CLASSES as readonly string[]).includes(cls)) {
errors.push(`structure: ${match} — unknown class folder "${cls}" (allowed: ${CLASSES.join(', ')})`)
continue
}
if (!/^\d{4}-\d{2}-\d{2}-.+\.md$/.test(base)) {
errors.push(`structure: ${match} — filename must be yyyy-mm-dd-topic.md`)
continue
}
rfcs.push({ lifecycle, cls, base, rel: match })
}
}
// --- Check 2: README completeness -------------------------------------------
// Parse the index into (lifecycle, class) -> set of linked rel paths, by
// tracking the current `## {Lifecycle}` and `### {Class}` headings and reading
// every `](path)` link target underneath. A link target is normalized to its
// path relative to docs/rfc.
const readmePath = resolve(rfcRoot, 'README.md')
const readme = readFileSync(readmePath, 'utf8')
const lifecycleByHeading = new Map(LIFECYCLES.map((l): [string, string] => [heading(l), l]))
const classByHeading = new Map(CLASSES.map((c): [string, string] => [heading(c), c]))
/** README-listed RFC link targets, keyed `lifecycle/class` -> set of rel paths. */
const listed = new Map<string, Set<string>>()
let curLifecycle: string | null = null
let curClass: string | null = null
for (const line of readme.split('\n')) {
const h2 = /^##\s+(.+?)\s*$/.exec(line)
if (h2?.[1] !== undefined) {
curLifecycle = lifecycleByHeading.get(h2[1].trim()) ?? null
curClass = null
continue
}
const h3 = /^###\s+(.+?)\s*$/.exec(line)
if (h3?.[1] !== undefined) {
curClass = classByHeading.get(h3[1].trim()) ?? null
continue
}
if (!curLifecycle || !curClass) continue
// Collect every relative .md link target on this line.
for (const m of line.matchAll(/\]\(([^)]+\.md)[^)]*\)/g)) {
const target = m[1]
if (target === undefined) continue
// README links are relative to docs/rfc; normalize and key by location.
const rel = relative(rfcRoot, resolve(rfcRoot, target))
const key = `${curLifecycle}/${curClass}`
const set = listed.get(key) ?? new Set<string>()
set.add(rel)
listed.set(key, set)
}
}
// Every on-disk RFC must be listed under the heading matching its path.
const seenOnDisk = new Set<string>()
for (const rfc of rfcs) {
seenOnDisk.add(rfc.rel)
const key = `${rfc.lifecycle}/${rfc.cls}`
if (!listed.get(key)?.has(rfc.rel)) {
errors.push(
`index: ${rfc.rel} is not listed in README under "## ${heading(rfc.lifecycle)}" → "### ${heading(rfc.cls)}"`,
)
}
}
// Every README entry must point at a real RFC under that same heading (catches a
// misfiled or stale row).
for (const [key, targets] of listed) {
for (const rel of targets) {
if (!seenOnDisk.has(rel)) {
errors.push(`index: README lists "${rel}" under "${key}", but no such RFC exists`)
if (errors.length === 0) {
try {
if (spliceReadme(readme, rfcs) !== readme) {
errors.push('index: docs/rfc/README.md is stale — run `pnpm run gen-rfc-index` and commit the result')
}
} catch (error) {
errors.push(`index: ${error instanceof Error ? error.message : String(error)}`)
}
}
// --- Report -----------------------------------------------------------------
if (errors.length === 0) {
console.log(`verify-rfc-classification: ${rfcs.length} RFC(s) checked, structure and index consistent.`)
process.exit(0)