verify-rfc-classification.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. /**
  2. * Doc-sync gate: enforce the RFC classification scheme
  3. * ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md)).
  4. * Every RFC is filed at `docs/rfc/{lifecycle}/{class}/yyyy-mm-dd-topic.md`; the
  5. * folder IS the label. This gate is the machine source of truth for the closed
  6. * class set and keeps the README index honest.
  7. *
  8. * Two checks:
  9. *
  10. * 1. STRUCTURE — every `.md` under a lifecycle folder lives in a class folder
  11. * from CLASSES, named `yyyy-mm-dd-*.md`. A loose `.md` directly under a
  12. * lifecycle root (other than the README/AGENTS allowlist) fails; an unknown
  13. * class folder fails; a stray file at an unexpected depth fails. This is what
  14. * makes the set CLOSED: a new class folder can't appear without amending
  15. * CLASSES here (and the README's Classification section, per the RFC).
  16. *
  17. * 2. COMPLETENESS — `docs/rfc/README.md` lists every RFC exactly once, under the
  18. * `### {Class}` heading inside the `## {Lifecycle}` section that matches the
  19. * file's path. A missing entry, a duplicate, or an entry under the wrong
  20. * heading fails. This mirrors `verify-event-taxonomy`: a curated doc table
  21. * checked against the on-disk source of truth, so the index can't drift.
  22. *
  23. * The class DESCRIPTIONS in the README prose are not checked (they are
  24. * explanatory text); only the per-class index tables are. This is checker, not
  25. * fixer: it reports and never rewrites.
  26. *
  27. * Run: `tsx scripts/verify-rfc-classification.ts`.
  28. */
  29. import { readFileSync } from 'node:fs'
  30. import { relative, resolve } from 'node:path'
  31. import { glob } from 'node:fs/promises'
  32. const root = resolve(import.meta.dirname, '..')
  33. const rfcRoot = resolve(root, 'docs/rfc')
  34. /** The closed set of RFC lifecycles (top-level folders under docs/rfc/). */
  35. const LIFECYCLES = ['proposed', 'implemented', 'rejected'] as const
  36. /**
  37. * The closed set of RFC classes (nested folder under each lifecycle). Adding a
  38. * class is a deliberate act: extend this list AND the README's Classification
  39. * section. The gate rejects any folder not listed here.
  40. */
  41. const CLASSES = ['feature', 'bug-fix', 'simplification', 'architecture', 'process', 'testing'] as const
  42. /** Non-RFC Markdown allowed to sit directly at a lifecycle root. */
  43. const ROOT_ALLOWLIST = new Set(['AGENTS.md', 'CLAUDE.md'])
  44. /** Title-case a class/lifecycle folder name for README heading comparison. */
  45. const heading = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1)
  46. const errors: string[] = []
  47. // --- Check 1: structure -----------------------------------------------------
  48. // Every Markdown file anywhere under a lifecycle folder, at any depth.
  49. interface Rfc {
  50. lifecycle: string
  51. cls: string
  52. base: string
  53. /** Path relative to docs/rfc, for the README link check. */
  54. rel: string
  55. }
  56. const rfcs: Rfc[] = []
  57. for (const lifecycle of LIFECYCLES) {
  58. for await (const match of glob(`${lifecycle}/**/*.md`, { cwd: rfcRoot })) {
  59. const segs = match.split('/')
  60. // Allowlisted file directly at the lifecycle root (e.g. implemented/AGENTS.md).
  61. if (segs.length === 2 && ROOT_ALLOWLIST.has(segs[1] ?? '')) continue
  62. const cls = segs[1]
  63. const base = segs[2]
  64. if (segs.length !== 3 || cls === undefined || base === undefined) {
  65. errors.push(`structure: ${match} — expected {lifecycle}/{class}/file.md (got depth ${segs.length})`)
  66. continue
  67. }
  68. if (!(CLASSES as readonly string[]).includes(cls)) {
  69. errors.push(`structure: ${match} — unknown class folder "${cls}" (allowed: ${CLASSES.join(', ')})`)
  70. continue
  71. }
  72. if (!/^\d{4}-\d{2}-\d{2}-.+\.md$/.test(base)) {
  73. errors.push(`structure: ${match} — filename must be yyyy-mm-dd-topic.md`)
  74. continue
  75. }
  76. rfcs.push({ lifecycle, cls, base, rel: match })
  77. }
  78. }
  79. // --- Check 2: README completeness -------------------------------------------
  80. // Parse the index into (lifecycle, class) -> set of linked rel paths, by
  81. // tracking the current `## {Lifecycle}` and `### {Class}` headings and reading
  82. // every `](path)` link target underneath. A link target is normalized to its
  83. // path relative to docs/rfc.
  84. const readmePath = resolve(rfcRoot, 'README.md')
  85. const readme = readFileSync(readmePath, 'utf8')
  86. const lifecycleByHeading = new Map(LIFECYCLES.map((l): [string, string] => [heading(l), l]))
  87. const classByHeading = new Map(CLASSES.map((c): [string, string] => [heading(c), c]))
  88. /** README-listed RFC link targets, keyed `lifecycle/class` -> set of rel paths. */
  89. const listed = new Map<string, Set<string>>()
  90. let curLifecycle: string | null = null
  91. let curClass: string | null = null
  92. for (const line of readme.split('\n')) {
  93. const h2 = /^##\s+(.+?)\s*$/.exec(line)
  94. if (h2?.[1] !== undefined) {
  95. curLifecycle = lifecycleByHeading.get(h2[1].trim()) ?? null
  96. curClass = null
  97. continue
  98. }
  99. const h3 = /^###\s+(.+?)\s*$/.exec(line)
  100. if (h3?.[1] !== undefined) {
  101. curClass = classByHeading.get(h3[1].trim()) ?? null
  102. continue
  103. }
  104. if (!curLifecycle || !curClass) continue
  105. // Collect every relative .md link target on this line.
  106. for (const m of line.matchAll(/\]\(([^)]+\.md)[^)]*\)/g)) {
  107. const target = m[1]
  108. if (target === undefined) continue
  109. // README links are relative to docs/rfc; normalize and key by location.
  110. const rel = relative(rfcRoot, resolve(rfcRoot, target))
  111. const key = `${curLifecycle}/${curClass}`
  112. const set = listed.get(key) ?? new Set<string>()
  113. set.add(rel)
  114. listed.set(key, set)
  115. }
  116. }
  117. // Every on-disk RFC must be listed under the heading matching its path.
  118. const seenOnDisk = new Set<string>()
  119. for (const rfc of rfcs) {
  120. seenOnDisk.add(rfc.rel)
  121. const key = `${rfc.lifecycle}/${rfc.cls}`
  122. if (!listed.get(key)?.has(rfc.rel)) {
  123. errors.push(
  124. `index: ${rfc.rel} is not listed in README under "## ${heading(rfc.lifecycle)}" → "### ${heading(rfc.cls)}"`,
  125. )
  126. }
  127. }
  128. // Every README entry must point at a real RFC under that same heading (catches a
  129. // misfiled or stale row).
  130. for (const [key, targets] of listed) {
  131. for (const rel of targets) {
  132. if (!seenOnDisk.has(rel)) {
  133. errors.push(`index: README lists "${rel}" under "${key}", but no such RFC exists`)
  134. }
  135. }
  136. }
  137. // --- Report -----------------------------------------------------------------
  138. if (errors.length === 0) {
  139. console.log(`verify-rfc-classification: ${rfcs.length} RFC(s) checked, structure and index consistent.`)
  140. process.exit(0)
  141. }
  142. console.error('verify-rfc-classification: violations found:')
  143. for (const e of errors) console.error(` ${e}`)
  144. process.exit(1)