verify-export-jsdoc.ts 26 KB

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