verify-type-equiv.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. /**
  2. * Verify every `ts type-equiv` block against the source symbol named by the
  3. * manifest. Blocks and entries have a one-to-one relationship; comparison
  4. * ignores comments and whitespace but preserves declaration structure.
  5. */
  6. import { globSync, readFileSync, existsSync } from 'node:fs'
  7. import { resolve, sep } from 'node:path'
  8. import ts from 'typescript'
  9. const root = resolve(import.meta.dirname, '..')
  10. /** Scan doc-typecheck's full Markdown scope so unmanifested blocks also fail. */
  11. const MARKDOWN_GLOBS = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md']
  12. /** One manifest entry: a documented type-equiv block and its source symbol. */
  13. interface ManifestEntry {
  14. /** Doc file (repo-relative) containing the ` ```ts type-equiv ` block. */
  15. doc: string
  16. /** The declared symbol the block must match (e.g. `SessionEvent`). */
  17. symbol: string
  18. /** Source file (repo-relative) that exports the symbol. */
  19. source: string
  20. }
  21. /** One extracted ` ```ts type-equiv ` block. */
  22. interface EquivBlock {
  23. doc: string
  24. /** 1-based line of the opening fence (for diagnostics). */
  25. line: number
  26. /** Symbol name parsed from the block's declaration. */
  27. symbol: string
  28. /** Block body (the pasted declaration). */
  29. code: string
  30. }
  31. /**
  32. * Remove comments and normalize whitespace so prose-only edits do not drift
  33. * structural copies. This is intentionally not a general tokenizer: repo type
  34. * declarations do not contain comment delimiters inside string literals.
  35. */
  36. function normalize(code: string): string {
  37. return code
  38. .replace(/\/\*[\s\S]*?\*\//g, '')
  39. .replace(/(^|[^:])\/\/.*$/gm, '$1')
  40. .replace(/\s+/g, ' ')
  41. .trim()
  42. }
  43. /** Strip source-only export modifiers. */
  44. function stripExport(code: string): string {
  45. return code.replace(/^export\s+(default\s+)?/, '')
  46. }
  47. /** Parse the declared symbol name from a type-equiv block body. */
  48. function blockSymbol(code: string): string | null {
  49. const m = /(?:export\s+(?:default\s+)?)?(?:abstract\s+)?(?:interface|type|class|enum)\s+([A-Za-z0-9_]+)/.exec(code)
  50. return m?.[1] ?? null
  51. }
  52. /** Extract every ` ```ts type-equiv ` block from one Markdown file. */
  53. function extractEquivBlocks(docRel: string): EquivBlock[] {
  54. const text = readFileSync(resolve(root, docRel), 'utf8')
  55. const lines = text.split('\n')
  56. const blocks: EquivBlock[] = []
  57. let open: { line: number; body: string[] } | null = null
  58. for (let i = 0; i < lines.length; i++) {
  59. const raw = lines[i] ?? ''
  60. const fence = /^```(\s*)(\S.*)?$/.exec(raw)
  61. if (!fence) {
  62. if (open) open.body.push(raw)
  63. continue
  64. }
  65. if (open) {
  66. const code = open.body.join('\n')
  67. const symbol = blockSymbol(code)
  68. if (!symbol) {
  69. throw new Error(`verify-type-equiv: ${docRel}:${open.line} — type-equiv block has no parseable interface/type/class declaration`)
  70. }
  71. blocks.push({ doc: docRel, line: open.line, symbol, code })
  72. open = null
  73. continue
  74. }
  75. if ((fence[2] ?? '').trim() === 'ts type-equiv') open = { line: i + 1, body: [] }
  76. }
  77. if (open) throw new Error(`verify-type-equiv: ${docRel}:${open.line} — unterminated type-equiv block`)
  78. return blocks
  79. }
  80. /** The declaration text of `symbol` in `sourceRel`, with `export` stripped, or
  81. * null when the symbol is not declared there. Uses the TS parser so it spans
  82. * interfaces, type aliases (including mapped/generic ones), classes, and enums
  83. * uniformly, and excludes the leading JSDoc (getStart skips leading trivia)
  84. * while keeping inline member comments. */
  85. function sourceDeclaration(sourceRel: string, symbol: string): string | null {
  86. const abs = resolve(root, sourceRel)
  87. const text = readFileSync(abs, 'utf8')
  88. const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, /* setParentNodes */ true)
  89. for (const stmt of sf.statements) {
  90. const named =
  91. ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)
  92. || ts.isClassDeclaration(stmt) || ts.isEnumDeclaration(stmt)
  93. if (named && stmt.name?.text === symbol) {
  94. return stripExport(stmt.getText(sf))
  95. }
  96. }
  97. return null
  98. }
  99. const manifestRaw = readFileSync(resolve(root, 'scripts/type-equiv.manifest.json'), 'utf8')
  100. const manifest = JSON.parse(manifestRaw) as { entries: ManifestEntry[] }
  101. const entries = manifest.entries
  102. // Key a block/entry by doc + symbol (a symbol may be documented in more than one
  103. // doc, but at most once per doc).
  104. const keyOf = (x: { doc: string; symbol: string }): string => `${x.doc}::${x.symbol}`
  105. // Collect every type-equiv block across ALL docs in scope — not only the docs
  106. // the manifest names — so a block in an unmanifested doc is found and reported
  107. // as an orphan rather than silently skipped.
  108. const docSet = new Set<string>()
  109. for (const pattern of MARKDOWN_GLOBS) {
  110. for (const match of globSync(pattern, { cwd: root })) docSet.add(match.split(sep).join('/'))
  111. }
  112. const blocks: EquivBlock[] = [...docSet].sort().flatMap(extractEquivBlocks)
  113. const errors: string[] = []
  114. // A manifest entry naming a doc that does not exist (or is outside the scanned
  115. // scope, so no block could ever match it) is an error in its own right.
  116. for (const d of [...new Set(entries.map(e => e.doc))]) {
  117. if (!existsSync(resolve(root, d))) errors.push(`manifest references ${d}, which does not exist`)
  118. else if (!docSet.has(d)) errors.push(`manifest references ${d}, which is outside the scanned markdown scope (${MARKDOWN_GLOBS.join(', ')})`)
  119. }
  120. // Duplicate-block guard: the same symbol twice in one doc is ambiguous.
  121. const blockByKey = new Map<string, EquivBlock>()
  122. for (const b of blocks) {
  123. const k = keyOf(b)
  124. const prior = blockByKey.get(k)
  125. if (prior) {
  126. errors.push(`duplicate type-equiv block for ${b.symbol} in ${b.doc} (lines ${prior.line} and ${b.line})`)
  127. continue
  128. }
  129. blockByKey.set(k, b)
  130. }
  131. // Duplicate-entry guard in the manifest.
  132. const entryByKey = new Map<string, ManifestEntry>()
  133. for (const e of entries) {
  134. const k = keyOf(e)
  135. if (entryByKey.has(k)) {
  136. errors.push(`duplicate manifest entry for ${e.symbol} in ${e.doc}`)
  137. continue
  138. }
  139. entryByKey.set(k, e)
  140. }
  141. // 1:1 correspondence: orphan blocks (no entry) and orphan entries (no block).
  142. for (const b of blocks) {
  143. if (!entryByKey.has(keyOf(b))) {
  144. errors.push(`type-equiv block ${b.symbol} (${b.doc}:${b.line}) has no manifest entry — add one to scripts/type-equiv.manifest.json`)
  145. }
  146. }
  147. for (const e of entries) {
  148. if (!blockByKey.has(keyOf(e))) {
  149. errors.push(`manifest entry ${e.symbol} (${e.doc}) has no matching type-equiv block — remove it or add the block`)
  150. }
  151. }
  152. // Verbatim check: each matched block must equal its source declaration.
  153. let verified = 0
  154. for (const e of entries) {
  155. const b = blockByKey.get(keyOf(e))
  156. if (!b) continue // already reported as an orphan entry
  157. const decl = sourceDeclaration(e.source, e.symbol)
  158. if (decl === null) {
  159. errors.push(`symbol ${e.symbol} not found in ${e.source} (manifest entry for ${e.doc})`)
  160. continue
  161. }
  162. if (normalize(decl) !== normalize(stripExport(b.code))) {
  163. errors.push(
  164. `DRIFT: ${e.doc}:${b.line} — type-equiv block for ${e.symbol} does not match ${e.source}.\n`
  165. + ` source: ${normalize(decl)}\n`
  166. + ` doc: ${normalize(stripExport(b.code))}`,
  167. )
  168. continue
  169. }
  170. verified++
  171. }
  172. if (errors.length === 0) {
  173. console.log(`verify-type-equiv: ${verified} type-equiv block(s) match source (1:1 with manifest).`)
  174. process.exit(0)
  175. }
  176. console.error('verify-type-equiv: type-equiv verification failed:')
  177. for (const e of errors) console.error(` ${e}`)
  178. console.error(`\n(checked ${blocks.length} block(s) across ${new Set(blocks.map(b => b.doc)).size} doc(s); manifest at scripts/type-equiv.manifest.json)`)
  179. process.exit(1)