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:
@@ -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()]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user