cordis-walk.ts 3.9 KB

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