Add JSDoc completeness gate for the cordis surface

gen-cordis-catalog now hard-errors (aggregated, not fail-fast) when an
event lacks description prose or a payload @param, or a public service
method lacks JSDoc, a @param per parameter, a @returns on a non-void
result, or an explicit return type annotation. The this receiver and the
trailing waterfall next are exempt on events (mode machinery owned by
@mode); a stale @param naming no real parameter errors, mirroring the
@mode contradiction check. parseJsDoc now ends prose at the first block
tag (standard JSDoc semantics), so the tags never change the rendered
catalog — only Source: line pointers moved.

Fills the ~139 gaps found across the 15 surface files, extends the spec
with negative-path fixtures for every new guard plus the exemptions,
records the decision as an implemented process RFC, and extends the
AGENTS.md typed-events bullet with the authoring rule. Runs inside
verify-cordis-catalog -> doc-sync, so CI and pre-push enforce it with
zero new wiring.
This commit is contained in:
Tianyi Cui
2026-07-04 19:06:35 +08:00
parent 734ffcc8a2
commit a29bbe1453
19 changed files with 593 additions and 73 deletions

View File

@@ -4,17 +4,20 @@
* The generated catalog is frozen by a regenerate-and-diff freshness gate, so
* the freshness half is exercised by `pnpm run verify-cordis-catalog` in CI.
* What a freshness diff CANNOT prove is that the generator REJECTS malformed
* source the way it promises to — a missing `@mode` tag, or a tag that
* contradicts the signature shape. These tests drive `collectEvents()` against
* synthetic fixture packages to prove each guard fires (and that a well-formed
* event passes), mirroring the drift-guard negative tests for verify-type-equiv.
* source the way it promises to — a missing `@mode` tag, a tag that
* contradicts the signature shape, or a JSDoc-completeness violation (missing
* prose, an undocumented parameter, a stale `@param`, a missing `@returns`, an
* unannotated return type). These tests drive `collectEvents()` /
* `collectServices()` against synthetic fixture packages to prove each guard
* fires (and that well-formed declarations pass), mirroring the drift-guard
* negative tests for verify-type-equiv.
*/
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { collectEvents } from '../../../../scripts/gen-cordis-catalog.ts'
import { collectEvents, collectServices } from '../../../../scripts/gen-cordis-catalog.ts'
/** Write a fixture package exposing one `interface Events` block and return the
* scan root to hand `collectEvents`. */
@@ -29,12 +32,31 @@ function fixtureRoot(eventsBlock: string): string {
return root
}
/** Write a fixture package exposing one `interface Context` entry (`ctx.fix` →
* `FixService`) plus the class source, and return the scan root to hand
* `collectServices`. */
function serviceFixtureRoot(classSource: string): string {
const root = mkdtempSync(join(tmpdir(), 'cordis-catalog-'))
const dir = join(root, 'packages', 'group', 'fix', 'src')
mkdirSync(dir, { recursive: true })
writeFileSync(
join(dir, 'index.ts'),
`declare module 'cordis' {\n interface Context {\n fix: FixService\n }\n}\n\n${classSource}\n`,
)
return root
}
const roots: string[] = []
const make = (block: string): string => {
const r = fixtureRoot(block)
roots.push(r)
return r
}
const makeService = (classSource: string): string => {
const r = serviceFixtureRoot(classSource)
roots.push(r)
return r
}
afterEach(() => {
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
@@ -43,7 +65,7 @@ afterEach(() => {
describe('gen-cordis-catalog collectEvents', () => {
it('extracts a well-formed event with its @mode and JSDoc', () => {
const events = collectEvents(make(
' /**\n * A thing happened.\n * @mode emit\n */\n \'fix/happened\'(id: string): void',
' /**\n * A thing happened.\n * @param id - which thing.\n * @mode emit\n */\n \'fix/happened\'(id: string): void',
))
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({ name: 'fix/happened', scope: 'fix', mode: 'emit', doc: 'A thing happened.' })
@@ -51,7 +73,7 @@ describe('gen-cordis-catalog collectEvents', () => {
it('classifies a trailing-next signature as a waterfall', () => {
const events = collectEvents(make(
' /**\n * Intercept it.\n * @mode waterfall\n */\n \'fix/intercept\'(x: number, next: () => Promise<number>): Promise<number>',
' /**\n * Intercept it.\n * @param x - the value under interception.\n * @mode waterfall\n */\n \'fix/intercept\'(x: number, next: () => Promise<number>): Promise<number>',
))
expect(events[0]?.mode).toBe('waterfall')
})
@@ -65,19 +87,124 @@ describe('gen-cordis-catalog collectEvents', () => {
it('hard-errors when an event is missing its @mode tag', () => {
expect(() => collectEvents(make(
' /** No mode here. */\n \'fix/untagged\'(id: string): void',
' /** No mode here. */\n \'fix/untagged\'(): void',
))).toThrow(/missing an @mode tag/)
})
it('hard-errors when @mode contradicts a trailing-next (waterfall) shape', () => {
expect(() => collectEvents(make(
' /**\n * Mislabeled.\n * @mode emit\n */\n \'fix/wrong\'(x: number, next: () => Promise<number>): Promise<number>',
' /**\n * Mislabeled.\n * @param x - the value.\n * @mode emit\n */\n \'fix/wrong\'(x: number, next: () => Promise<number>): Promise<number>',
))).toThrow(/trailing 'next' parameter .* tagged '@mode emit'/)
})
it('hard-errors when @mode waterfall has no trailing next to delegate to', () => {
expect(() => collectEvents(make(
' /**\n * Not actually a waterfall.\n * @mode waterfall\n */\n \'fix/nonext\'(id: string): void',
' /**\n * Not actually a waterfall.\n * @param id - which thing.\n * @mode waterfall\n */\n \'fix/nonext\'(id: string): void',
))).toThrow(/tagged '@mode waterfall' but has no trailing 'next'/)
})
it('hard-errors on an undocumented payload parameter', () => {
expect(() => collectEvents(make(
' /**\n * A thing happened.\n * @mode emit\n */\n \'fix/happened\'(id: string): void',
))).toThrow(/is missing @param id/)
})
it('hard-errors on a stale @param naming no real parameter', () => {
expect(() => collectEvents(make(
' /**\n * A thing happened.\n * @param id - which thing.\n * @param ghost - not a parameter.\n * @mode emit\n */\n \'fix/happened\'(id: string): void',
))).toThrow(/@param ghost does not match any parameter/)
})
it('hard-errors on an @param with an empty description', () => {
expect(() => collectEvents(make(
' /**\n * A thing happened.\n * @param id\n * @mode emit\n */\n \'fix/happened\'(id: string): void',
))).toThrow(/@param id has an empty description/)
})
it('hard-errors on an event whose JSDoc has no description prose', () => {
expect(() => collectEvents(make(
' /**\n * @param id - which thing.\n * @mode emit\n */\n \'fix/happened\'(id: string): void',
))).toThrow(/no description prose/)
})
it('exempts the `this` receiver and the trailing waterfall `next` from @param', () => {
const events = collectEvents(make(
' /**\n * Scoped interception.\n * @param x - the value under interception.\n * @mode waterfall\n */\n \'fix/scoped\'(this: object, x: number, next: () => Promise<number>): Promise<number>',
))
expect(events).toHaveLength(1)
})
it('aggregates every violation into one error instead of failing fast', () => {
expect(() => collectEvents(make(
' /** First. */\n \'fix/one\'(): void\n /** Second. */\n \'fix/two\'(): void',
))).toThrow(/2 JSDoc completeness violation\(s\)[\s\S]*fix\/one[\s\S]*fix\/two/)
})
})
describe('gen-cordis-catalog collectServices', () => {
const WELL_FORMED = `/** Fixture service. */
export class FixService {
/**
* Do the thing.
* @param id - which thing to do.
* @returns the outcome of doing it.
*/
run(id: string): string { return id }
/** Fire and forget (void needs no @returns). */
poke(): void {}
/** Flush (Promise<void> needs no @returns either). */
flush(): Promise<void> { return Promise.resolve() }
}`
it('extracts a well-formed service with its methods and class JSDoc', () => {
const services = collectServices(makeService(WELL_FORMED))
expect(services).toHaveLength(1)
expect(services[0]).toMatchObject({ key: 'fix', type: 'FixService', abstract: false, doc: 'Fixture service.' })
expect(services[0]?.methods).toHaveLength(3)
})
it('hard-errors on a public method with no JSDoc at all', () => {
expect(() => collectServices(makeService(
'/** Fixture service. */\nexport class FixService {\n run(id: string): string { return id }\n}',
))).toThrow(/ctx\.fix\.run .* has no JSDoc/)
})
it('hard-errors on an undocumented method parameter', () => {
expect(() => collectServices(makeService(
'/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n * @returns the outcome.\n */\n run(id: string): string { return id }\n}',
))).toThrow(/ctx\.fix\.run .* is missing @param id/)
})
it('hard-errors on a missing @returns for a non-void return type', () => {
expect(() => collectServices(makeService(
'/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n * @param id - which thing.\n */\n run(id: string): string { return id }\n}',
))).toThrow(/is missing @returns \(return type: string\)/)
})
it('hard-errors on an unannotated (inferred) return type', () => {
expect(() => collectServices(makeService(
'/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n * @param id - which thing.\n */\n run(id: string) { return id }\n}',
))).toThrow(/no return type annotation/)
})
it('hard-errors on a service class with no JSDoc', () => {
expect(() => collectServices(makeService(
'export class FixService {\n /** Fire and forget. */\n poke(): void {}\n}',
))).toThrow(/class FixService has no JSDoc/)
})
it('hard-errors on a stale method @param', () => {
expect(() => collectServices(makeService(
'/** Fixture service. */\nexport class FixService {\n /**\n * Fire and forget.\n * @param ghost - not a parameter.\n */\n poke(): void {}\n}',
))).toThrow(/@param ghost does not match any parameter/)
})
it('ignores private/protected/static members (not the ctx.<key> surface)', () => {
const services = collectServices(makeService(
'/** Fixture service. */\nexport class FixService {\n private hidden(id: string): string { return id }\n protected hook(): void {}\n static helper(): void {}\n}',
))
expect(services[0]?.methods).toHaveLength(0)
})
})