verify-type-equiv.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. /**
  2. * Verify every `ts type-equiv` and `ts public-api` block against the source
  3. * symbol named by the manifest. Ordinary entries preserve the complete
  4. * declaration; `public-api` entries preserve a class's body-stripped public
  5. * declaration. Blocks and entries have a one-to-one relationship; comparison
  6. * ignores whitespace and non-JSDoc comments but preserves declaration
  7. * structure and every original JSDoc comment.
  8. */
  9. import { globSync, readFileSync, existsSync } from 'node:fs'
  10. import { resolve, sep } from 'node:path'
  11. import ts from 'typescript'
  12. const root = resolve(import.meta.dirname, '..')
  13. /** Scan doc-typecheck's full Markdown scope so unmanifested blocks also fail. */
  14. const MARKDOWN_GLOBS = ['README.md', '.agents/notes/**/*.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md']
  15. /** One manifest entry: a source-equivalence block and its source symbol. */
  16. interface ManifestEntry {
  17. /** Doc file (repo-relative) containing the source-equivalence block. */
  18. doc: string
  19. /** The declared symbol the block must match (e.g. `SessionEvent`). */
  20. symbol: string
  21. /** Source file (repo-relative) that exports the symbol. */
  22. source: string
  23. /** Complete declaration (default), or a body-stripped public class API. */
  24. projection?: 'public-api'
  25. }
  26. /** One extracted ` ```ts type-equiv ` or ` ```ts public-api ` block. */
  27. interface EquivBlock {
  28. doc: string
  29. /** 1-based line of the opening fence (for diagnostics). */
  30. line: number
  31. /** Symbol name parsed from the block's declaration. */
  32. symbol: string
  33. /** Complete declaration (default), or a body-stripped public class API. */
  34. projection?: 'public-api'
  35. /** Block body (the pasted declaration). */
  36. code: string
  37. }
  38. /** Normalize declaration structure independently of comments and whitespace. */
  39. function normalizeStructure(code: string): string {
  40. return code
  41. .replace(/\/\*[\s\S]*?\*\//g, '')
  42. .replace(/(^|[^:])\/\/.*$/gm, '$1')
  43. .replace(/\s+/g, ' ')
  44. .trim()
  45. }
  46. /**
  47. * Extract normalized JSDoc comments in source order. Type declarations in this
  48. * repository do not contain comment delimiters inside string literals.
  49. */
  50. function normalizeJSDoc(code: string): string[] {
  51. return [...code.matchAll(/\/\*\*[\s\S]*?\*\//g)]
  52. .map(match => match[0].replace(/\s+/g, ' ').trim())
  53. }
  54. /** Strip source-only export modifiers. */
  55. function stripExport(code: string): string {
  56. return code.replace(/^export\s+(default\s+)?/, '')
  57. }
  58. /** Parse the declared symbol name from a source-equivalence block body. */
  59. function blockSymbol(code: string): string | null {
  60. const sf = ts.createSourceFile('type-equiv.ts', code, ts.ScriptTarget.Latest, /* setParentNodes */ false, ts.ScriptKind.TS)
  61. for (const stmt of sf.statements) {
  62. const named =
  63. ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)
  64. || ts.isClassDeclaration(stmt) || ts.isEnumDeclaration(stmt)
  65. if (named && stmt.name) return stmt.name.text
  66. }
  67. return null
  68. }
  69. /** Extract every source-equivalence block from one Markdown file. */
  70. function extractEquivBlocks(docRel: string): EquivBlock[] {
  71. const text = readFileSync(resolve(root, docRel), 'utf8')
  72. const lines = text.split('\n')
  73. const blocks: EquivBlock[] = []
  74. let open: { line: number; body: string[]; projection?: 'public-api' } | null = null
  75. for (let i = 0; i < lines.length; i++) {
  76. const raw = lines[i] ?? ''
  77. const fence = /^```(\s*)(\S.*)?$/.exec(raw)
  78. if (!fence) {
  79. if (open) open.body.push(raw)
  80. continue
  81. }
  82. if (open) {
  83. const code = open.body.join('\n')
  84. const symbol = blockSymbol(code)
  85. if (!symbol) {
  86. throw new Error(`verify-type-equiv: ${docRel}:${open.line} — type-equiv block has no parseable interface/type/class declaration`)
  87. }
  88. blocks.push({
  89. doc: docRel,
  90. line: open.line,
  91. symbol,
  92. code,
  93. ...(open.projection === undefined ? {} : { projection: open.projection }),
  94. })
  95. open = null
  96. continue
  97. }
  98. const info = (fence[2] ?? '').trim()
  99. if (info === 'ts type-equiv public-api') {
  100. throw new Error(`verify-type-equiv: ${docRel}:${i + 1} — use the concise \`ts public-api\` fence`)
  101. }
  102. if (info === 'ts type-equiv') open = { line: i + 1, body: [] }
  103. if (info === 'ts public-api') open = { line: i + 1, body: [], projection: 'public-api' }
  104. }
  105. if (open) throw new Error(`verify-type-equiv: ${docRel}:${open.line} — unterminated type-equiv block`)
  106. return blocks
  107. }
  108. /**
  109. * The declaration text of `symbol` in `sourceRel`, with `export` stripped, or
  110. * null when the symbol is not declared there. Uses the TS parser so it spans
  111. * interfaces, type aliases (including mapped/generic ones), classes, and enums
  112. * uniformly while including declaration and member JSDoc.
  113. */
  114. function sourceDeclaration(sourceRel: string, symbol: string): string | null {
  115. const abs = resolve(root, sourceRel)
  116. const text = readFileSync(abs, 'utf8')
  117. const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, /* setParentNodes */ true)
  118. for (const stmt of sf.statements) {
  119. const named =
  120. ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt)
  121. || ts.isClassDeclaration(stmt) || ts.isEnumDeclaration(stmt)
  122. if (named && stmt.name?.text === symbol) {
  123. const declarationStart = stmt.getStart(sf)
  124. const jsDoc = ts.getJSDocCommentsAndTags(stmt)
  125. .filter(ts.isJSDoc)
  126. .map(doc => text.slice(doc.pos, doc.end))
  127. .join('\n')
  128. const declaration = stripExport(text.slice(declarationStart, stmt.getEnd()))
  129. return jsDoc === '' ? declaration : `${jsDoc}\n${declaration}`
  130. }
  131. }
  132. return null
  133. }
  134. /** Leading source JSDoc attached to one declaration or member. */
  135. function sourceJSDoc(text: string, node: ts.Node): string {
  136. return ts.getJSDocCommentsAndTags(node)
  137. .filter(ts.isJSDoc)
  138. .map(doc => text.slice(doc.pos, doc.end))
  139. .join('\n')
  140. }
  141. /** Whether a class member is part of its public declaration. */
  142. function isPublicMember(member: ts.ClassElement): boolean {
  143. if (ts.isClassStaticBlockDeclaration(member)) return false
  144. const name = ts.getNameOfDeclaration(member)
  145. if (name && ts.isPrivateIdentifier(name)) return false
  146. const modifiers = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined
  147. return !(modifiers?.some(modifier =>
  148. modifier.kind === ts.SyntaxKind.PrivateKeyword
  149. || modifier.kind === ts.SyntaxKind.ProtectedKeyword,
  150. ) ?? false)
  151. }
  152. /** Remove an implementation body while retaining the source signature. */
  153. function bodylessMember(text: string, sf: ts.SourceFile, member: ts.ClassElement): string {
  154. const start = member.getStart(sf)
  155. let end = member.end
  156. if (ts.isConstructorDeclaration(member) || ts.isMethodDeclaration(member)
  157. || ts.isGetAccessorDeclaration(member) || ts.isSetAccessorDeclaration(member)) {
  158. if (member.body) end = member.body.getStart(sf)
  159. }
  160. if (ts.isPropertyDeclaration(member) && member.initializer) end = member.initializer.getStart(sf)
  161. const signature = text.slice(start, end).trimEnd().replace(/;$/, '').replace(/=\s*$/, '').trimEnd()
  162. return `${signature};`
  163. }
  164. /**
  165. * Render a class as an ambient declaration containing only its public fields,
  166. * constructor, accessors, and methods. Implementation bodies and private or
  167. * protected members are deliberately absent; original class/member JSDoc is
  168. * retained so the projection is the source-owned public contract.
  169. */
  170. function sourcePublicApi(sourceRel: string, symbol: string): string | null {
  171. const abs = resolve(root, sourceRel)
  172. const text = readFileSync(abs, 'utf8')
  173. const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, /* setParentNodes */ true)
  174. for (const stmt of sf.statements) {
  175. if (!ts.isClassDeclaration(stmt) || stmt.name?.text !== symbol) continue
  176. const classDoc = sourceJSDoc(text, stmt)
  177. const abstract = stmt.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AbstractKeyword) ? 'abstract ' : ''
  178. const typeParameters = stmt.typeParameters?.map(parameter => parameter.getText(sf)).join(', ')
  179. const heritage = stmt.heritageClauses?.map(clause => clause.getText(sf)).join(' ')
  180. const header = `declare ${abstract}class ${symbol}${typeParameters ? `<${typeParameters}>` : ''}${heritage ? ` ${heritage}` : ''} {`
  181. const members = stmt.members
  182. .filter(isPublicMember)
  183. .map((member) => {
  184. const jsDoc = sourceJSDoc(text, member)
  185. const declaration = bodylessMember(text, sf, member)
  186. return jsDoc === '' ? declaration : `${jsDoc}\n${declaration}`
  187. })
  188. const declaration = [header, ...members.map(member => member.split('\n').map(line => ` ${line}`).join('\n')), '}'].join('\n')
  189. return classDoc === '' ? declaration : `${classDoc}\n${declaration}`
  190. }
  191. return null
  192. }
  193. const manifestRaw = readFileSync(resolve(root, 'scripts/type-equiv.manifest.json'), 'utf8')
  194. const manifest = JSON.parse(manifestRaw) as { entries: ManifestEntry[] }
  195. const entries = manifest.entries
  196. // Key a block/entry by doc + symbol + projection. A symbol may be documented in
  197. // more than one doc, and a doc may carry both complete and projected forms.
  198. const keyOf = (x: { doc: string; symbol: string; projection?: 'public-api' }): string =>
  199. `${x.doc}::${x.symbol}::${x.projection ?? 'declaration'}`
  200. // Collect every type-equiv block across ALL docs in scope — not only the docs
  201. // the manifest names — so a block in an unmanifested doc is found and reported
  202. // as an orphan rather than silently skipped.
  203. const docSet = new Set<string>()
  204. for (const pattern of MARKDOWN_GLOBS) {
  205. for (const match of globSync(pattern, { cwd: root })) docSet.add(match.split(sep).join('/'))
  206. }
  207. const blocks: EquivBlock[] = [...docSet].sort().flatMap(extractEquivBlocks)
  208. const errors: string[] = []
  209. // A manifest entry naming a doc that does not exist (or is outside the scanned
  210. // scope, so no block could ever match it) is an error in its own right.
  211. for (const d of [...new Set(entries.map(e => e.doc))]) {
  212. if (!existsSync(resolve(root, d))) errors.push(`manifest references ${d}, which does not exist`)
  213. else if (!docSet.has(d)) errors.push(`manifest references ${d}, which is outside the scanned markdown scope (${MARKDOWN_GLOBS.join(', ')})`)
  214. }
  215. // Duplicate-block guard: the same projected symbol twice in one doc is ambiguous.
  216. const blockByKey = new Map<string, EquivBlock>()
  217. for (const b of blocks) {
  218. const k = keyOf(b)
  219. const prior = blockByKey.get(k)
  220. if (prior) {
  221. errors.push(`duplicate type-equiv block for ${b.symbol} in ${b.doc} (lines ${prior.line} and ${b.line})`)
  222. continue
  223. }
  224. blockByKey.set(k, b)
  225. }
  226. // Duplicate-entry guard in the manifest.
  227. const entryByKey = new Map<string, ManifestEntry>()
  228. for (const e of entries) {
  229. const k = keyOf(e)
  230. if (entryByKey.has(k)) {
  231. errors.push(`duplicate manifest entry for ${e.symbol} in ${e.doc}`)
  232. continue
  233. }
  234. entryByKey.set(k, e)
  235. }
  236. // 1:1 correspondence: orphan blocks (no entry) and orphan entries (no block).
  237. for (const b of blocks) {
  238. if (!entryByKey.has(keyOf(b))) {
  239. errors.push(`type-equiv block ${b.symbol} (${b.doc}:${b.line}) has no manifest entry — add one to scripts/type-equiv.manifest.json`)
  240. }
  241. }
  242. for (const e of entries) {
  243. if (!blockByKey.has(keyOf(e))) {
  244. errors.push(`manifest entry ${e.symbol} (${e.doc}) has no matching type-equiv block — remove it or add the block`)
  245. }
  246. }
  247. // Verbatim check: each matched block must equal its source declaration.
  248. let verified = 0
  249. for (const e of entries) {
  250. const b = blockByKey.get(keyOf(e))
  251. if (!b) continue // already reported as an orphan entry
  252. const decl = e.projection === 'public-api'
  253. ? sourcePublicApi(e.source, e.symbol)
  254. : sourceDeclaration(e.source, e.symbol)
  255. if (decl === null) {
  256. errors.push(`symbol ${e.symbol} not found in ${e.source} (manifest entry for ${e.doc})`)
  257. continue
  258. }
  259. const doc = stripExport(b.code)
  260. const sourceStructure = normalizeStructure(decl)
  261. const docStructure = normalizeStructure(doc)
  262. const sourceJSDoc = normalizeJSDoc(decl)
  263. const docJSDoc = normalizeJSDoc(doc)
  264. if (sourceStructure !== docStructure || JSON.stringify(sourceJSDoc) !== JSON.stringify(docJSDoc)) {
  265. errors.push(
  266. `DRIFT: ${e.doc}:${b.line} — type-equiv block for ${e.symbol} does not match ${e.source}.\n`
  267. + ` source structure: ${sourceStructure}\n`
  268. + ` doc structure: ${docStructure}\n`
  269. + ` source JSDoc: ${JSON.stringify(sourceJSDoc)}\n`
  270. + ` doc JSDoc: ${JSON.stringify(docJSDoc)}`,
  271. )
  272. continue
  273. }
  274. verified++
  275. }
  276. if (errors.length === 0) {
  277. console.log(`verify-type-equiv: ${verified} type-equiv block(s) match source structure and JSDoc (1:1 with manifest).`)
  278. process.exit(0)
  279. }
  280. console.error('verify-type-equiv: type-equiv verification failed:')
  281. for (const e of errors) console.error(` ${e}`)
  282. 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)`)
  283. process.exit(1)