jsdoc.ts 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. /**
  2. * Shared JSDoc parsing and completeness-check helpers for the documentation
  3. * gates: the cordis and persistence catalog generators
  4. * (`scripts/gen-cordis-catalog.ts` / `scripts/gen-persistence-catalog.ts`),
  5. * the plugin config catalog generator (`scripts/gen-config-catalog.ts`), and
  6. * the export-surface gate (`scripts/verify-export-jsdoc.ts`). One home for the
  7. * mechanics so "documented" means the same thing on every gated surface:
  8. * description prose ends at the first block tag; every checkable parameter
  9. * needs a non-empty `@param`; a non-void ANNOTATED return needs a non-empty
  10. * `@returns`; a stale `@param` naming no real parameter errors.
  11. */
  12. import ts from 'typescript'
  13. /** Repo-relative source pointer `file:line` for a node's first character. */
  14. export function pointer(rel: string, sf: ts.SourceFile, node: ts.Node): string {
  15. const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf))
  16. return `${rel}:${line + 1}`
  17. }
  18. /** The raw `/** … *​/` JSDoc block immediately preceding a node, or '' if none. */
  19. export function rawJsDoc(text: string, node: ts.Node): string {
  20. const ranges = ts.getLeadingCommentRanges(text, node.getFullStart()) ?? []
  21. const jsdoc = ranges.filter(r => text.slice(r.pos, r.pos + 3) === '/**').at(-1)
  22. return jsdoc ? text.slice(jsdoc.pos, jsdoc.end) : ''
  23. }
  24. /** A dispatch mode, rendered as the badge after an event name in the catalog. */
  25. export type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial'
  26. /**
  27. * Parse a raw JSDoc block into description prose + the `@mode` tag (when
  28. * present). Output obeys the repo's markdown conventions so the generated
  29. * catalog passes verify-md-wrap: each prose paragraph collapses to ONE physical
  30. * line, and a `-` bullet list is preserved with each item on its own single
  31. * line (continuation lines folded in). `{@link Foo}` unwraps to `Foo`.
  32. * Description prose ends at the FIRST block tag (standard JSDoc semantics):
  33. * tag lines and their continuation lines are never prose, so `@param` /
  34. * `@returns` blocks are invisible to the rendered catalog.
  35. * @param raw - the raw comment text including the JSDoc delimiters.
  36. * @returns the collapsed description prose, parsed valid `@mode` (or null),
  37. * and whether any `@mode` tag was present.
  38. */
  39. export function parseJsDoc(raw: string): { doc: string; mode: Mode | null; hasMode: boolean } {
  40. const inner = raw
  41. .replace(/^\/\*\*/, '')
  42. .replace(/\*\/$/, '')
  43. .split('\n')
  44. .map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
  45. let mode: Mode | null = null
  46. let hasMode = false
  47. let inTags = false
  48. const blocks: string[] = []
  49. let para: string[] = []
  50. let list: string[] = []
  51. let item: string[] = []
  52. const join = (parts: string[]): string => parts.join(' ').replace(/\s+/g, ' ').trim()
  53. const flushItem = (): void => {
  54. if (item.length) list.push(join(item))
  55. item = []
  56. }
  57. const flushList = (): void => {
  58. flushItem()
  59. if (list.length) blocks.push(list.join('\n')) // one block, items on own lines
  60. list = []
  61. }
  62. const flushPara = (): void => {
  63. flushList()
  64. if (para.length) blocks.push(join(para))
  65. para = []
  66. }
  67. for (const line of inner) {
  68. const tagLine = line.trimStart()
  69. const m = /^@mode\s+(emit|waterfall|parallel|serial)\s*$/.exec(tagLine)
  70. if (m) { mode = m[1] as Mode; hasMode = true; flushPara(); inTags = true; continue }
  71. if (/^@mode\b/.test(tagLine)) { hasMode = true; flushPara(); inTags = true; continue }
  72. if (tagLine.startsWith('@')) { flushPara(); inTags = true; continue }
  73. if (inTags) continue // block-tag territory: continuations are never prose
  74. if (line.trim() === '') { flushPara(); continue }
  75. if (/^-\s+/.test(line)) {
  76. // A list item starts: a pending paragraph (e.g. an intro line directly
  77. // above the list, no blank between) flushes FIRST so it renders above.
  78. flushItem()
  79. if (para.length) { blocks.push(join(para)); para = [] }
  80. item.push(line)
  81. continue
  82. }
  83. if (item.length) { item.push(line); continue } // continuation of current item
  84. para.push(line)
  85. }
  86. flushPara()
  87. const doc = blocks.join('\n\n').replace(/\{@link\s+([^}]+)\}/g, '$1').trim()
  88. return { doc, mode, hasMode }
  89. }
  90. /**
  91. * Parse the block tags of a raw JSDoc comment for the completeness checks:
  92. * every `@param name — description` entry plus the `@returns` description.
  93. * Standard JSDoc block-tag semantics — a tag's description runs across
  94. * continuation lines until the next tag or a blank line, and the `-`/`—`
  95. * separator after a param name is optional. `[name]` optional-brackets unwrap
  96. * to `name`. Rendering never sees these: parseJsDoc stops prose at the first
  97. * block tag.
  98. * @param raw - the raw comment text including the JSDoc delimiters.
  99. * @returns the `@param` name→description map plus the `@returns` description
  100. * (null when the tag is absent, '' when present but empty).
  101. */
  102. export function parseTags(raw: string): { params: Map<string, string>; returns: string | null } {
  103. const inner = raw
  104. .replace(/^\/\*\*/, '')
  105. .replace(/\*\/$/, '')
  106. .split('\n')
  107. .map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
  108. const params = new Map<string, string>()
  109. let returns: string | null = null
  110. let sink: ((text: string) => void) | null = null
  111. for (const line of inner) {
  112. const param = /^@param\s+(\[?[\w$]+\]?)\s*(?:[-—–]\s*)?(.*)$/.exec(line)
  113. if (param) {
  114. const name = (param[1] ?? '').replace(/^\[|\]$/g, '')
  115. let acc = param[2] ?? ''
  116. params.set(name, acc)
  117. sink = (t) => { acc = acc ? `${acc} ${t}` : t; params.set(name, acc) }
  118. continue
  119. }
  120. const ret = /^@returns?(?:\s+[-—–]?\s*(.*))?$/.exec(line)
  121. if (ret) {
  122. let acc = ret[1] ?? ''
  123. returns = acc
  124. sink = (t) => { acc = acc ? `${acc} ${t}` : t; returns = acc }
  125. continue
  126. }
  127. if (line.startsWith('@') || line.trim() === '') { sink = null; continue }
  128. sink?.(line.trim())
  129. }
  130. return { params, returns }
  131. }
  132. /**
  133. * Check the `@param` half of the completeness contract for one function-like
  134. * declaration: every checkable parameter carries a non-empty `@param`, and no
  135. * `@param` is stale. A binding-pattern parameter is a violation (it has no name
  136. * for `@param` to match); an exempt parameter may be documented but its absence
  137. * is never checked. Violations append to `violations` in place.
  138. * @param where - the offender label violations open with, e.g. `event 'x' (file:1)`.
  139. * @param surface - the surface noun for the binding-pattern message ("event", "service", "export").
  140. * @param parameters - the declaration's parameter list.
  141. * @param tags - the parsed `@param` name→description map from parseTags.
  142. * @param sf - the source file (for rendering a binding pattern's text).
  143. * @param isExempt - which parameters need no `@param` (e.g. `this`, a waterfall's trailing `next`).
  144. * @param violations - the aggregate list violations append to.
  145. */
  146. export function checkParams(
  147. where: string,
  148. surface: string,
  149. parameters: readonly ts.ParameterDeclaration[],
  150. tags: Map<string, string>,
  151. sf: ts.SourceFile,
  152. isExempt: (p: ts.ParameterDeclaration) => boolean,
  153. violations: string[],
  154. ): void {
  155. for (const p of parameters) {
  156. if (!ts.isIdentifier(p.name)) {
  157. violations.push(`${where}: parameter '${p.name.getText(sf)}' is a binding pattern; the ${surface} surface needs simple identifier parameters so @param can name them.`)
  158. continue
  159. }
  160. if (isExempt(p)) continue
  161. const desc = tags.get(p.name.text)
  162. if (desc === undefined) violations.push(`${where} is missing @param ${p.name.text}.`)
  163. else if (!desc.trim()) violations.push(`${where}: @param ${p.name.text} has an empty description.`)
  164. }
  165. for (const tag of tags.keys()) {
  166. if (!parameters.some(p => ts.isIdentifier(p.name) && p.name.text === tag)) {
  167. violations.push(`${where}: @param ${tag} does not match any parameter (stale tag?).`)
  168. }
  169. }
  170. }
  171. /**
  172. * Check the `@returns` half of the completeness contract: a non-`void` /
  173. * `Promise<void>` return needs a non-empty `@returns`, and the return type must
  174. * be ANNOTATED — a pure-AST walk cannot classify an inferred return. On a void
  175. * declaration `@returns` stays optional (resolution timing can be worth
  176. * documenting), never required. Violations append to `violations` in place.
  177. * @param where - the offender label violations open with.
  178. * @param typeNode - the declared return type annotation, or undefined when inferred.
  179. * @param returns - the parsed `@returns` description from parseTags (null when absent).
  180. * @param sf - the source file (for rendering the annotation's text).
  181. * @param violations - the aggregate list violations append to.
  182. */
  183. export function checkReturns(
  184. where: string,
  185. typeNode: ts.TypeNode | undefined,
  186. returns: string | null,
  187. sf: ts.SourceFile,
  188. violations: string[],
  189. ): void {
  190. if (typeNode === undefined) {
  191. violations.push(`${where} has no return type annotation; annotate it explicitly so the gate can classify the result.`)
  192. return
  193. }
  194. const rt = typeNode.getText(sf).replace(/\s+/g, ' ')
  195. if (/^(void|Promise<void>)$/.test(rt)) return
  196. if (returns === null) violations.push(`${where} is missing @returns (return type: ${rt}).`)
  197. else if (!returns.trim()) violations.push(`${where}: @returns has an empty description.`)
  198. }
  199. /**
  200. * Throw one aggregate error for every completeness violation a walk collected.
  201. * Aggregation (vs failing fast) is deliberate: a remediation pass sees the
  202. * whole list at once instead of replaying the gate once per offender.
  203. * @param gate - the reporting gate's name, prefixed to the error message.
  204. * @param violations - the collected violation lines; no-op when empty.
  205. */
  206. export function reportViolations(gate: string, violations: string[]): void {
  207. if (violations.length === 0) return
  208. throw new Error(
  209. `${gate}: ${violations.length} JSDoc completeness violation(s) (see AGENTS.md):\n`
  210. + violations.map(v => ` ${v}`).join('\n'),
  211. )
  212. }