verify-type-equiv.ts 8.7 KB

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