verify-export-jsdoc.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557
  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, a const with a function
  17. * initializer or an INLINE callable annotation, or a non-identifier
  18. * function default export) additionally needs a non-empty `@param` per
  19. * parameter (`this` receiver annotations exempt; a stale `@param` errors)
  20. * and a non-empty `@returns` unless the return type is `void` /
  21. * `Promise<void>`. Wrapper expressions (parentheses, `as` / `satisfies`
  22. * casts, non-null assertions) are peeled before classifying. The walk
  23. * classifies returns syntactically, so the return type must be ANNOTATED —
  24. * except a const whose declarator is annotated with a NAMED type (e.g.
  25. * `export const f: Handler = …`), where that type's own declaration owns
  26. * the signature contract and `@returns` stays optional; an inline
  27. * `(x: T) => U` annotation or single-call-signature literal is the surface
  28. * signature itself and gets the full contract, and a literal mixing
  29. * call/construct signatures with anything else is refused (extract a named
  30. * type).
  31. * - An exported class needs class-level JSDoc; its public methods (static
  32. * included — they are reachable on the exported name) follow the function
  33. * contract, and public properties and accessors need description prose (on
  34. * a get/set pair the getter's doc covers both). A member declared by an
  35. * `extends`/`implements` heritage type is EXEMPT — the seam declaration is
  36. * the doc's one home, the IDE inherits it, and re-documenting every
  37. * implementation invites drift — UNLESS the override grows surface the
  38. * base never documented: a protected-only base member does not exempt a
  39. * public override, and parameters the base never names keep their `@param`
  40. * duty. This is the one question the walk asks the TYPE CHECKER (heritage
  41. * members live across package boundaries); everything else is pure AST.
  42. * Constructors are exempt like the cordis gate's: plugin classes are
  43. * framework-constructed, and the class doc owns the story.
  44. * - Exported interfaces, type aliases, enums: description prose on the
  45. * declaration (member-level docs stay review's job; the highest-value
  46. * member surface — seam service classes — is already under the cordis
  47. * gate).
  48. * - An exported namespace recurses (its exported members are package
  49. * surface; in an ambient `declare` namespace every member exports
  50. * implicitly); the namespace itself needs prose only when it does not
  51. * merge with an already-documented same-name declaration (the
  52. * Config-namespace idiom documents the class/function once, not twice).
  53. * - The cordis plugin-protocol slots are exempt: top-level `name` / `inject`
  54. * / `reusable` / `Config` consts and the `apply` entry, plus the same
  55. * slots as statics on a plugin class. Their shape is fixed by the
  56. * framework, so a doc would restate the protocol — the module doc comment
  57. * and the `interface Config` carry the plugin's real semantics. (These
  58. * names are reserved by cordis convention; documenting one anyway is
  59. * allowed, only absence goes unchecked.)
  60. * - Overload groups: each overload signature carries its own docs; the
  61. * implementation signature is exempt (callers never see it).
  62. * - Skipped: `declare module` / `declare global` augmentation bodies (the
  63. * cordis gate's turf; an augmentation is not an export of the package) and
  64. * re-export statements with a module specifier (`export … from`) — the
  65. * defining module is walked on its own, and external definitions are not
  66. * ours to document. An `export import X = N.member` alias documents
  67. * ITSELF (its target may be a non-exported namespace member no walk
  68. * visits, so a skip would fail open).
  69. * - Everything else fails CLOSED: `export =` is refused outright, and an
  70. * exported statement kind the dispatch does not recognize is itself a
  71. * violation, so no export form can pass unchecked by omission.
  72. */
  73. import { existsSync, globSync } from 'node:fs'
  74. import { resolve } from 'node:path'
  75. import ts from 'typescript'
  76. import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc } from './jsdoc.ts'
  77. const root = resolve(import.meta.dirname, '..')
  78. /** Plugin-protocol slot names exempt as statics on an exported class. */
  79. const PROTOCOL_STATICS = new Set(['Config', 'inject', 'name', 'reusable'])
  80. /** Plugin-protocol slot names exempt as top-level exports (const or function). */
  81. const PROTOCOL_EXPORTS = new Set(['Config', 'inject', 'name', 'reusable', 'apply'])
  82. /** Per-file walk state threaded through the scope recursion. */
  83. interface Walk {
  84. /** Repo-relative path of the file being walked. */
  85. rel: string
  86. /** The parsed source file. */
  87. sf: ts.SourceFile
  88. /** Raw file text (rawJsDoc reads comment ranges out of it). */
  89. text: string
  90. /** The program's checker, consulted only for heritage-member lookups. */
  91. checker: ts.TypeChecker
  92. /** The aggregate violation list, appended in place. */
  93. violations: string[]
  94. }
  95. /** True when a statement carries the `export` modifier. */
  96. function isExported(stmt: ts.Statement): boolean {
  97. return ts.canHaveModifiers(stmt) && (ts.getModifiers(stmt)?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) ?? false)
  98. }
  99. /** True for a class member a consumer cannot reach: `private`/`protected`/`#name`. */
  100. function isNonPublic(member: ts.ClassElement): boolean {
  101. const mods = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined
  102. return (mods?.some(m => m.kind === ts.SyntaxKind.PrivateKeyword || m.kind === ts.SyntaxKind.ProtectedKeyword) ?? false)
  103. || ('name' in member && ts.isPrivateIdentifier(member.name))
  104. }
  105. /** True when a class member carries the `static` modifier. */
  106. function isStatic(member: ts.ClassElement): boolean {
  107. const mods = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined
  108. return mods?.some(m => m.kind === ts.SyntaxKind.StaticKeyword) ?? false
  109. }
  110. /** The `this`-receiver exemption every function-like check shares. */
  111. function thisReceiver(p: ts.ParameterDeclaration): boolean {
  112. return ts.isIdentifier(p.name) && p.name.text === 'this'
  113. }
  114. /**
  115. * Peel wrapper expressions that carry no surface of their own — parentheses,
  116. * `as` / `satisfies` / angle-bracket casts, non-null assertions — so a
  117. * wrapped function expression is still classified as function-like.
  118. * @param e - the expression to unwrap.
  119. * @returns the innermost non-wrapper expression.
  120. */
  121. function unwrapExpression(e: ts.Expression): ts.Expression {
  122. let inner = e
  123. while (
  124. ts.isParenthesizedExpression(inner) || ts.isAsExpression(inner) || ts.isSatisfiesExpression(inner)
  125. || ts.isNonNullExpression(inner) || ts.isTypeAssertionExpression(inner)
  126. ) inner = inner.expression
  127. return inner
  128. }
  129. /**
  130. * Classify a declarator's type annotation for the function contract: an
  131. * inline function type or a type literal that is EXACTLY one call signature
  132. * is the surface signature itself; a literal mixing call/construct
  133. * signatures with anything else cannot be classified syntactically and is
  134. * refused (fail closed — extract a named type); everything else is a plain
  135. * value shape.
  136. * @param type - the declarator's type annotation.
  137. * @returns the signature to check, 'refuse' for an unclassifiable callable literal, or null for a non-callable shape.
  138. */
  139. function callableAnnotation(type: ts.TypeNode): ts.SignatureDeclarationBase | 'refuse' | null {
  140. if (ts.isFunctionTypeNode(type)) return type
  141. if (!ts.isTypeLiteralNode(type)) return null
  142. const signatures = type.members.filter(m => ts.isCallSignatureDeclaration(m) || ts.isConstructSignatureDeclaration(m))
  143. if (signatures.length === 0) return null
  144. if (signatures.length === 1 && type.members.length === 1 && signatures[0] !== undefined && ts.isCallSignatureDeclaration(signatures[0])) {
  145. return signatures[0]
  146. }
  147. return 'refuse'
  148. }
  149. /**
  150. * The heritage-member exemption for one class member. When the member's name
  151. * is declared by an `extends`/`implements` heritage type, the seam declaration
  152. * is the doc's one home (the IDE inherits it on hover) and the member needs no
  153. * doc of its own — EXCEPT where the override grows public surface the base
  154. * never documented: a base member that is protected on every declaration does
  155. * not exempt a public override (consumers could not call it before), and
  156. * parameters the base never names keep their own `@param` duty (the caller
  157. * reads the seam doc, which cannot describe them; an underscore-prefixed
  158. * rename of a base parameter — the deliberately-unused marker — is the same
  159. * parameter, not new surface). Static members are looked
  160. * up on the base CONSTRUCTOR type (only an `extends` expression has one; an
  161. * unresolvable or interface expression yields no property and therefore no
  162. * exemption).
  163. * @param cls - the class whose heritage to search.
  164. * @param name - the member name to look up.
  165. * @param staticSide - whether to search the constructor side instead of the instance side.
  166. * @param checker - the program's type checker.
  167. * @returns null when no exemption applies; otherwise the parameter names the
  168. * base declarations carry (`baseParams: null` means the base's parameters are
  169. * not syntactically recoverable — a complex heritage type — and the member is
  170. * exempt in full).
  171. */
  172. function heritageExemption(
  173. cls: ts.ClassDeclaration,
  174. name: string,
  175. staticSide: boolean,
  176. checker: ts.TypeChecker,
  177. ): { baseParams: Set<string> | null } | null {
  178. const isProtected = (d: ts.Declaration): boolean =>
  179. (ts.canHaveModifiers(d) ? ts.getModifiers(d) : undefined)?.some(m => m.kind === ts.SyntaxKind.ProtectedKeyword) ?? false
  180. for (const clause of cls.heritageClauses ?? []) {
  181. for (const t of clause.types) {
  182. const type = staticSide ? checker.getTypeAtLocation(t.expression) : checker.getTypeAtLocation(t)
  183. const prop = type.getProperty(name)
  184. if (prop === undefined) continue
  185. const decls = prop.declarations ?? []
  186. if (decls.length > 0 && decls.every(isProtected)) continue // public override of a protected base: new surface
  187. let baseParams: Set<string> | null = null
  188. for (const d of decls) {
  189. let params: readonly ts.ParameterDeclaration[] | undefined
  190. if (ts.isMethodDeclaration(d) || ts.isMethodSignature(d)) params = d.parameters
  191. else if ((ts.isPropertySignature(d) || ts.isPropertyDeclaration(d)) && d.type !== undefined && ts.isFunctionTypeNode(d.type)) {
  192. params = d.type.parameters
  193. } else continue
  194. baseParams ??= new Set()
  195. // Leading underscores are the deliberately-unused marker (eslint
  196. // argsIgnorePattern), not a rename: `_cwd` overriding `cwd` is the
  197. // same parameter, so compare underscore-stripped on both sides.
  198. for (const p of params) if (ts.isIdentifier(p.name)) baseParams.add(p.name.text.replace(/^_+/, ''))
  199. }
  200. return { baseParams }
  201. }
  202. }
  203. return null
  204. }
  205. /**
  206. * Check description-prose presence for one labeled declaration: JSDoc must
  207. * exist and carry prose above its block tags.
  208. * @param where - the offender label violations open with.
  209. * @param raw - the declaration's raw JSDoc block ('' if none).
  210. * @param w - the walk state violations append to.
  211. */
  212. function checkDescribed(where: string, raw: string, w: Walk): void {
  213. if (!raw) w.violations.push(`${where} has no JSDoc.`)
  214. else if (!parseJsDoc(raw).doc) w.violations.push(`${where} has no description prose above its block tags.`)
  215. }
  216. /**
  217. * Check the full function contract for one labeled function-like declaration:
  218. * description prose, `@param` per parameter, `@returns` on a non-void result.
  219. * @param where - the offender label violations open with.
  220. * @param raw - the declaration's raw JSDoc block ('' if none).
  221. * @param parameters - the declaration's parameter list.
  222. * @param returnType - the return type annotation, or undefined when inferred.
  223. * @param returnsWaived - suppress the `@returns`/annotation requirement (a
  224. * declarator-annotated const defers its return contract to the named type).
  225. * @param w - the walk state violations append to.
  226. */
  227. function checkFunctionLike(
  228. where: string,
  229. raw: string,
  230. parameters: readonly ts.ParameterDeclaration[],
  231. returnType: ts.TypeNode | undefined,
  232. returnsWaived: boolean,
  233. w: Walk,
  234. ): void {
  235. if (!raw) { w.violations.push(`${where} has no JSDoc.`); return }
  236. if (!parseJsDoc(raw).doc) w.violations.push(`${where} has no description prose above its block tags.`)
  237. const { params, returns } = parseTags(raw)
  238. checkParams(where, 'export', parameters, params, w.sf, thisReceiver, w.violations)
  239. if (!returnsWaived) checkReturns(where, returnType, returns, w.sf, w.violations)
  240. }
  241. /**
  242. * Check one exported class: class-level prose, the function contract on every
  243. * public method (overload implementations exempt), and description prose on
  244. * public properties and accessors (a get/set pair is covered by the getter's
  245. * doc). Heritage-declared members are exempt per heritageExemption (an
  246. * override's extra parameters keep their @param duty); plugin-protocol
  247. * statics are exempt; constructors are not checked (framework-constructed
  248. * plugins, and the class doc owns the story).
  249. * @param cls - the exported class declaration.
  250. * @param name - the class's surface name (namespace-qualified).
  251. * @param w - the walk state violations append to.
  252. */
  253. function checkClass(cls: ts.ClassDeclaration, name: string, w: Walk): void {
  254. checkDescribed(`exported class '${name}' (${pointer(w.rel, w.sf, cls)})`, rawJsDoc(w.text, cls), w)
  255. const overloadSigs = new Set<string>()
  256. const documentedGetters = new Set<string>()
  257. for (const m of cls.members) {
  258. if ('name' in m && ts.isComputedPropertyName(m.name)) continue
  259. if (ts.isMethodDeclaration(m) && !m.body) overloadSigs.add(m.name.getText(w.sf))
  260. if (ts.isGetAccessorDeclaration(m)) documentedGetters.add(m.name.getText(w.sf))
  261. }
  262. for (const m of cls.members) {
  263. if (isNonPublic(m) || ts.isConstructorDeclaration(m)) continue
  264. if (!('name' in m) || ts.isComputedPropertyName(m.name)) continue // computed/symbol members
  265. const mname = m.name.getText(w.sf)
  266. if (isStatic(m) && PROTOCOL_STATICS.has(mname)) continue // cordis plugin-protocol slot
  267. const exemption = heritageExemption(cls, mname, isStatic(m), w.checker)
  268. if (ts.isMethodDeclaration(m)) {
  269. if (m.body && overloadSigs.has(mname)) continue // overload implementation: the signatures carry the docs
  270. const where = `exported class method '${name}.${mname}' (${pointer(w.rel, w.sf, m)})`
  271. if (exemption !== null) {
  272. // The heritage declaration owns prose and @returns; parameters the
  273. // base never names — including binding patterns, which no base
  274. // declaration can name — are new surface and keep their @param duty.
  275. const base = exemption.baseParams
  276. const inBase = (p: ts.ParameterDeclaration): boolean =>
  277. base !== null && ts.isIdentifier(p.name) && base.has(p.name.text.replace(/^_+/, ''))
  278. if (base !== null && m.parameters.some(p => !thisReceiver(p) && !inBase(p))) {
  279. const { params } = parseTags(rawJsDoc(w.text, m))
  280. checkParams(where, 'export', m.parameters, params, w.sf,
  281. p => thisReceiver(p) || inBase(p), w.violations)
  282. }
  283. continue
  284. }
  285. checkFunctionLike(where, rawJsDoc(w.text, m), m.parameters, m.type, false, w)
  286. } else if (exemption !== null) {
  287. continue // the heritage declaration owns the doc (properties/accessors carry no own parameters)
  288. } else if (ts.isGetAccessorDeclaration(m) || ts.isPropertyDeclaration(m)) {
  289. const kind = ts.isPropertyDeclaration(m) ? 'property' : 'accessor'
  290. checkDescribed(`exported class ${kind} '${name}.${mname}' (${pointer(w.rel, w.sf, m)})`, rawJsDoc(w.text, m), w)
  291. } else if (ts.isSetAccessorDeclaration(m) && !documentedGetters.has(mname)) {
  292. checkDescribed(`exported class accessor '${name}.${mname}' (${pointer(w.rel, w.sf, m)})`, rawJsDoc(w.text, m), w)
  293. }
  294. // index signatures / static blocks: not named surface
  295. }
  296. }
  297. /**
  298. * Check one exported declaration statement, dispatching on its kind. Any
  299. * exported statement kind the dispatch does not recognize is a violation
  300. * (fail closed), so no export form can pass unchecked by omission.
  301. * @param stmt - the exported statement (export modifier or export-list target).
  302. * @param prefix - the namespace qualification for surface names ('' at top level).
  303. * @param overloadSigs - names in this scope declared as bodyless function overload signatures.
  304. * @param byName - this scope's named declarations (for namespace/sibling-merge lookups).
  305. * @param ambient - whether the enclosing scope is ambient (`declare`), where members export implicitly.
  306. * @param w - the walk state violations append to.
  307. */
  308. function checkDecl(
  309. stmt: ts.Statement,
  310. prefix: string,
  311. overloadSigs: Set<string>,
  312. byName: Map<string, ts.Statement[]>,
  313. ambient: boolean,
  314. w: Walk,
  315. ): void {
  316. const at = (n: ts.Node): string => ` (${pointer(w.rel, w.sf, n)})`
  317. if (ts.isFunctionDeclaration(stmt)) {
  318. const name = stmt.name?.text ?? 'default'
  319. if (prefix === '' && PROTOCOL_EXPORTS.has(name)) return // cordis plugin-protocol slot
  320. if (stmt.body && overloadSigs.has(name)) return // overload implementation: the signatures carry the docs
  321. checkFunctionLike(`exported function '${prefix}${name}'${at(stmt)}`, rawJsDoc(w.text, stmt),
  322. stmt.parameters, stmt.type, false, w)
  323. return
  324. }
  325. if (ts.isClassDeclaration(stmt)) {
  326. checkClass(stmt, `${prefix}${stmt.name?.text ?? 'default'}`, w)
  327. return
  328. }
  329. if (ts.isInterfaceDeclaration(stmt)) {
  330. checkDescribed(`exported interface '${prefix}${stmt.name.text}'${at(stmt)}`, rawJsDoc(w.text, stmt), w)
  331. return
  332. }
  333. if (ts.isTypeAliasDeclaration(stmt)) {
  334. checkDescribed(`exported type '${prefix}${stmt.name.text}'${at(stmt)}`, rawJsDoc(w.text, stmt), w)
  335. return
  336. }
  337. if (ts.isEnumDeclaration(stmt)) {
  338. checkDescribed(`exported enum '${prefix}${stmt.name.text}'${at(stmt)}`, rawJsDoc(w.text, stmt), w)
  339. return
  340. }
  341. if (ts.isVariableStatement(stmt)) {
  342. const raw = rawJsDoc(w.text, stmt) // JSDoc sits on the statement, not the declarator
  343. for (const d of stmt.declarationList.declarations) {
  344. const name = ts.isIdentifier(d.name) ? d.name.text : d.name.getText(w.sf)
  345. if (prefix === '' && PROTOCOL_EXPORTS.has(name)) continue // cordis plugin-protocol slot
  346. const where = `exported const '${prefix}${name}'${at(d)}`
  347. const annotation = d.type !== undefined ? callableAnnotation(d.type) : null
  348. const init = d.initializer !== undefined ? unwrapExpression(d.initializer) : undefined
  349. if (annotation === 'refuse') {
  350. // A literal mixing call/construct signatures with other members (or
  351. // overloading them) has no single signature the walk can hold the
  352. // tags against — fail closed rather than silently narrow the check.
  353. w.violations.push(`${where}: its callable type literal is not gate-classifiable; extract a named type and document it there.`)
  354. } else if (annotation !== null) {
  355. // An INLINE callable annotation is the surface signature itself: its
  356. // parameters and result need docs right here. (A NAMED reference
  357. // type carries its docs at the type's own declaration instead.)
  358. checkFunctionLike(where, raw, annotation.parameters, annotation.type, false, w)
  359. } else if (init !== undefined && (ts.isArrowFunction(init) || ts.isFunctionExpression(init))) {
  360. // A named declarator type annotation (`const f: Handler = …`) hands
  361. // the return contract to the named type; the arrow's own annotation is
  362. // still checked when it is the only signature the reader has.
  363. checkFunctionLike(where, raw, init.parameters, init.type, init.type === undefined && d.type !== undefined, w)
  364. } else {
  365. checkDescribed(where, raw, w)
  366. }
  367. }
  368. return
  369. }
  370. if (ts.isModuleDeclaration(stmt) && ts.isIdentifier(stmt.name)) {
  371. // A namespace merging with a documented same-name sibling (the
  372. // Config-namespace idiom) needs no second doc block of its own.
  373. const siblings = (byName.get(stmt.name.text) ?? []).filter(s => s !== stmt)
  374. const merged = siblings.some(s => parseJsDoc(rawJsDoc(w.text, s)).doc !== '')
  375. if (!merged) checkDescribed(`exported namespace '${prefix}${stmt.name.text}'${at(stmt)}`, rawJsDoc(w.text, stmt), w)
  376. let body = stmt.body
  377. let nsPrefix = `${prefix}${stmt.name.text}.`
  378. while (body !== undefined && ts.isModuleDeclaration(body)) { // dotted `namespace A.B`
  379. nsPrefix += `${body.name.getText(w.sf)}.`
  380. body = body.body
  381. }
  382. // In an ambient (`declare`) namespace body, members are implicitly
  383. // exported — no `export` modifier required — so the recursion must treat
  384. // every statement as surface.
  385. const declared = ambient
  386. || ((ts.canHaveModifiers(stmt) ? ts.getModifiers(stmt) : undefined)?.some(m => m.kind === ts.SyntaxKind.DeclareKeyword) ?? false)
  387. if (body !== undefined && ts.isModuleBlock(body)) checkScope(body.statements, nsPrefix, w, declared)
  388. return
  389. }
  390. if (ts.isImportEqualsDeclaration(stmt)) {
  391. // An alias (`export import X = N.member`) is a distinct exported name and
  392. // its target may be a non-exported namespace member no walk ever visits,
  393. // so a blanket skip would fail open — the alias documents itself.
  394. checkDescribed(`exported alias '${prefix}${stmt.name.text}'${at(stmt)}`, rawJsDoc(w.text, stmt), w)
  395. return
  396. }
  397. // Fail CLOSED: an exported statement kind this dispatch does not recognize
  398. // must never pass silently — the gate's whole promise is that unchecked
  399. // surface cannot exist. New TypeScript export forms extend the gate here.
  400. w.violations.push(`exported statement${at(stmt)} uses an export form verify-export-jsdoc does not handle; extend the gate.`)
  401. }
  402. /**
  403. * Walk one lexical scope (file top level or a namespace body): check every
  404. * exported declaration, resolving `export { … }` lists (no module specifier)
  405. * to their local declarations.
  406. * @param statements - the scope's statements.
  407. * @param prefix - the namespace qualification for surface names ('' at top level).
  408. * @param w - the walk state violations append to.
  409. * @param ambient - whether this scope is ambient (`declare` namespace or a declaration file), where members export implicitly.
  410. */
  411. function checkScope(statements: readonly ts.Statement[], prefix: string, w: Walk, ambient: boolean): void {
  412. const byName = new Map<string, ts.Statement[]>()
  413. const overloadSigs = new Set<string>()
  414. const add = (name: string, stmt: ts.Statement): void => {
  415. byName.set(name, [...(byName.get(name) ?? []), stmt])
  416. }
  417. for (const stmt of statements) {
  418. if (ts.isFunctionDeclaration(stmt)) {
  419. if (stmt.name) add(stmt.name.text, stmt)
  420. if (!stmt.body && stmt.name) overloadSigs.add(stmt.name.text)
  421. } else if (ts.isClassDeclaration(stmt) || ts.isInterfaceDeclaration(stmt)
  422. || ts.isTypeAliasDeclaration(stmt) || ts.isEnumDeclaration(stmt)) {
  423. if (stmt.name) add(stmt.name.text, stmt)
  424. } else if (ts.isModuleDeclaration(stmt) && ts.isIdentifier(stmt.name)) {
  425. add(stmt.name.text, stmt)
  426. } else if (ts.isVariableStatement(stmt)) {
  427. for (const d of stmt.declarationList.declarations) {
  428. if (ts.isIdentifier(d.name)) add(d.name.text, stmt)
  429. }
  430. }
  431. }
  432. const checked = new Set<ts.Statement>()
  433. const check = (stmt: ts.Statement): void => {
  434. if (checked.has(stmt)) return
  435. checked.add(stmt)
  436. checkDecl(stmt, prefix, overloadSigs, byName, ambient, w)
  437. }
  438. for (const stmt of statements) {
  439. if (ts.isModuleDeclaration(stmt)
  440. && (ts.isStringLiteral(stmt.name) || (stmt.flags & ts.NodeFlags.GlobalAugmentation) !== 0)) {
  441. continue // `declare module '…'` / `declare global` augmentation: not an export of this package
  442. }
  443. if (ts.isExportDeclaration(stmt)) {
  444. if (stmt.moduleSpecifier) continue // re-export: the defining module is walked on its own
  445. if (stmt.exportClause && ts.isNamedExports(stmt.exportClause)) {
  446. for (const el of stmt.exportClause.elements) {
  447. for (const decl of byName.get((el.propertyName ?? el.name).text) ?? []) check(decl)
  448. // a name with no local declaration is an imported binding re-exported
  449. // without a specifier — its defining module is walked on its own
  450. }
  451. }
  452. continue
  453. }
  454. if (ts.isExportAssignment(stmt)) {
  455. if (stmt.isExportEquals) {
  456. // `export =` has no ESM consumer surface in this repo and the walk
  457. // cannot classify its operand's shape; refuse rather than fail open.
  458. w.violations.push(`export-equals assignment (${pointer(w.rel, w.sf, stmt)}) is not a gate-supported export form; use ESM named exports.`)
  459. continue
  460. }
  461. const where = `default export (${pointer(w.rel, w.sf, stmt)})`
  462. const expr = unwrapExpression(stmt.expression)
  463. if (ts.isIdentifier(expr)) {
  464. for (const decl of byName.get(expr.text) ?? []) check(decl)
  465. } else if (ts.isArrowFunction(expr) || ts.isFunctionExpression(expr)) {
  466. checkFunctionLike(where, rawJsDoc(w.text, stmt), expr.parameters, expr.type, false, w)
  467. } else {
  468. checkDescribed(where, rawJsDoc(w.text, stmt), w)
  469. }
  470. continue
  471. }
  472. if (isExported(stmt) || (ambient && !ts.isImportDeclaration(stmt))) check(stmt)
  473. }
  474. }
  475. /**
  476. * Compiler options for the walk's program. The real repo hands over its
  477. * tsconfig.base.json (whose `paths` map resolves cross-package imports to
  478. * source, so heritage-member lookups see seam types); a fixture root without
  479. * one gets bare defaults — fixtures are single-file and self-contained.
  480. * Emit-side options are stripped: the walk never emits or asks for
  481. * diagnostics, it only binds types on demand.
  482. * @param scanRoot - the root being scanned.
  483. * @returns compiler options for ts.createProgram.
  484. */
  485. function loadCompilerOptions(scanRoot: string): ts.CompilerOptions {
  486. const cfgPath = resolve(scanRoot, 'tsconfig.base.json')
  487. if (!existsSync(cfgPath)) return { skipLibCheck: true }
  488. const cfg = ts.readConfigFile(cfgPath, ts.sys.readFile.bind(ts.sys)) as { config?: unknown }
  489. const parsed = ts.parseJsonConfigFileContent(cfg.config ?? {}, ts.sys, scanRoot)
  490. return {
  491. ...parsed.options,
  492. noEmit: true,
  493. composite: false,
  494. declaration: false,
  495. declarationMap: false,
  496. sourceMap: false,
  497. incremental: false,
  498. }
  499. }
  500. /**
  501. * Walk every non-vendored package source file and collect JSDoc-completeness
  502. * violations for its module-level exports. Returns findings instead of
  503. * throwing so tests assert on the list; the CLI entry turns a non-empty list
  504. * into exit 1.
  505. * @param scanRoot - the repo root to scan; tests pass a fixture dir.
  506. * @returns every violation, in file order, one human-readable line each.
  507. */
  508. export function collectExportJsdocViolations(scanRoot: string = root): string[] {
  509. const violations: string[] = []
  510. const rels = globSync('packages/*/*/src/**/*.ts', { cwd: scanRoot }).sort()
  511. const program = ts.createProgram(rels.map(rel => resolve(scanRoot, rel)), loadCompilerOptions(scanRoot))
  512. const checker = program.getTypeChecker()
  513. for (const rel of rels) {
  514. const sf = program.getSourceFile(resolve(scanRoot, rel))
  515. if (!sf) continue // program root files always resolve; guard for narrowing
  516. // A script-style declaration file (no imports/exports) is one big ambient
  517. // scope; a module-style .d.ts still honors explicit export modifiers.
  518. checkScope(sf.statements, '', { rel, sf, text: sf.text, checker, violations }, sf.isDeclarationFile && !ts.isExternalModule(sf))
  519. }
  520. return violations
  521. }
  522. /** CLI entry: list every violation and exit 1, or confirm a clean surface. */
  523. function main(): void {
  524. const violations = collectExportJsdocViolations()
  525. if (violations.length === 0) {
  526. console.log('verify-export-jsdoc: every exported name on the package surface is documented.')
  527. return
  528. }
  529. console.error(`verify-export-jsdoc: ${violations.length} JSDoc completeness violation(s) (see AGENTS.md):`)
  530. for (const v of violations) console.error(` ${v}`)
  531. process.exit(1)
  532. }
  533. // Run only when invoked as a script, not when imported by a test.
  534. if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
  535. main()
  536. }