verify-export-jsdoc.spec.ts 20 KB

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