verify-export-jsdoc.spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561
  1. /**
  2. * Negative-path tests for the export-surface JSDoc gate (`scripts/verify-export-jsdoc.ts`).
  3. */
  4. import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  5. import { tmpdir } from 'node:os'
  6. import { dirname, join } from 'node:path'
  7. import { afterEach, describe, expect, it } from 'vitest'
  8. import { collectExportJsdocViolations } from '../../../../scripts/verify-export-jsdoc.ts'
  9. const roots: string[] = []
  10. afterEach(() => {
  11. while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
  12. })
  13. /** Write fixture files under `packages/group/fix/src/` and return the scan root. */
  14. function fixture(files: Record<string, string>): string {
  15. const root = mkdtempSync(join(tmpdir(), 'export-jsdoc-'))
  16. roots.push(root)
  17. for (const [rel, content] of Object.entries(files)) {
  18. const abs = join(root, 'packages', 'group', 'fix', 'src', rel)
  19. mkdirSync(dirname(abs), { recursive: true })
  20. writeFileSync(abs, content)
  21. }
  22. return root
  23. }
  24. /** Single-file fixture shorthand: the content becomes `src/index.ts`. */
  25. const make = (content: string): string => fixture({ 'index.ts': content })
  26. describe('verify-export-jsdoc functions and consts', () => {
  27. it('limits packages without src/* exports to declarations reachable from package entrypoints', () => {
  28. const root = fixture({
  29. 'index.ts': "export { publicFn } from './internal.ts'\n",
  30. 'internal.ts': `
  31. export function publicFn(value: string): string { return value }
  32. export function hiddenFn(value: string): string { return value }
  33. `,
  34. })
  35. writeFileSync(join(root, 'packages/group/fix/package.json'), JSON.stringify({
  36. exports: { '.': { types: './lib/types/index.d.ts', default: './lib/index.js' } },
  37. }))
  38. const violations = collectExportJsdocViolations(root)
  39. expect(violations).toHaveLength(1)
  40. expect(violations.every(violation => violation.includes('publicFn'))).toBe(true)
  41. })
  42. it('accepts a fully documented surface', () => {
  43. expect(collectExportJsdocViolations(make(`
  44. /**
  45. * Add one to a count.
  46. * @param n - the count to bump.
  47. * @returns the count plus one.
  48. */
  49. export function bump(n: number): number { return n + 1 }
  50. /**
  51. * Fire-and-forget (void needs no @returns).
  52. * @param flag - whether to arm.
  53. */
  54. export function poke(flag: boolean): void { void flag }
  55. /** The default retry budget. */
  56. export const RETRIES = 3
  57. /**
  58. * Halve a count.
  59. * @param n - the count to halve.
  60. * @returns the count halved.
  61. */
  62. export const halve = (n: number): number => n / 2
  63. `))).toEqual([])
  64. })
  65. it('flags an exported function with no JSDoc at all', () => {
  66. expect(collectExportJsdocViolations(make(
  67. 'export function bare(): void {}\n',
  68. ))).toEqual([expect.stringMatching(/exported function 'bare' .* has no JSDoc\./)])
  69. })
  70. it('flags a missing @param and a missing @returns', () => {
  71. const violations = collectExportJsdocViolations(make(
  72. '/** Docs without tags. */\nexport function f(x: number): number { return x }\n',
  73. ))
  74. expect(violations).toEqual([
  75. expect.stringMatching(/exported function 'f' .* is missing @param x\./),
  76. expect.stringMatching(/exported function 'f' .* is missing @returns \(return type: number\)\./),
  77. ])
  78. })
  79. it('flags an unannotated (inferred) return type', () => {
  80. expect(collectExportJsdocViolations(make(
  81. '/**\n * Docs.\n * @param x - value.\n */\nexport function f(x: number) { return x }\n',
  82. ))).toEqual([expect.stringMatching(/no return type annotation/)])
  83. })
  84. it('flags tags-only JSDoc with no description prose', () => {
  85. expect(collectExportJsdocViolations(make(
  86. '/**\n * @param x - value.\n */\nexport function f(x: number): void {}\n',
  87. ))).toEqual([expect.stringMatching(/no description prose above its block tags/)])
  88. })
  89. it('flags a stale @param and a binding-pattern parameter', () => {
  90. const violations = collectExportJsdocViolations(make(
  91. '/**\n * Docs.\n * @param ghost - not real.\n */\nexport function f({ a }: { a: number }): void {}\n',
  92. ))
  93. expect(violations).toEqual([
  94. expect.stringMatching(/parameter '\{ a \}' is a binding pattern; the export surface needs simple identifier parameters/),
  95. expect.stringMatching(/@param ghost does not match any parameter \(stale tag\?\)/),
  96. ])
  97. })
  98. it('exempts a `this` receiver annotation from @param', () => {
  99. expect(collectExportJsdocViolations(make(
  100. '/**\n * Docs.\n * @param x - value.\n */\nexport function f(this: object, x: number): void {}\n',
  101. ))).toEqual([])
  102. })
  103. it('waives @returns for a declarator-annotated const but not an unannotated one', () => {
  104. expect(collectExportJsdocViolations(make(`
  105. type Fn = (x: number) => number
  106. /**
  107. * Uses the named signature.
  108. * @param x - value.
  109. */
  110. export const good: Fn = x => x
  111. /**
  112. * No signature anywhere.
  113. * @param x - value.
  114. */
  115. export const bad = (x: number) => x
  116. `))).toEqual([expect.stringMatching(/exported const 'bad' .* has no return type annotation/)])
  117. })
  118. it('requires description prose on a non-function const', () => {
  119. expect(collectExportJsdocViolations(make(
  120. 'export const LIMIT = 10\n',
  121. ))).toEqual([expect.stringMatching(/exported const 'LIMIT' .* has no JSDoc\./)])
  122. })
  123. })
  124. describe('verify-export-jsdoc type-level exports', () => {
  125. it('requires description prose on interfaces, type aliases, and enums', () => {
  126. const violations = collectExportJsdocViolations(make(
  127. 'export interface I { a: number }\nexport type T = number\nexport enum E { A }\n',
  128. ))
  129. expect(violations).toEqual([
  130. expect.stringMatching(/exported interface 'I' .* has no JSDoc\./),
  131. expect.stringMatching(/exported type 'T' .* has no JSDoc\./),
  132. expect.stringMatching(/exported enum 'E' .* has no JSDoc\./),
  133. ])
  134. })
  135. it('skips `declare module` augmentation bodies (the cordis gate owns them)', () => {
  136. expect(collectExportJsdocViolations(make(
  137. "declare module 'cordis' {\n interface Events {\n 'fix/x'(): void\n }\n}\nexport {}\n",
  138. ))).toEqual([])
  139. })
  140. })
  141. describe('verify-export-jsdoc export forms', () => {
  142. it('resolves an `export { … }` list to the local declaration', () => {
  143. expect(collectExportJsdocViolations(make(
  144. 'function f(): void {}\nexport { f }\n',
  145. ))).toEqual([expect.stringMatching(/exported function 'f' .* has no JSDoc\./)])
  146. })
  147. it('does not treat a never-exported sibling declarator as surface', () => {
  148. // `export { publicValue }` resolves to the whole variable statement; only
  149. // the named declarator is surface — the gate must not demand JSDoc for
  150. // the private sibling sharing the statement.
  151. expect(collectExportJsdocViolations(make(
  152. '/** The public knob. */\nconst publicValue = 1, privateHelper = 2\nexport { publicValue }\nvoid privateHelper\n',
  153. ))).toEqual([])
  154. })
  155. it('unions declarators across multiple export lists over one statement', () => {
  156. // Two lists each name one declarator of the same undocumented statement:
  157. // both are surface (deduplicating on first resolution would drop `b`),
  158. // while the never-exported `c` stays out.
  159. const violations = collectExportJsdocViolations(make(
  160. 'const a = 1, b = 2, c = 3\nexport { a }\nexport { b }\nvoid c\n',
  161. ))
  162. expect(violations).toEqual([
  163. expect.stringMatching(/exported const 'a' .* has no JSDoc\./),
  164. expect.stringMatching(/exported const 'b' .* has no JSDoc\./),
  165. ])
  166. })
  167. it('scopes a default-export identifier to its own declarator', () => {
  168. // `export default` of an identifier reaches the statement through the
  169. // same name lookup as an export list; the sibling stays private.
  170. expect(collectExportJsdocViolations(make(
  171. '/** The app entry. */\nconst app = 1, scratch = 2\nexport default app\nvoid scratch\n',
  172. ))).toEqual([])
  173. })
  174. it('reports a re-exported module once, at its defining file', () => {
  175. const violations = collectExportJsdocViolations(fixture({
  176. 'index.ts': "export * from './other.ts'\n",
  177. 'other.ts': 'export function f(): void {}\n',
  178. }))
  179. expect(violations).toEqual([expect.stringMatching(/other\.ts:1\) has no JSDoc\./)])
  180. })
  181. it('exempts overload implementations when the signatures are documented', () => {
  182. expect(collectExportJsdocViolations(make(`
  183. /**
  184. * From a number.
  185. * @param x - the number.
  186. * @returns its text.
  187. */
  188. export function f(x: number): string
  189. /**
  190. * From a flag.
  191. * @param x - the flag.
  192. * @returns its text.
  193. */
  194. export function f(x: boolean): string
  195. export function f(x: number | boolean): string { return String(x) }
  196. `))).toEqual([])
  197. })
  198. })
  199. describe('verify-export-jsdoc classes', () => {
  200. it('flags an undocumented class, method, property, and accessor', () => {
  201. const violations = collectExportJsdocViolations(make(`
  202. export class C {
  203. state = 1
  204. get view(): number { return this.state }
  205. run(x: number): number { return x }
  206. }
  207. `))
  208. expect(violations).toEqual([
  209. expect.stringMatching(/exported class 'C' .* has no JSDoc\./),
  210. expect.stringMatching(/exported class property 'C.state' .* has no JSDoc\./),
  211. expect.stringMatching(/exported class accessor 'C.view' .* has no JSDoc\./),
  212. expect.stringMatching(/exported class method 'C.run' .* has no JSDoc\./),
  213. ])
  214. })
  215. it('exempts members declared by an extends/implements heritage type', () => {
  216. expect(collectExportJsdocViolations(make(`
  217. /** Seam. */
  218. export abstract class Base {
  219. /**
  220. * Do it.
  221. * @param x - input.
  222. * @returns output.
  223. */
  224. abstract run(x: number): number
  225. }
  226. /** Iface. */
  227. export interface Sized {
  228. /** Byte size. */
  229. size: number
  230. }
  231. /** Impl. */
  232. export class Impl extends Base implements Sized {
  233. size = 0
  234. run(x: number): number { return x }
  235. }
  236. `))).toEqual([])
  237. })
  238. it('skips private/protected/#private members and constructors', () => {
  239. expect(collectExportJsdocViolations(make(`
  240. /** Documented. */
  241. export class C {
  242. #secret = 1
  243. private hidden(): void {}
  244. protected hook(): void {}
  245. constructor(x: number) { void x }
  246. }
  247. `))).toEqual([])
  248. })
  249. it('exempts plugin-protocol statics but checks other statics', () => {
  250. const violations = collectExportJsdocViolations(make(`
  251. /** Plugin. */
  252. export class C {
  253. static Config = { a: 1 }
  254. static inject = ['bash']
  255. static reusable = true
  256. static other = 1
  257. }
  258. `))
  259. expect(violations).toEqual([expect.stringMatching(/exported class property 'C.other' .* has no JSDoc\./)])
  260. })
  261. it("covers a set accessor by the getter's doc", () => {
  262. expect(collectExportJsdocViolations(make(`
  263. /** Documented. */
  264. export class C {
  265. /** The current width. */
  266. get width(): number { return 1 }
  267. set width(_v: number) {}
  268. }
  269. `))).toEqual([])
  270. })
  271. })
  272. describe('verify-export-jsdoc plugin protocol and namespaces', () => {
  273. it('exempts top-level plugin-protocol exports', () => {
  274. expect(collectExportJsdocViolations(make(`
  275. export const name = 'fix'
  276. export const inject = ['bash']
  277. export const reusable = true
  278. export const Config = { parse: true }
  279. export function apply(): void {}
  280. `))).toEqual([])
  281. })
  282. it('recurses into namespaces with qualified names and honors the merge idiom', () => {
  283. const violations = collectExportJsdocViolations(make(`
  284. /** The plugin class. */
  285. export class Fix {}
  286. export namespace Fix {
  287. export interface Config { a: number }
  288. }
  289. export namespace Loose {
  290. export const x = 1
  291. }
  292. `))
  293. expect(violations).toEqual([
  294. expect.stringMatching(/exported interface 'Fix.Config' .* has no JSDoc\./),
  295. expect.stringMatching(/exported namespace 'Loose' .* has no JSDoc\./),
  296. expect.stringMatching(/exported const 'Loose.x' .* has no JSDoc\./),
  297. ])
  298. })
  299. })
  300. describe('verify-export-jsdoc fail-closed forms', () => {
  301. it('checks the function contract on a non-identifier default export', () => {
  302. expect(collectExportJsdocViolations(make(
  303. '/** Doubles. */\nexport default (x: number): number => x * 2\n',
  304. ))).toEqual([
  305. expect.stringMatching(/default export .* is missing @param x\./),
  306. expect.stringMatching(/default export .* is missing @returns \(return type: number\)\./),
  307. ])
  308. expect(collectExportJsdocViolations(make(
  309. '/**\n * Doubles.\n * @param x - the input.\n * @returns twice the input.\n */\nexport default (x: number): number => x * 2\n',
  310. ))).toEqual([])
  311. })
  312. it('treats an inline function-type annotation as the surface signature', () => {
  313. expect(collectExportJsdocViolations(make(
  314. '/** Maps a number. */\nexport declare const f: (x: number) => number\n',
  315. ))).toEqual([
  316. expect.stringMatching(/exported const 'f' .* is missing @param x\./),
  317. expect.stringMatching(/exported const 'f' .* is missing @returns \(return type: number\)\./),
  318. ])
  319. expect(collectExportJsdocViolations(make(
  320. '/**\n * Maps a number.\n * @param x - the input.\n * @returns the mapped value.\n */\nexport const f: (x: number) => number = v => v\n',
  321. ))).toEqual([])
  322. })
  323. it('recurses into an ambient declare namespace where members export implicitly', () => {
  324. expect(collectExportJsdocViolations(make(
  325. 'export declare namespace N {\n function f(x: number): number\n}\n',
  326. ))).toEqual([
  327. expect.stringMatching(/exported namespace 'N' .* has no JSDoc\./),
  328. expect.stringMatching(/exported function 'N.f' .* has no JSDoc\./),
  329. ])
  330. })
  331. it('requires an export-import alias to document itself (its target may be unwalked)', () => {
  332. expect(collectExportJsdocViolations(make(
  333. '/** Holder. */\nexport namespace N {\n /** The value. */\n export const x = 1\n}\nexport import y = N.x\n',
  334. ))).toEqual([expect.stringMatching(/exported alias 'y' .* has no JSDoc\./)])
  335. expect(collectExportJsdocViolations(make(
  336. 'namespace N {\n export const x = 1\n}\n/** Alias surfacing the internal counter. */\nexport import y = N.x\n',
  337. ))).toEqual([])
  338. })
  339. it('refuses an export-import alias to a callable, class, or namespace target', () => {
  340. const refusal = /exported alias 'g' .* aliases a callable, class, or namespace target/
  341. expect(collectExportJsdocViolations(make(
  342. 'namespace N {\n export function f(x: number): number { return x }\n}\n/** Alias. */\nexport import g = N.f\n',
  343. ))).toEqual([expect.stringMatching(refusal)])
  344. expect(collectExportJsdocViolations(make(
  345. 'namespace N {\n export class C {\n run(x: number): number { return x }\n }\n}\n/** Alias. */\nexport import g = N.C\n',
  346. ))).toEqual([expect.stringMatching(refusal)])
  347. expect(collectExportJsdocViolations(make(
  348. 'namespace N {\n export namespace Sub {\n export function f(x: number): number { return x }\n }\n}\n/** Alias. */\nexport import g = N.Sub\n',
  349. ))).toEqual([expect.stringMatching(refusal)])
  350. })
  351. it('classifies wrapped function initializers and default exports (parens, satisfies)', () => {
  352. expect(collectExportJsdocViolations(make(
  353. 'type Fn = (x: number) => number\n/** Wrapped. */\nexport const f = (((x: number): number => x)) satisfies Fn\n',
  354. ))).toEqual([
  355. expect.stringMatching(/exported const 'f' .* is missing @param x\./),
  356. expect.stringMatching(/exported const 'f' .* is missing @returns \(return type: number\)\./),
  357. ])
  358. expect(collectExportJsdocViolations(make(
  359. 'type Fn = (x: number) => number\n/** Wrapped. */\nexport default (((x: number): number => x * 2) satisfies Fn)\n',
  360. ))).toEqual([
  361. expect.stringMatching(/default export .* is missing @param x\./),
  362. expect.stringMatching(/default export .* is missing @returns \(return type: number\)\./),
  363. ])
  364. })
  365. it('treats a single-call-signature type literal as the surface signature', () => {
  366. expect(collectExportJsdocViolations(make(
  367. '/** Maps. */\nexport declare const f: { (x: number): number }\n',
  368. ))).toEqual([
  369. expect.stringMatching(/exported const 'f' .* is missing @param x\./),
  370. expect.stringMatching(/exported const 'f' .* is missing @returns \(return type: number\)\./),
  371. ])
  372. })
  373. it('refuses a hybrid callable type literal instead of narrowing the check', () => {
  374. expect(collectExportJsdocViolations(make(
  375. '/** Hybrid. */\nexport declare const f: { (x: number): number; flush: () => void }\n',
  376. ))).toEqual([expect.stringMatching(/exported const 'f'.*callable type literal is not gate-classifiable; extract a named type/)])
  377. })
  378. it('refuses an export-equals assignment instead of failing open', () => {
  379. expect(collectExportJsdocViolations(make(
  380. 'const x = 1\nexport = x\n',
  381. ))).toEqual([expect.stringMatching(/export-equals assignment .* is not a gate-supported export form/)])
  382. })
  383. })
  384. describe('verify-export-jsdoc heritage refinement', () => {
  385. it('requires @param for parameters the base member never names', () => {
  386. const violations = collectExportJsdocViolations(make(`
  387. /** Seam. */
  388. export abstract class Base {
  389. /**
  390. * Do it.
  391. * @param x - input.
  392. * @returns output.
  393. */
  394. abstract run(x: number): number
  395. }
  396. /** Impl. */
  397. export class Impl extends Base {
  398. override run(x: number, verbose?: boolean): number { return verbose ? x : -x }
  399. }
  400. `))
  401. expect(violations).toEqual([expect.stringMatching(/exported class method 'Impl.run' .* is missing @param verbose\./)])
  402. })
  403. it('does not exempt a public override of a protected-only base member', () => {
  404. expect(collectExportJsdocViolations(make(`
  405. /** Seam. */
  406. export abstract class Base {
  407. /** Subclass hook. */
  408. protected hook(): void {}
  409. }
  410. /** Impl. */
  411. export class Impl extends Base {
  412. override hook(): void {}
  413. }
  414. `))).toEqual([expect.stringMatching(/exported class method 'Impl.hook' .* has no JSDoc\./)])
  415. })
  416. it('treats an underscore-prefixed rename of a base parameter as the same parameter', () => {
  417. expect(collectExportJsdocViolations(make(`
  418. /** Seam. */
  419. export abstract class Base {
  420. /**
  421. * Load it.
  422. * @param cwd - the working directory to scope the lookup.
  423. * @returns the loaded value.
  424. */
  425. abstract load(cwd: string): number
  426. }
  427. /** Impl (ignores cwd). */
  428. export class Impl extends Base {
  429. load(_cwd: string): number { return 1 }
  430. }
  431. `))).toEqual([])
  432. })
  433. it('flags a binding-pattern parameter an override adds beyond the base', () => {
  434. expect(collectExportJsdocViolations(make(`
  435. /** Seam. */
  436. export abstract class Base {
  437. /**
  438. * Do it.
  439. * @param x - input.
  440. * @returns output.
  441. */
  442. abstract run(x: number): number
  443. }
  444. /** Impl. */
  445. export class Impl extends Base {
  446. override run(x: number, { verbose }: { verbose?: boolean } = {}): number { return verbose ? x : -x }
  447. }
  448. `))).toEqual([expect.stringMatching(/exported class method 'Impl.run' .* is a binding pattern/)])
  449. })
  450. it('revives the @returns duty when an override grows a concrete result over a void base', () => {
  451. const voidBase = `
  452. /** Seam. */
  453. export abstract class Base {
  454. /** Do it (fire-and-forget). */
  455. abstract run(): void
  456. }
  457. `
  458. expect(collectExportJsdocViolations(make(`${voidBase}
  459. /** Impl. */
  460. export class Impl extends Base {
  461. override run(): number { return 1 }
  462. }
  463. `))).toEqual([expect.stringMatching(/exported class method 'Impl.run' .* is missing @returns \(return type: number\)\./)])
  464. expect(collectExportJsdocViolations(make(`${voidBase}
  465. /** Impl. */
  466. export class Impl extends Base {
  467. /**
  468. * Do it and count.
  469. * @returns how many were done.
  470. */
  471. override run(): number { return 1 }
  472. }
  473. `))).toEqual([])
  474. })
  475. it('classifies an unannotated override return over a void base via the checker', () => {
  476. const voidBase = `
  477. /** Seam. */
  478. export abstract class Base {
  479. /** Do it (fire-and-forget). */
  480. abstract run(): void
  481. }
  482. `
  483. expect(collectExportJsdocViolations(make(`${voidBase}
  484. /** Impl. */
  485. export class Impl extends Base {
  486. override run() { return 1 }
  487. }
  488. `))).toEqual([expect.stringMatching(/exported class method 'Impl.run' .* non-void result its heritage declaration does not document/)])
  489. expect(collectExportJsdocViolations(make(`${voidBase}
  490. /** Impl (faithful void, no annotation needed). */
  491. export class Impl extends Base {
  492. override run() {}
  493. }
  494. `))).toEqual([])
  495. })
  496. it('keeps the full exemption when the base return already carries the @returns duty', () => {
  497. expect(collectExportJsdocViolations(make(`
  498. /** Seam. */
  499. export abstract class Base {
  500. /**
  501. * Count things.
  502. * @returns the count.
  503. */
  504. abstract run(): number
  505. }
  506. /** Impl. */
  507. export class Impl extends Base {
  508. override run(): number { return 1 }
  509. }
  510. `))).toEqual([])
  511. })
  512. })