jsdoc.ts 8.2 KB

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