Redesign per review: neither language is canonical. A pair is three sibling files — foo.md, foo.zh.md, foo.i18n.yaml — and either language may be authored first (a Chinese-first RFC is as legitimate as an English-first one). The sidecar record holds the FULL git blob hash of both sides as of the last confirmed-consistent state, replacing the in-file one-directional fingerprint; editing either side without re-confirming the pair goes red. New --write mode re-records a pair after both sides are brought in line, making the confirmation a reviewable yaml diff. Pairs merge whole (completeness enforced). - gate rewritten around pair anchors (union of .zh.md and .i18n.yaml remnants) so half-deleted pairs are caught from either side; red/green proven for en-only edit, zh-only edit, missing record, and a record for an excluded file - verify-rfc-classification now skips .zh.md counterparts (same RFC, indexed via its English filename; the pairing gate owns consistency) - docs/i18n/README.md + translation-rules.md reframed bidirectionally (terminology table binds both directions; typography section governs the Chinese side); zh counterparts updated; skill workflow updated - RFC amended to the shipped design, records the English-canonical in-file-fingerprint model as considered-and-revised; RFC translated (docs/rfc/.../2026-07-02-bilingual-docs-and-pairing-gate.zh.md) and added to the required frontier - generated docs stay excluded with the follow-up recorded: teach the generators to emit Chinese, then de-list
164 lines
6.6 KiB
TypeScript
164 lines
6.6 KiB
TypeScript
/**
|
|
* Doc-sync gate: enforce the RFC classification scheme
|
|
* ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.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:
|
|
*
|
|
* 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).
|
|
*
|
|
* 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.
|
|
*
|
|
* Run: `tsx scripts/verify-rfc-classification.ts`.
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs'
|
|
import { relative, resolve } from 'node:path'
|
|
import { glob } from 'node:fs/promises'
|
|
|
|
const root = resolve(import.meta.dirname, '..')
|
|
const rfcRoot = resolve(root, 'docs/rfc')
|
|
|
|
/** 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`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Report -----------------------------------------------------------------
|
|
if (errors.length === 0) {
|
|
console.log(`verify-rfc-classification: ${rfcs.length} RFC(s) checked, structure and index consistent.`)
|
|
process.exit(0)
|
|
}
|
|
|
|
console.error('verify-rfc-classification: violations found:')
|
|
for (const e of errors) console.error(` ${e}`)
|
|
process.exit(1)
|