verify-export-jsdoc.spec.ts 19 KB

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