Files
deepseek-harness/packages/core/agent/tests/verify-export-jsdoc.spec.ts
T
Tianyi Cui cd9737d569 Gate JSDoc completeness on every package export
New doc-sync gate verify-export-jsdoc walks every module-level exported
name under packages/*/*/src and requires description prose everywhere,
plus @param per parameter and @returns on non-void annotated returns for
function-like exports, public class methods, properties, and accessors.
The parsing + check helpers move out of gen-cordis-catalog.ts into a
shared scripts/jsdoc.ts so 'documented' means one thing on both gated
surfaces.

Deliberate exemptions (documented in the RFC): heritage-declared class
members (the seam declaration is the doc's one home — the one checker
query in an otherwise pure-AST walk), cordis plugin-protocol slots
(name/inject/reusable/Config/apply, top-level and static), constructors,
overload implementations, declare-module augmentation bodies, and
re-export statements (checked at the defining module).

The 203 under-documented exports the gate found at adoption are filled
in this change, so the gate lands green; generated catalogs/graphs are
regenerated for the shifted line pointers.

RFC: docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md
2026-07-06 22:09:30 +08:00

297 lines
9.7 KiB
TypeScript

/**
* Negative-path tests for the export-surface JSDoc gate
* (`scripts/verify-export-jsdoc.ts`).
*
* The gate's positive half runs against the real tree in CI (`pnpm run
* verify-export-jsdoc`, part of doc-sync). What that run cannot prove is that
* the walk REJECTS an undocumented surface the way it promises to — and that
* every deliberate exemption (heritage members, plugin-protocol slots,
* constructors, overload implementations, augmentation bodies, re-exports)
* actually holds. These tests drive `collectExportJsdocViolations()` against
* synthetic fixture packages, mirroring the gen-cordis-catalog negative
* tests.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { collectExportJsdocViolations } from '../../../../scripts/verify-export-jsdoc.ts'
const roots: string[] = []
afterEach(() => {
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
})
/** Write fixture files under `packages/group/fix/src/` and return the scan root. */
function fixture(files: Record<string, string>): string {
const root = mkdtempSync(join(tmpdir(), 'export-jsdoc-'))
roots.push(root)
for (const [rel, content] of Object.entries(files)) {
const abs = join(root, 'packages', 'group', 'fix', 'src', rel)
mkdirSync(dirname(abs), { recursive: true })
writeFileSync(abs, content)
}
return root
}
/** Single-file fixture shorthand: the content becomes `src/index.ts`. */
const make = (content: string): string => fixture({ 'index.ts': content })
describe('verify-export-jsdoc functions and consts', () => {
it('accepts a fully documented surface', () => {
expect(collectExportJsdocViolations(make(`
/**
* Add one to a count.
* @param n - the count to bump.
* @returns the count plus one.
*/
export function bump(n: number): number { return n + 1 }
/**
* Fire-and-forget (void needs no @returns).
* @param flag - whether to arm.
*/
export function poke(flag: boolean): void { void flag }
/** The default retry budget. */
export const RETRIES = 3
/**
* Halve a count.
* @param n - the count to halve.
* @returns the count halved.
*/
export const halve = (n: number): number => n / 2
`))).toEqual([])
})
it('flags an exported function with no JSDoc at all', () => {
expect(collectExportJsdocViolations(make(
'export function bare(): void {}\n',
))).toEqual([expect.stringMatching(/exported function 'bare' .* has no JSDoc\./)])
})
it('flags a missing @param and a missing @returns', () => {
const violations = collectExportJsdocViolations(make(
'/** Docs without tags. */\nexport function f(x: number): number { return x }\n',
))
expect(violations).toEqual([
expect.stringMatching(/exported function 'f' .* is missing @param x\./),
expect.stringMatching(/exported function 'f' .* is missing @returns \(return type: number\)\./),
])
})
it('flags an unannotated (inferred) return type', () => {
expect(collectExportJsdocViolations(make(
'/**\n * Docs.\n * @param x - value.\n */\nexport function f(x: number) { return x }\n',
))).toEqual([expect.stringMatching(/no return type annotation/)])
})
it('flags tags-only JSDoc with no description prose', () => {
expect(collectExportJsdocViolations(make(
'/**\n * @param x - value.\n */\nexport function f(x: number): void {}\n',
))).toEqual([expect.stringMatching(/no description prose above its block tags/)])
})
it('flags a stale @param and a binding-pattern parameter', () => {
const violations = collectExportJsdocViolations(make(
'/**\n * Docs.\n * @param ghost - not real.\n */\nexport function f({ a }: { a: number }): void {}\n',
))
expect(violations).toEqual([
expect.stringMatching(/parameter '\{ a \}' is a binding pattern; the export surface needs simple identifier parameters/),
expect.stringMatching(/@param ghost does not match any parameter \(stale tag\?\)/),
])
})
it('exempts a `this` receiver annotation from @param', () => {
expect(collectExportJsdocViolations(make(
'/**\n * Docs.\n * @param x - value.\n */\nexport function f(this: object, x: number): void {}\n',
))).toEqual([])
})
it('waives @returns for a declarator-annotated const but not an unannotated one', () => {
expect(collectExportJsdocViolations(make(`
type Fn = (x: number) => number
/**
* Uses the named signature.
* @param x - value.
*/
export const good: Fn = x => x
/**
* No signature anywhere.
* @param x - value.
*/
export const bad = (x: number) => x
`))).toEqual([expect.stringMatching(/exported const 'bad' .* has no return type annotation/)])
})
it('requires description prose on a non-function const', () => {
expect(collectExportJsdocViolations(make(
'export const LIMIT = 10\n',
))).toEqual([expect.stringMatching(/exported const 'LIMIT' .* has no JSDoc\./)])
})
})
describe('verify-export-jsdoc type-level exports', () => {
it('requires description prose on interfaces, type aliases, and enums', () => {
const violations = collectExportJsdocViolations(make(
'export interface I { a: number }\nexport type T = number\nexport enum E { A }\n',
))
expect(violations).toEqual([
expect.stringMatching(/exported interface 'I' .* has no JSDoc\./),
expect.stringMatching(/exported type 'T' .* has no JSDoc\./),
expect.stringMatching(/exported enum 'E' .* has no JSDoc\./),
])
})
it('skips `declare module` augmentation bodies (the cordis gate owns them)', () => {
expect(collectExportJsdocViolations(make(
"declare module 'cordis' {\n interface Events {\n 'fix/x'(): void\n }\n}\nexport {}\n",
))).toEqual([])
})
})
describe('verify-export-jsdoc export forms', () => {
it('resolves an `export { … }` list to the local declaration', () => {
expect(collectExportJsdocViolations(make(
'function f(): void {}\nexport { f }\n',
))).toEqual([expect.stringMatching(/exported function 'f' .* has no JSDoc\./)])
})
it('reports a re-exported module once, at its defining file', () => {
const violations = collectExportJsdocViolations(fixture({
'index.ts': "export * from './other.ts'\n",
'other.ts': 'export function f(): void {}\n',
}))
expect(violations).toEqual([expect.stringMatching(/other\.ts:1\) has no JSDoc\./)])
})
it('exempts overload implementations when the signatures are documented', () => {
expect(collectExportJsdocViolations(make(`
/**
* From a number.
* @param x - the number.
* @returns its text.
*/
export function f(x: number): string
/**
* From a flag.
* @param x - the flag.
* @returns its text.
*/
export function f(x: boolean): string
export function f(x: number | boolean): string { return String(x) }
`))).toEqual([])
})
})
describe('verify-export-jsdoc classes', () => {
it('flags an undocumented class, method, property, and accessor', () => {
const violations = collectExportJsdocViolations(make(`
export class C {
state = 1
get view(): number { return this.state }
run(x: number): number { return x }
}
`))
expect(violations).toEqual([
expect.stringMatching(/exported class 'C' .* has no JSDoc\./),
expect.stringMatching(/exported class property 'C.state' .* has no JSDoc\./),
expect.stringMatching(/exported class accessor 'C.view' .* has no JSDoc\./),
expect.stringMatching(/exported class method 'C.run' .* has no JSDoc\./),
])
})
it('exempts members declared by an extends/implements heritage type', () => {
expect(collectExportJsdocViolations(make(`
/** Seam. */
export abstract class Base {
/**
* Do it.
* @param x - input.
* @returns output.
*/
abstract run(x: number): number
}
/** Iface. */
export interface Sized {
/** Byte size. */
size: number
}
/** Impl. */
export class Impl extends Base implements Sized {
size = 0
run(x: number): number { return x }
}
`))).toEqual([])
})
it('skips private/protected/#private members and constructors', () => {
expect(collectExportJsdocViolations(make(`
/** Documented. */
export class C {
#secret = 1
private hidden(): void {}
protected hook(): void {}
constructor(x: number) { void x }
}
`))).toEqual([])
})
it('exempts plugin-protocol statics but checks other statics', () => {
const violations = collectExportJsdocViolations(make(`
/** Plugin. */
export class C {
static Config = { a: 1 }
static inject = ['bash']
static reusable = true
static other = 1
}
`))
expect(violations).toEqual([expect.stringMatching(/exported class property 'C.other' .* has no JSDoc\./)])
})
it("covers a set accessor by the getter's doc", () => {
expect(collectExportJsdocViolations(make(`
/** Documented. */
export class C {
/** The current width. */
get width(): number { return 1 }
set width(_v: number) {}
}
`))).toEqual([])
})
})
describe('verify-export-jsdoc plugin protocol and namespaces', () => {
it('exempts top-level plugin-protocol exports', () => {
expect(collectExportJsdocViolations(make(`
export const name = 'fix'
export const inject = ['bash']
export const reusable = true
export const Config = { parse: true }
export function apply(): void {}
`))).toEqual([])
})
it('recurses into namespaces with qualified names and honors the merge idiom', () => {
const violations = collectExportJsdocViolations(make(`
/** The plugin class. */
export class Fix {}
export namespace Fix {
export interface Config { a: number }
}
export namespace Loose {
export const x = 1
}
`))
expect(violations).toEqual([
expect.stringMatching(/exported interface 'Fix.Config' .* has no JSDoc\./),
expect.stringMatching(/exported namespace 'Loose' .* has no JSDoc\./),
expect.stringMatching(/exported const 'Loose.x' .* has no JSDoc\./),
])
})
})