verify-export-jsdoc.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. /**
  2. * Verify JSDoc completeness for EVERY module-level exported name of every
  3. * non-vendored package (each `packages/<group>/<pkg>/src/` tree). This is the
  4. * mechanical form of the AGENTS.md rule "every export has a JSDoc explaining
  5. * semantics", generalizing the cordis-surface gate (`gen-cordis-catalog.ts`,
  6. * which owns `interface Events` members and `ctx.<key>` service classes) to
  7. * the whole export surface; the parsing + check helpers are shared via
  8. * `scripts/jsdoc.ts` so "documented" means the same thing on both.
  9. *
  10. * `tsx scripts/verify-export-jsdoc.ts` → exit 1 listing every offender
  11. *
  12. * The contract, per exported declaration kind:
  13. *
  14. * - Every exported name needs JSDoc with non-empty description prose (prose
  15. * ends at the first block tag, standard JSDoc semantics).
  16. * - A function-like export (function declaration, or a const with a function
  17. * initializer) additionally needs a non-empty `@param` per parameter
  18. * (`this` receiver annotations exempt; a stale `@param` errors) and a
  19. * non-empty `@returns` unless the return type is `void`/`Promise<void>`.
  20. * The walk classifies returns syntactically, so the return type must be
  21. * ANNOTATED — except a const whose DECLARATOR is type-annotated (e.g.
  22. * `export const f: Handler = …`), where the named type owns the return
  23. * contract and `@returns` stays optional.
  24. * - An exported class needs class-level JSDoc; its public methods (static
  25. * included — they are reachable on the exported name) follow the function
  26. * contract, and public properties and accessors need description prose (on
  27. * a get/set pair the getter's doc covers both). A member whose name exists
  28. * on an `extends`/`implements` heritage type is EXEMPT — the seam
  29. * declaration is the doc's one home, the IDE inherits it, and re-documenting
  30. * every implementation invites drift. This is the one question the walk
  31. * asks the TYPE CHECKER (heritage members live across package boundaries);
  32. * everything else is pure AST. Constructors are exempt like the cordis
  33. * gate's: plugin classes are framework-constructed, and the class doc owns
  34. * the story.
  35. * - Exported interfaces, type aliases, enums: description prose on the
  36. * declaration (member-level docs stay review's job; the highest-value
  37. * member surface — seam service classes — is already under the cordis
  38. * gate).
  39. * - An exported namespace recurses (its exported members are package
  40. * surface); the namespace itself needs prose only when it does not merge
  41. * with an already-documented same-name declaration (the Config-namespace
  42. * idiom documents the class/function once, not twice).
  43. * - The cordis plugin-protocol slots are exempt: top-level `name` / `inject`
  44. * / `reusable` / `Config` consts and the `apply` entry, plus the same
  45. * slots as statics on a plugin class. Their shape is fixed by the
  46. * framework, so a doc would restate the protocol — the module doc comment
  47. * and the `interface Config` carry the plugin's real semantics. (These
  48. * names are reserved by cordis convention; documenting one anyway is
  49. * allowed, only absence goes unchecked.)
  50. * - Overload groups: each overload signature carries its own docs; the
  51. * implementation signature is exempt (callers never see it).
  52. * - Skipped: `declare module` / `declare global` augmentation bodies (the
  53. * cordis gate's turf; an augmentation is not an export of the package) and
  54. * re-export statements with a module specifier (`export … from`) — the
  55. * defining module is walked on its own, and external definitions are not
  56. * ours to document.
  57. */
  58. import { existsSync, globSync } from 'node:fs'
  59. import { resolve } from 'node:path'
  60. import ts from 'typescript'
  61. import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc } from './jsdoc.ts'
  62. const root = resolve(import.meta.dirname, '..')
  63. /** Plugin-protocol slot names exempt as statics on an exported class. */
  64. const PROTOCOL_STATICS = new Set(['Config', 'inject', 'name', 'reusable'])
  65. /** Plugin-protocol slot names exempt as top-level exports (const or function). */
  66. const PROTOCOL_EXPORTS = new Set(['Config', 'inject', 'name', 'reusable', 'apply'])
  67. /** Per-file walk state threaded through the scope recursion. */
  68. interface Walk {
  69. /** Repo-relative path of the file being walked. */
  70. rel: string
  71. /** The parsed source file. */
  72. sf: ts.SourceFile
  73. /** Raw file text (rawJsDoc reads comment ranges out of it). */
  74. text: string
  75. /** The program's checker, consulted only for heritage-member lookups. */
  76. checker: ts.TypeChecker
  77. /** The aggregate violation list, appended in place. */
  78. violations: string[]
  79. }
  80. /** True when a statement carries the `export` modifier. */
  81. function isExported(stmt: ts.Statement): boolean {
  82. return ts.canHaveModifiers(stmt) && (ts.getModifiers(stmt)?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false)
  83. }
  84. /** True for a class member a consumer cannot reach: `private`/`protected`/`#name`. */
  85. function isNonPublic(member: ts.ClassElement): boolean {
  86. const mods = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined
  87. return (mods?.some(m => m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword) ?? false)
  88. || ('name' in member && ts.isPrivateIdentifier(member.name))
  89. }
  90. /** True when a class member carries the `static` modifier. */
  91. function isStatic(member: ts.ClassElement): boolean {
  92. const mods = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined
  93. return mods?.some(m => m.kind === ts.SyntaxKind.StaticKeyword) ?? false
  94. }
  95. /** The `this`-receiver exemption every function-like check shares. */
  96. function thisReceiver(p: ts.ParameterDeclaration): boolean {
  97. return ts.isIdentifier(p.name) && p.name.text === 'this'
  98. }
  99. /**
  100. * True when a member name exists on any `extends`/`implements` heritage type
  101. * of the class — the member implements or overrides a documented seam
  102. * declaration, which is the doc's one home (the IDE inherits it on hover).
  103. * Static members are looked up on the base CONSTRUCTOR type (only an
  104. * `extends` expression has one; an unresolvable or interface expression
  105. * yields no property and therefore no exemption).
  106. * @param cls - the class whose heritage to search.
  107. * @param name - the member name to look up.
  108. * @param staticSide - whether to search the constructor side instead of the instance side.
  109. * @param checker - the program's type checker.
  110. * @returns true when a heritage type declares the member.
  111. */
  112. function inheritedMember(cls: ts.ClassDeclaration, name: string, staticSide: boolean, checker: ts.TypeChecker): boolean {
  113. for (const clause of cls.heritageClauses ?? []) {
  114. for (const t of clause.types) {
  115. const type = staticSide ? checker.getTypeAtLocation(t.expression) : checker.getTypeAtLocation(t)
  116. if (type.getProperty(name) !== undefined) return true
  117. }
  118. }
  119. return false
  120. }
  121. /**
  122. * Check description-prose presence for one labeled declaration: JSDoc must
  123. * exist and carry prose above its block tags.
  124. * @param where - the offender label violations open with.
  125. * @param raw - the declaration's raw JSDoc block ('' if none).
  126. * @param w - the walk state violations append to.
  127. */
  128. function checkDescribed(where: string, raw: string, w: Walk): void {
  129. if (!raw) w.violations.push(`${where} has no JSDoc.`)
  130. else if (!parseJsDoc(raw).doc) w.violations.push(`${where} has no description prose above its block tags.`)
  131. }
  132. /**
  133. * Check the full function contract for one labeled function-like declaration:
  134. * description prose, `@param` per parameter, `@returns` on a non-void result.
  135. * @param where - the offender label violations open with.
  136. * @param raw - the declaration's raw JSDoc block ('' if none).
  137. * @param parameters - the declaration's parameter list.
  138. * @param returnType - the return type annotation, or undefined when inferred.
  139. * @param returnsWaived - suppress the `@returns`/annotation requirement (a
  140. * declarator-annotated const defers its return contract to the named type).
  141. * @param w - the walk state violations append to.
  142. */
  143. function checkFunctionLike(
  144. where: string,
  145. raw: string,
  146. parameters: readonly ts.ParameterDeclaration[],
  147. returnType: ts.TypeNode | undefined,
  148. returnsWaived: boolean,
  149. w: Walk,
  150. ): void {
  151. if (!raw) { w.violations.push(`${where} has no JSDoc.`); return }
  152. if (!parseJsDoc(raw).doc) w.violations.push(`${where} has no description prose above its block tags.`)
  153. const { params, returns } = parseTags(raw)
  154. checkParams(where, 'export', parameters, params, w.sf, thisReceiver, w.violations)
  155. if (!returnsWaived) checkReturns(where, returnType, returns, w.sf, w.violations)
  156. }
  157. /**
  158. * Check one exported class: class-level prose, the function contract on every
  159. * public method (overload implementations exempt), and description prose on
  160. * public properties and accessors (a get/set pair is covered by the getter's
  161. * doc). Members declared by a heritage type and the plugin-protocol statics
  162. * are exempt; constructors are not checked (framework-constructed plugins,
  163. * and the class doc owns the story).
  164. * @param cls - the exported class declaration.
  165. * @param name - the class's surface name (namespace-qualified).
  166. * @param w - the walk state violations append to.
  167. */
  168. function checkClass(cls: ts.ClassDeclaration, name: string, w: Walk): void {
  169. checkDescribed(`exported class '${name}' (${pointer(w.rel, w.sf, cls)})`, rawJsDoc(w.text, cls), w)
  170. const overloadSigs = new Set<string>()
  171. const documentedGetters = new Set<string>()
  172. for (const m of cls.members) {
  173. if ('name' in m && ts.isComputedPropertyName(m.name)) continue
  174. if (ts.isMethodDeclaration(m) && !m.body) overloadSigs.add(m.name.getText(w.sf))
  175. if (ts.isGetAccessorDeclaration(m)) documentedGetters.add(m.name.getText(w.sf))
  176. }
  177. for (const m of cls.members) {
  178. if (isNonPublic(m) || ts.isConstructorDeclaration(m)) continue
  179. if (!('name' in m) || ts.isComputedPropertyName(m.name)) continue // computed/symbol members
  180. const mname = m.name.getText(w.sf)
  181. if (isStatic(m) && PROTOCOL_STATICS.has(mname)) continue // cordis plugin-protocol slot
  182. if (inheritedMember(cls, mname, isStatic(m), w.checker)) continue // the heritage declaration owns the doc
  183. if (ts.isMethodDeclaration(m)) {
  184. if (m.body && overloadSigs.has(mname)) continue // overload implementation: the signatures carry the docs
  185. checkFunctionLike(`exported class method '${name}.${mname}' (${pointer(w.rel, w.sf, m)})`, rawJsDoc(w.text, m), m.parameters, m.type, false, w)
  186. } else if (ts.isGetAccessorDeclaration(m) || ts.isPropertyDeclaration(m)) {
  187. const kind = ts.isPropertyDeclaration(m) ? 'property' : 'accessor'
  188. checkDescribed(`exported class ${kind} '${name}.${mname}' (${pointer(w.rel, w.sf, m)})`, rawJsDoc(w.text, m), w)
  189. } else if (ts.isSetAccessorDeclaration(m) && !documentedGetters.has(mname)) {
  190. checkDescribed(`exported class accessor '${name}.${mname}' (${pointer(w.rel, w.sf, m)})`, rawJsDoc(w.text, m), w)
  191. }
  192. // index signatures / static blocks: not named surface
  193. }
  194. }
  195. /**
  196. * Check one exported declaration statement, dispatching on its kind.
  197. * @param stmt - the exported statement (export modifier or export-list target).
  198. * @param prefix - the namespace qualification for surface names ('' at top level).
  199. * @param overloadSigs - names in this scope declared as bodyless function overload signatures.
  200. * @param byName - this scope's named declarations (for namespace/sibling-merge lookups).
  201. * @param w - the walk state violations append to.
  202. */
  203. function checkDecl(
  204. stmt: ts.Statement,
  205. prefix: string,
  206. overloadSigs: Set<string>,
  207. byName: Map<string, ts.Statement[]>,
  208. w: Walk,
  209. ): void {
  210. const at = (n: ts.Node): string => ` (${pointer(w.rel, w.sf, n)})`
  211. if (ts.isFunctionDeclaration(stmt)) {
  212. const name = stmt.name?.text ?? 'default'
  213. if (prefix === '' && PROTOCOL_EXPORTS.has(name)) return // cordis plugin-protocol slot
  214. if (stmt.body && overloadSigs.has(name)) return // overload implementation: the signatures carry the docs
  215. checkFunctionLike(`exported function '${prefix}${name}'${at(stmt)}`, rawJsDoc(w.text, stmt),
  216. stmt.parameters, stmt.type, false, w)
  217. return
  218. }
  219. if (ts.isClassDeclaration(stmt)) {
  220. checkClass(stmt, `${prefix}${stmt.name?.text ?? 'default'}`, w)
  221. return
  222. }
  223. if (ts.isInterfaceDeclaration(stmt)) {
  224. checkDescribed(`exported interface '${prefix}${stmt.name.text}'${at(stmt)}`, rawJsDoc(w.text, stmt), w)
  225. return
  226. }
  227. if (ts.isTypeAliasDeclaration(stmt)) {
  228. checkDescribed(`exported type '${prefix}${stmt.name.text}'${at(stmt)}`, rawJsDoc(w.text, stmt), w)
  229. return
  230. }
  231. if (ts.isEnumDeclaration(stmt)) {
  232. checkDescribed(`exported enum '${prefix}${stmt.name.text}'${at(stmt)}`, rawJsDoc(w.text, stmt), w)
  233. return
  234. }
  235. if (ts.isVariableStatement(stmt)) {
  236. const raw = rawJsDoc(w.text, stmt) // JSDoc sits on the statement, not the declarator
  237. for (const d of stmt.declarationList.declarations) {
  238. const name = ts.isIdentifier(d.name) ? d.name.text : d.name.getText(w.sf)
  239. if (prefix === '' && PROTOCOL_EXPORTS.has(name)) continue // cordis plugin-protocol slot
  240. const where = `exported const '${prefix}${name}'${at(d)}`
  241. const init = d.initializer
  242. if (init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init))) {
  243. // A declarator type annotation (`const f: Handler = …`) hands the
  244. // return contract to the named type; the arrow's own annotation is
  245. // still checked when it is the only signature the reader has.
  246. checkFunctionLike(where, raw, init.parameters, init.type, init.type === undefined && d.type !== undefined, w)
  247. } else {
  248. checkDescribed(where, raw, w)
  249. }
  250. }
  251. return
  252. }
  253. if (ts.isModuleDeclaration(stmt) && ts.isIdentifier(stmt.name)) {
  254. // A namespace merging with a documented same-name sibling (the
  255. // Config-namespace idiom) needs no second doc block of its own.
  256. const siblings = (byName.get(stmt.name.text) ?? []).filter(s => s !== stmt)
  257. const merged = siblings.some(s => parseJsDoc(rawJsDoc(w.text, s)).doc !== '')
  258. if (!merged) checkDescribed(`exported namespace '${prefix}${stmt.name.text}'${at(stmt)}`, rawJsDoc(w.text, stmt), w)
  259. let body = stmt.body
  260. let nsPrefix = `${prefix}${stmt.name.text}.`
  261. while (body !== undefined && ts.isModuleDeclaration(body)) { // dotted `namespace A.B`
  262. nsPrefix += `${body.name.getText(w.sf)}.`
  263. body = body.body
  264. }
  265. if (body !== undefined && ts.isModuleBlock(body)) checkScope(body.statements, nsPrefix, w)
  266. }
  267. }
  268. /**
  269. * Walk one lexical scope (file top level or a namespace body): check every
  270. * exported declaration, resolving `export { … }` lists (no module specifier)
  271. * to their local declarations.
  272. * @param statements - the scope's statements.
  273. * @param prefix - the namespace qualification for surface names ('' at top level).
  274. * @param w - the walk state violations append to.
  275. */
  276. function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk): void {
  277. const byName = new Map<string, ts.Statement[]>()
  278. const overloadSigs = new Set<string>()
  279. const add = (name: string, stmt: ts.Statement): void => {
  280. byName.set(name, [...(byName.get(name) ?? []), stmt])
  281. }
  282. for (const stmt of statements) {
  283. if (ts.isFunctionDeclaration(stmt)) {
  284. if (stmt.name) add(stmt.name.text, stmt)
  285. if (!stmt.body && stmt.name) overloadSigs.add(stmt.name.text)
  286. } else if (ts.isClassDeclaration(stmt) || ts.isInterfaceDeclaration(stmt)
  287. || ts.isTypeAliasDeclaration(stmt) || ts.isEnumDeclaration(stmt)) {
  288. if (stmt.name) add(stmt.name.text, stmt)
  289. } else if (ts.isModuleDeclaration(stmt) && ts.isIdentifier(stmt.name)) {
  290. add(stmt.name.text, stmt)
  291. } else if (ts.isVariableStatement(stmt)) {
  292. for (const d of stmt.declarationList.declarations) {
  293. if (ts.isIdentifier(d.name)) add(d.name.text, stmt)
  294. }
  295. }
  296. }
  297. const checked = new Set<ts.Statement>()
  298. const check = (stmt: ts.Statement): void => {
  299. if (checked.has(stmt)) return
  300. checked.add(stmt)
  301. checkDecl(stmt, prefix, overloadSigs, byName, w)
  302. }
  303. for (const stmt of statements) {
  304. if (ts.isModuleDeclaration(stmt)
  305. && (ts.isStringLiteral(stmt.name) || (stmt.flags & ts.NodeFlags.GlobalAugmentation) !== 0)) {
  306. continue // `declare module '…'` / `declare global` augmentation: not an export of this package
  307. }
  308. if (ts.isExportDeclaration(stmt)) {
  309. if (stmt.moduleSpecifier) continue // re-export: the defining module is walked on its own
  310. if (stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
  311. for (const el of stmt.exportClause.elements) {
  312. for (const decl of byName.get((el.propertyName ?? el.name).text) ?? []) check(decl)
  313. // a name with no local declaration is an imported binding re-exported
  314. // without a specifier — its defining module is walked on its own
  315. }
  316. }
  317. continue
  318. }
  319. if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) {
  320. if (ts.isIdentifier(stmt.expression)) {
  321. for (const decl of byName.get(stmt.expression.text) ?? []) check(decl)
  322. } else {
  323. checkDescribed(`default export (${pointer(w.rel, w.sf, stmt)})`, rawJsDoc(w.text, stmt), w)
  324. }
  325. continue
  326. }
  327. if (isExported(stmt)) check(stmt)
  328. }
  329. }
  330. /**
  331. * Compiler options for the walk's program. The real repo hands over its
  332. * tsconfig.base.json (whose `paths` map resolves cross-package imports to
  333. * source, so heritage-member lookups see seam types); a fixture root without
  334. * one gets bare defaults — fixtures are single-file and self-contained.
  335. * Emit-side options are stripped: the walk never emits or asks for
  336. * diagnostics, it only binds types on demand.
  337. * @param scanRoot - the root being scanned.
  338. * @returns compiler options for ts.createProgram.
  339. */
  340. function loadCompilerOptions(scanRoot: string): ts.CompilerOptions {
  341. const cfgPath = resolve(scanRoot, 'tsconfig.base.json')
  342. if (!existsSync(cfgPath)) return { skipLibCheck: true }
  343. const cfg = ts.readConfigFile(cfgPath, ts.sys.readFile.bind(ts.sys)) as { config?: unknown }
  344. const parsed = ts.parseJsonConfigFileContent(cfg.config ?? {}, ts.sys, scanRoot)
  345. return {
  346. ...parsed.options,
  347. noEmit: true,
  348. composite: false,
  349. declaration: false,
  350. declarationMap: false,
  351. sourceMap: false,
  352. incremental: false,
  353. }
  354. }
  355. /**
  356. * Walk every non-vendored package source file and collect JSDoc-completeness
  357. * violations for its module-level exports. Returns findings instead of
  358. * throwing so tests assert on the list; the CLI entry turns a non-empty list
  359. * into exit 1.
  360. * @param scanRoot - the repo root to scan; tests pass a fixture dir.
  361. * @returns every violation, in file order, one human-readable line each.
  362. */
  363. export function collectExportJsdocViolations(scanRoot: string = root): string[] {
  364. const violations: string[] = []
  365. const rels = globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).sort()
  366. const program = ts.createProgram(rels.map(rel => resolve(scanRoot, rel)), loadCompilerOptions(scanRoot))
  367. const checker = program.getTypeChecker()
  368. for (const rel of rels) {
  369. const sf = program.getSourceFile(resolve(scanRoot, rel))
  370. if (!sf) continue // program root files always resolve; guard for narrowing
  371. checkScope(sf.statements, '', { rel, sf, text: sf.text, checker, violations })
  372. }
  373. return violations
  374. }
  375. /** CLI entry: list every violation and exit 1, or confirm a clean surface. */
  376. function main(): void {
  377. const violations = collectExportJsdocViolations()
  378. if (violations.length === 0) {
  379. console.log('verify-export-jsdoc: every exported name on the package surface is documented.')
  380. return
  381. }
  382. console.error(`verify-export-jsdoc: ${violations.length} JSDoc completeness violation(s) (see AGENTS.md):`)
  383. for (const v of violations) console.error(` ${v}`)
  384. process.exit(1)
  385. }
  386. // Run only when invoked as a script, not when imported by a test.
  387. if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
  388. main()
  389. }