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

@@ -126,6 +126,8 @@ export class AgentRegistry extends Service {
* Register the agent-creation factory (the loop calls this on construction,
* effect-scoped). Throws if a factory is already registered. Returns the
* disposer; on dispose the factory slot is cleared.
* @param factory - the loop-owned factory {@link create}/{@link resume} delegate to.
* @returns the disposer that clears the factory slot.
*/
setFactory(factory: AgentFactory): () => void {
const dispose = this.ctx.effect(() => {
@@ -142,6 +144,8 @@ export class AgentRegistry extends Service {
* agent): this constructs the agent and its session. Throws if no factory is
* registered. Returns an {@link AgentHandle} — the owner disposes it to tear
* down exactly this agent.
* @param options - agent id, session id/seed/metadata, and agent options.
* @returns the handle whose dispose tears down exactly this agent.
*/
create(options: CreateAgentOptions): AgentHandle {
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
@@ -152,6 +156,8 @@ export class AgentRegistry extends Service {
* Load a persisted session and resume an agent on it through the registered
* factory. Rejects if no factory is registered; the factory rejects if
* session persistence is not configured. Returns an {@link AgentHandle}.
* @param options - the persisted session id plus agent id and options.
* @returns the handle for the resumed agent.
*/
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
@@ -162,6 +168,8 @@ export class AgentRegistry extends Service {
* Register a live agent. Throws if an agent with the same id is already
* registered. Emits `agent/created` on registration and `agent/disposed`
* when the calling fiber is disposed. Returns the disposer.
* @param agent - the already-constructed agent to record in the store.
* @returns the disposer that removes the agent and emits `agent/disposed`.
*/
register(agent: Agent): () => void {
const dispose = this.ctx.effect(function* (this: AgentRegistry) {
@@ -200,10 +208,19 @@ export class AgentRegistry extends Service {
return () => void dispose()
}
/**
* Look up a live agent.
* @param id - the agent id to look up.
* @returns the agent, or undefined when no live agent has that id.
*/
get(id: AgentId): Agent | undefined {
return this.store.get(id)
}
/**
* All live agents, in registration order.
* @returns a fresh array; mutating it does not affect the registry.
*/
list(): Agent[] {
return [...this.store.values()]
}

View File

@@ -228,12 +228,14 @@ declare module 'cordis' {
/**
* An agent was registered in the {@link AgentRegistry} and is ready to
* receive messages.
* @param agent - the newly registered agent, already resolvable in the registry.
* @mode emit
*/
'agent/created'(agent: Agent): void
/**
* An agent was disposed and removed from the registry; its fiber and any
* in-flight turn have been torn down.
* @param agent - the agent that was torn down; its handle is now inert.
* @mode emit
*/
'agent/disposed'(agent: Agent): void
@@ -241,12 +243,17 @@ declare module 'cordis' {
* Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive
* lifecycle off this transition, never off a status you just requested —
* `send()` does not flip status to `running` before it returns.
* @param agent - the agent whose status flipped.
* @param status - the status just entered (the transition's destination).
* @mode emit
*/
'agent/status'(agent: Agent, status: AgentStatus): void
/**
* A message entered the agent's inbox (queued or steering). `source` is
* the resolved source (defaults applied), not the caller's raw options.
* @param agent - the agent whose inbox received the message.
* @param content - the enqueued content blocks, verbatim.
* @param info - the resolved source plus whether it entered as steering.
* @mode emit
*/
'agent/queued'(agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void
@@ -260,6 +267,8 @@ declare module 'cordis' {
* so via `agent.inject()` (a `context/message` the first request sees), not
* by returning a decision. Cannot block the session from starting; that gap
* is deliberate (a bridge logs/injects, it does not gate startup).
* @param agent - the agent whose session lifecycle began.
* @param source - why the session started (fresh startup, resume, …).
* @mode emit
*/
'agent/session-start'(agent: Agent, source: SessionStartSource): void
@@ -295,6 +304,11 @@ declare module 'cordis' {
* listener needs to measure pressure (the system prompt counts toward the
* budget). `signal` cancels any in-flight work a listener starts (e.g. a
* summarization model call).
* @param agent - the agent about to open the step.
* @param turn - the already-open turn this step belongs to.
* @param step - the number of the step about to start.
* @param fullSystemPrompt - the assembled prompt, for measuring token pressure.
* @param signal - aborts in-flight listener work when the turn is torn down.
* @mode serial
*/
// TODO: `fullSystemPrompt` is a smell on a generic per-step seam — compaction
@@ -310,6 +324,9 @@ declare module 'cordis' {
* turn, per drained message. Maps onto Claude Code's `UserPromptSubmit` hook.
* Call `next()` to delegate to the default (allow unchanged), or return a
* {@link PromptDecision} without calling `next()` to short-circuit.
* @param agent - the agent draining its inbox.
* @param content - the drained message's blocks, as queued.
* @param source - the message's resolved source.
* @mode waterfall
*/
'agent/prompt-submit'(agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision>
@@ -319,12 +336,20 @@ declare module 'cordis' {
* delegate, or return without it to short-circuit. For surface mutation that
* must precede history derivation (compaction), use {@link agent/pre-step}
* instead — by the time this fires, `options.messages` is already derived.
* @param agent - the agent making the model call.
* @param turn - the open turn number.
* @param step - the step whose request this is.
* @param options - the assembled request; listeners return a transformed copy.
* @mode waterfall
*/
'agent/request'(agent: Agent, turn: number, step: number, options: GenerateOptions, next: () => Promise<GenerateOptions>): Promise<GenerateOptions>
/**
* Waterfall: post-process the assembled assistant {@link Message} before
* tool dispatch (validation, content rewriting, …).
* @param agent - the agent that received the step's response.
* @param turn - the open turn number.
* @param step - the step that produced the message.
* @param message - the assistant message as assembled from the stream.
* @mode waterfall
*/
'agent/step-result'(agent: Agent, turn: number, step: number, message: Message, next: () => Promise<Message>): Promise<Message>
@@ -335,6 +360,9 @@ declare module 'cordis' {
* Listeners force-continue (`/goal`, `/loop` — optionally attaching a
* `reason` recorded as next-step steering) or force-stop (budget guards).
* Call `next()` to delegate to the default, or return a decision to override.
* @param agent - the agent deciding whether to run another step.
* @param turn - the turn being continued or stopped.
* @param defaultDecision - what the loop would do absent an override.
* @mode waterfall
*/
'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
@@ -342,12 +370,20 @@ declare module 'cordis' {
// ---- streaming + tool notifications (emit) ----
/**
* Steering content was injected into a running turn.
* @param agent - the agent that absorbed the steering.
* @param turn - the running turn that received it.
* @param content - the injected blocks.
* @param source - the steering message's resolved source.
* @mode emit
*/
'agent/steering'(agent: Agent, turn: number, content: ContentBlock[], source: MessageSource): void
/**
* A step or turn errored. The loop reports a failure here (plus the logger)
* even when the error has no in-turn position for a session `error` event.
* @param agent - the agent whose turn errored.
* @param turn - the turn in which the failure surfaced.
* @param step - the step at which the failure surfaced.
* @param error - the failure, verbatim.
* @mode emit
*/
'agent/error'(agent: Agent, turn: number, step: number, error: Error): void

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)
})
})