cordis-walk.ts 3.7 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
  1. /**
  2. * AST walkers for the Cordis catalog generator: locate the Cordis module merge
  3. * in a source file, enumerate its `interface Events` members, and resolve the
  4. * `interface Context` service keys to their service classes.
  5. */
  6. import ts from 'typescript'
  7. import { parseJsDoc, pointer, rawJsDoc } from './jsdoc.ts'
  8. /** The body of the cordis module merge in `sf`: `declare module 'cordis'`
  9. * (harness packages) or `declare module './context.ts'` (vendor core), or
  10. * null when the file has neither. */
  11. export function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
  12. for (const stmt of sf.statements) {
  13. if (!ts.isModuleDeclaration(stmt) || !ts.isStringLiteral(stmt.name)) continue
  14. if (stmt.name.text !== 'cordis' && stmt.name.text !== './context.ts') continue
  15. if (stmt.body && ts.isModuleBlock(stmt.body)) return stmt.body
  16. }
  17. return null
  18. }
  19. /** Every `interface Events` method member of a cordis module merge, with the
  20. * event name resolved from its (possibly string-literal) property name. */
  21. export function eventMembers(body: ts.ModuleBlock, sf: ts.SourceFile): { name: string; member: ts.MethodSignature }[] {
  22. const out: { name: string; member: ts.MethodSignature }[] = []
  23. for (const stmt of body.statements) {
  24. if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Events') continue
  25. for (const member of stmt.members) {
  26. if (!ts.isMethodSignature(member)) continue
  27. const name = ts.isStringLiteral(member.name) ? member.name.text : member.name.getText(sf)
  28. out.push({ name, member })
  29. }
  30. }
  31. return out
  32. }
  33. /** The `ctx.<key> → type name` map declared by a merge's `interface Context`. */
  34. function contextKeyMap(body: ts.ModuleBlock, sf: ts.SourceFile): Map<string, string> {
  35. const keyToType = new Map<string, string>()
  36. for (const stmt of body.statements) {
  37. if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Context') continue
  38. for (const member of stmt.members) {
  39. if (!ts.isPropertySignature(member) || !member.type) continue
  40. keyToType.set(member.name.getText(sf), member.type.getText(sf))
  41. }
  42. }
  43. return keyToType
  44. }
  45. /** One `ctx.<key>` service class resolved from a Context merge. */
  46. export interface ServiceClass {
  47. key: string
  48. type: string
  49. cls: ts.ClassDeclaration
  50. abstract: boolean
  51. /** Class-level JSDoc prose (empty string when missing — also reported). */
  52. doc: string
  53. }
  54. /**
  55. * Resolve each `ctx.<key>` of a merge to the service class declared in the
  56. * same file. A key whose type is not a class here (a Pick-mixin member, e.g.
  57. * timer helpers) is skipped. A class without JSDoc prose is reported into
  58. * `violations` (named `where` by the caller's gate).
  59. *
  60. * @param body — the cordis module merge body.
  61. * @param sf — the source file containing the merge.
  62. * @param rel — repo-relative path of `sf`, for violation pointers.
  63. * @param violations — sink for JSDoc-completeness violations.
  64. * @returns the resolved service classes, in Context-declaration order.
  65. */
  66. export function serviceClasses(
  67. body: ts.ModuleBlock,
  68. sf: ts.SourceFile,
  69. rel: string,
  70. violations: string[],
  71. ): ServiceClass[] {
  72. const text = sf.getFullText()
  73. const out: ServiceClass[] = []
  74. for (const [key, type] of contextKeyMap(body, sf)) {
  75. const cls = sf.statements.find(
  76. (s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === type,
  77. )
  78. if (!cls) continue // a Pick-mixin member, not a class here
  79. const abstract = cls.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false
  80. const doc = parseJsDoc(rawJsDoc(text, cls)).doc
  81. if (!doc) violations.push(`service ctx.${key} (${pointer(rel, sf, cls)}): class ${type} has no JSDoc.`)
  82. out.push({ key, type, cls, abstract, doc })
  83. }
  84. return out
  85. }