verify-event-taxonomy.ts 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. /**
  2. * Doc-sync gate (doc-sync-enforcement RFC, part 2): verify the event-taxonomy table in
  3. * docs/architecture.md against the events actually declared in source.
  4. *
  5. * The table duplicates the `declare module 'cordis' { interface Events }`
  6. * blocks across packages/* /src. This script extracts both sets of event names
  7. * and asserts they match exactly — every declared event appears in the table,
  8. * and the table names no event that isn't declared. Verify, don't generate
  9. * (per the RFC): the table keeps its hand-written Mode/Purpose columns; only
  10. * the set of names is checked.
  11. *
  12. * Run: `tsx scripts/verify-event-taxonomy.ts`.
  13. */
  14. import { readFileSync } from 'node:fs'
  15. import { join, relative, resolve } from 'node:path'
  16. import { glob } from 'node:fs/promises'
  17. const root = resolve(import.meta.dirname, '..')
  18. /**
  19. * Remove `/* *​/` block comments and `//` line comments from TS source. Used to
  20. * de-risk the brace walk in {@link declaredEvents} — a JSDoc `{@link}` tag would
  21. * otherwise throw off the `{`/`}` depth counter. Good enough for our own source
  22. * (no string literals contain `//` or comment-like brace sequences in an Events
  23. * block); it is not a general tokenizer.
  24. */
  25. function stripComments(text: string): string {
  26. return text
  27. .replace(/\/\*[\s\S]*?\*\//g, '')
  28. .replace(/(^|[^:])\/\/.*$/gm, '$1')
  29. }
  30. /**
  31. * Event names declared in source: the keys inside every `interface Events`
  32. * block under packages/* /src. A declared event is a quoted `'scope/name'(`
  33. * method signature at the start of a line within such a block.
  34. */
  35. async function declaredEvents(): Promise<Map<string, string>> {
  36. const found = new Map<string, string>()
  37. for await (const match of glob('packages/*/src/**/*.ts', { cwd: root })) {
  38. const abs = resolve(root, match)
  39. // Strip comments first so a JSDoc `{@link …}` tag (or a `// {` line) inside
  40. // an Events block can't unbalance the brace walk below. Event names live in
  41. // code, never in comments, so this loses nothing.
  42. const text = stripComments(readFileSync(abs, 'utf8'))
  43. // Walk `interface Events {` blocks brace-balanced and pull quoted keys.
  44. const re = /interface\s+Events\s*\{/g
  45. let m: RegExpExecArray | null
  46. while ((m = re.exec(text)) !== null) {
  47. let depth = 1
  48. let i = m.index + m[0].length
  49. const start = i
  50. while (i < text.length && depth > 0) {
  51. const ch = text[i]
  52. if (ch === '{') depth++
  53. else if (ch === '}') depth--
  54. i++
  55. }
  56. const body = text.slice(start, i - 1)
  57. // A declaration is a quoted event name followed by `(` (method form).
  58. for (const k of body.matchAll(/['"]([a-z][a-z-]*\/[a-z-]+)['"]\s*\(/g)) {
  59. const name = k[1]
  60. if (name) found.set(name, relative(root, abs))
  61. }
  62. }
  63. }
  64. return found
  65. }
  66. /** Event names referenced in the architecture-doc taxonomy table (in `code`). */
  67. function tableEvents(): Set<string> {
  68. const text = readFileSync(join(root, 'docs/architecture.md'), 'utf8')
  69. const lines = text.split('\n')
  70. const heading = lines.findIndex(l => /^###\s+Event taxonomy/.test(l))
  71. if (heading === -1) throw new Error('verify-event-taxonomy: "### Event taxonomy" heading not found')
  72. const names = new Set<string>()
  73. for (let i = heading + 1; i < lines.length; i++) {
  74. const line = lines[i] ?? ''
  75. if (/^###\s/.test(line)) break // next section ends the table
  76. if (!line.includes('|')) continue
  77. for (const code of line.matchAll(/`([^`]+)`/g)) {
  78. // A cell may read "`a/b` / `c/d` (pkg)" — pull each scoped name.
  79. for (const name of (code[1] ?? '').matchAll(/[a-z][a-z-]*\/[a-z-]+/g)) names.add(name[0])
  80. }
  81. }
  82. return names
  83. }
  84. const declared = await declaredEvents()
  85. const table = tableEvents()
  86. const declaredNames = new Set(declared.keys())
  87. const missingFromTable = [...declaredNames].filter(n => !table.has(n)).sort()
  88. const missingFromSource = [...table].filter(n => !declaredNames.has(n)).sort()
  89. if (missingFromTable.length === 0 && missingFromSource.length === 0) {
  90. console.log(`verify-event-taxonomy: ${declaredNames.size} events match the architecture-doc table.`)
  91. process.exit(0)
  92. }
  93. if (missingFromTable.length > 0) {
  94. console.error('verify-event-taxonomy: declared in source but MISSING from the docs/architecture.md table:')
  95. for (const n of missingFromTable) {
  96. console.error(` ${n} (declared in ${declared.get(n) ?? '?'})`)
  97. }
  98. }
  99. if (missingFromSource.length > 0) {
  100. console.error('verify-event-taxonomy: named in the table but NOT declared in source (stale doc):')
  101. for (const n of missingFromSource) {
  102. console.error(` ${n}`)
  103. }
  104. }
  105. process.exit(1)