diff --git a/AGENTS.md b/AGENTS.md index 19f279319a..4768fbd1a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY` (and optional `DEEPSEEK_BASE_UR - Every npm package is `@deepseek-ai/dsh-`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package. - ESM everywhere (`"type": "module"`). Cross-package imports use package names, never relative paths; in-package relative imports use explicit `.ts` extensions. Dev/test/demo run unbuilt via tsx + the root tsconfig `paths` map; building is only for consumers outside the repo. - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. -- **Typed events via declaration merging**; extensible unions use the merge-extensible-map pattern (`ContentBlockMap`, `SessionEventMap`, …). Every new event's JSDoc carries an `@mode` tag — the catalog generator hard-errors without it; mode semantics are in the [generated catalog](docs/cordis-catalog/events-and-services.md) header and [the catalog RFC](docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md). +- **Typed events via declaration merging**; extensible unions use the merge-extensible-map pattern (`ContentBlockMap`, `SessionEventMap`, …). Every new event's JSDoc carries an `@mode` tag and a `@param` per payload parameter (`this`/trailing `next` exempt); every public service-class method documents each parameter and non-void return (`@param`/`@returns`) — the catalog generator hard-errors otherwise ([completeness RFC](docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md)); mode semantics are in the [generated catalog](docs/cordis-catalog/events-and-services.md) header and [the catalog RFC](docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md). - **Discriminated unions: `switch` on the tag**, not if-chains. Closed unions end with `default: assertNever(...)`; merge-extensible unions must NOT — handle known cases and fall through `default` with a comment. - **Waterfall listeners MUST call `next()`** to delegate; returning without it is the veto ([semantics](docs/architecture.md#cordis-waterfall-semantics-important)). - **Plugins, not loop changes**: new behavior goes on the documented extension seams; changing `agent-loop` requires updating docs/architecture.md. diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index ac7ea069b9..875a4e52fa 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -25,7 +25,7 @@ An agent was registered in the AgentRegistry and is ready to receive messages. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:233`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:234`](../../packages/core/agent/src/types.ts) #### `agent/disposed` — emit @@ -37,7 +37,7 @@ An agent was disposed and removed from the registry; its fiber and any in-flight Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:241`](../../packages/core/agent/src/types.ts) #### `agent/error` — emit @@ -49,7 +49,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:353`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:389`](../../packages/core/agent/src/types.ts) #### `agent/pre-step` — serial @@ -63,7 +63,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:305`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:319`](../../packages/core/agent/src/types.ts) #### `agent/prompt-submit` — waterfall @@ -75,7 +75,7 @@ Waterfall: decide what happens to ONE drained queued message before it becomes a Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:315`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:332`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -87,7 +87,7 @@ A message entered the agent's inbox (queued or steering). `source` is the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:252`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts) #### `agent/request` — waterfall @@ -99,7 +99,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:324`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:345`](../../packages/core/agent/src/types.ts) #### `agent/session-start` — emit @@ -111,7 +111,7 @@ The agent's session lifecycle began, fired once before its first turn. `source` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:265`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:274`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -123,7 +123,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:246`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:250`](../../packages/core/agent/src/types.ts) #### `agent/steering` — emit @@ -135,7 +135,7 @@ Steering content was injected into a running turn. Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:347`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:379`](../../packages/core/agent/src/types.ts) #### `agent/step-result` — waterfall @@ -147,7 +147,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:330`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:355`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -159,7 +159,7 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:340`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:368`](../../packages/core/agent/src/types.ts) ### `fs/*` @@ -173,7 +173,7 @@ Single-slot decision: produce the optional version guard for the next FileSystem Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) -Source: [`packages/fs/fs/src/index.ts:119`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:123`](../../packages/fs/fs/src/index.ts) #### `fs/observed` — emit @@ -185,7 +185,7 @@ Record that an actor observed a target at a version, after a successful read/wri Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) -Source: [`packages/fs/fs/src/index.ts:131`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:138`](../../packages/fs/fs/src/index.ts) #### `fs/write-intent` — waterfall @@ -197,7 +197,7 @@ Single-slot decision: produce the write intent for the next FileSystem.writeText Types: [FsTarget](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) -Source: [`packages/fs/fs/src/index.ts:107`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:109`](../../packages/fs/fs/src/index.ts) ### `llm/*` @@ -211,7 +211,7 @@ Waterfall around every streaming model call (retry, caching, routing). Bound to Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:31`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:32`](../../packages/llm/llm/src/index.ts) ### `session/*` @@ -223,7 +223,7 @@ A session was created in the store. 'session/created'(session: Session): void ``` -Source: [`packages/core/session/src/index.ts:35`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:36`](../../packages/core/session/src/index.ts) #### `session/event` — emit @@ -235,7 +235,7 @@ An event was appended to a session log (sync, fire-and-forget). This is the per- Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:41`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:44`](../../packages/core/session/src/index.ts) #### `session/flush` — parallel @@ -245,7 +245,7 @@ Awaited durability checkpoint. The agent loop awaits `ctx.parallel('session/flus 'session/flush'(session: Session): Promise | void ``` -Source: [`packages/core/session/src/index.ts:50`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:54`](../../packages/core/session/src/index.ts) ### `subagent/*` @@ -257,7 +257,7 @@ A subagent run settled — emitted when SubagentRun.result resolves (any stop re 'subagent/end'(info: SubagentRunEndInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:75`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:77`](../../packages/subagent/subagent/src/index.ts) #### `subagent/start` — emit @@ -267,7 +267,7 @@ A subagent run started — emitted after the provider is resolved and its capabi 'subagent/start'(info: SubagentRunInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:69`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:70`](../../packages/subagent/subagent/src/index.ts) ### `system-prompt/*` @@ -279,7 +279,7 @@ Waterfall around prompt assembly — mutate or extend the PromptAssembly (sectio 'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, next: () => Promise): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:24`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:26`](../../packages/core/system-prompt/src/index.ts) #### `system-prompt/change` — emit @@ -289,7 +289,7 @@ A section or tool provider was registered or unregistered (the assembly inputs c 'system-prompt/change'(): void ``` -Source: [`packages/core/system-prompt/src/index.ts:30`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:32`](../../packages/core/system-prompt/src/index.ts) ### `tools/*` @@ -301,7 +301,7 @@ A tool was registered or unregistered (the available tool set changed). 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:84`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:87`](../../packages/core/tools/src/index.ts) #### `tools/post-execute` — waterfall @@ -313,7 +313,7 @@ Waterfall AFTER a tool runs — where hook plugins inspect the result and accept Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:79`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:82`](../../packages/core/tools/src/index.ts) #### `tools/pre-execute` — waterfall @@ -325,7 +325,7 @@ Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook pl Types: [ToolExecution](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:65`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:66`](../../packages/core/tools/src/index.ts) ### `web/*` @@ -444,7 +444,7 @@ abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: F Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsInfo](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) -Source: [`packages/fs/fs/src/index.ts:165`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:172`](../../packages/fs/fs/src/index.ts) ### `ctx.llm` — `LlmService` @@ -458,7 +458,7 @@ stream(options: GenerateOptions): AsyncIterable Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:69`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:70`](../../packages/llm/llm/src/index.ts) ### `ctx.sessionPersistence` — `SessionPersistence` (abstract seam) @@ -497,7 +497,7 @@ get(id: SessionId): Session | undefined list(): Session[] ``` -Source: [`packages/core/session/src/index.ts:323`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:327`](../../packages/core/session/src/index.ts) ### `ctx.subagents` — `SubagentService` @@ -510,7 +510,7 @@ list(): string[] start(name: string, request: SubagentStartRequest): SubagentRun ``` -Source: [`packages/subagent/subagent/src/index.ts:121`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:123`](../../packages/subagent/subagent/src/index.ts) ### `ctx.systemPrompt` — `SystemPrompt` @@ -522,7 +522,7 @@ tools(provider: () => ToolSchema[]): () => void assemble(): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:71`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:73`](../../packages/core/system-prompt/src/index.ts) ### `ctx.tools` — `ToolRegistry` @@ -537,7 +537,7 @@ async execute(exec: ToolExecution): Promise Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:265`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:268`](../../packages/core/tools/src/index.ts) ### `ctx.web` — `WebService` diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 2f34a92198..f5550e9378 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -168,6 +168,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Generated tool-schema catalog (boot-and-harvest)](implemented/process/2026-07-02-tool-schema-catalog.md) | 2026-07-02 | | [Bilingual documentation via paired sibling files and a pairing gate](implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md) | 2026-07-02 | | [Documentation tiers, budgets, and the ceiling gate](implemented/process/2026-07-04-doc-tiers-and-budgets.md) | 2026-07-04 | +| [JSDoc completeness gate for the cordis surface](implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md) | 2026-07-04 | ### Testing diff --git a/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md new file mode 100644 index 0000000000..c86fe5533f --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md @@ -0,0 +1,33 @@ +# RFC: JSDoc completeness gate for the cordis surface + +Status: implemented (accepted 2026-07-04) + +## Context + +The [generated cordis catalog](2026-06-20-generated-cordis-catalog.md) already walks every harness `interface Events` member and every `ctx.` service class with the TypeScript compiler API, and already hard-errors on a missing `@mode` tag — a forcing function that made dispatch modes impossible to leave undocumented. Nothing equivalent guarded the rest of the JSDoc: a service method could ship with no doc at all, and no event or method documented its parameters or return value individually. A survey at adoption found 5 public service methods with no JSDoc and roughly 139 missing `@param`/`@returns` entries across 15 files — on the product API spine (`ctx.bash`, `ctx.fs`, `ctx.sessions`, …) and the cross-plugin event payload contracts, exactly the surface where "what does this argument mean" is the question a plugin author asks the IDE. + +The AGENTS.md rule ("every export has a JSDoc explaining semantics") is prose-checkable only by review; the repo's stated preference is to encode invariants in mechanical gates. The scope "cordis service functions and events" has a precise machine definition that only the catalog generator knows: events are the `interface Events` members inside `declare module 'cordis'`, and the service surface is the public methods of the class each `interface Context` key names. An ESLint rule cannot see that mapping; the generator computes it on every run. + +## Decision + +Extend `scripts/gen-cordis-catalog.ts` — the same walk, the same `@mode` precedent — to enforce JSDoc COMPLETENESS on everything it catalogs. `verify-cordis-catalog` runs inside `doc-sync`, which both CI and the lefthook pre-push hook already execute, so the gate needs zero new wiring (quality-gates principle: one source of truth). + +The contract: + +- **Events** need description prose plus a non-empty `@param` for every **payload parameter**. A payload parameter is a signature parameter that carries event data; the `this` receiver annotation and the trailing waterfall `next` are exempt — `next` is dispatch machinery whose semantics the `@mode waterfall` tag (and its structural cross-check) already owns, so restating it per event would be boilerplate. Documenting an exempt parameter anyway is allowed; only absence is checked. +- **Service classes** need class-level JSDoc, and every public method needs description prose, a non-empty `@param` per parameter, and a non-empty `@returns` unless the annotated return type is `void`/`Promise` (where `@returns` stays optional — resolution timing can be worth documenting — but is never required). +- **Stale tags error**: an `@param` naming no real parameter is a violation, mirroring the `@mode`-contradicts-signature check. Tag descriptions must be non-empty; their semantic quality beyond that is review's job. +- **Explicitness the walk can check**: the gate is a pure-AST pass (no type checker), so a service method must annotate its return type (an inferred return cannot be classified) and surface parameters must be simple identifiers (a binding pattern has no name for `@param` to match). +- **Violations aggregate** into one error listing every offender — a remediation pass sees the whole list at once. The previously fail-fast `@mode` checks moved into the same aggregated report, with their message texts unchanged. + +The tags are **enforcement-only**: `parseJsDoc` now ends description prose at the first block tag (standard JSDoc semantics, which also stops multi-line tag descriptions from leaking into the catalog as prose), so `@param`/`@returns` never change the rendered catalog. Rendering them — restructuring the services section into per-method entries — was considered and deliberately deferred: source JSDoc plus IDE hover is where method docs are consumed, and the catalog stays an index. No escape-hatch tag exists; the surface is small and curated (12 services, 57 methods, 27 events at adoption), and the point is that the check cannot be waved off. + +Negative-path tests in `packages/core/agent/tests/gen-cordis-catalog.spec.ts` drive `collectEvents`/`collectServices` against synthetic fixtures to prove each guard fires and that the exemptions hold. The authoring rule lives in the root [AGENTS.md](../../../../AGENTS.md) conventions bullet alongside the `@mode` rule. + +## Consequences + +- A new event or service method cannot land with an undocumented parameter or result: the generator refuses to regenerate and `verify-cordis-catalog` fails pre-push and in CI. The ~139 gaps found at adoption were filled in the same change, so the gate landed green. +- The service surface must annotate return types explicitly and use identifier parameters. Neither constraint bound at adoption (every method already annotated; no destructured seam parameters existed); both are now load-bearing requirements a violating change will discover mechanically. +- The general AGENTS.md JSDoc rule ("one-liners when one line suffices") acquires a stricter carve-out on this surface: a one-line summary still suffices only when the method has no parameters and a void result. +- `@param` on `next` or `this` stays legal but unchecked — a deliberate asymmetry: the gate enforces the payload contract and refuses to demand boilerplate. +- The rendered catalog is unchanged by the tags (prose stops at the first block tag). If method-level rendering is wanted later, that is a catalog-design decision to take separately, not a gap in this gate. diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index 01c5c081c3..b629f10e4d 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -77,16 +77,32 @@ export abstract class BashExecutor extends Service { * call this, then pass the result to {@link run}/{@link start} — keeping * defaulting in the implementation that owns the config while the seam type * stays explicit (no hidden `?? default` inside run/start). + * @param request - the caller's request; omitted fields get this + * implementation's defaults, capped fields are clamped. + * @returns the fully-specified spec to hand to {@link run}/{@link start}. */ abstract resolve(request: BashExecRequest): BashExecSpec - /** Run a command in the foreground; resolves when it finishes. */ + /** + * Run a command in the foreground; resolves when it finishes. + * @param spec - a resolved spec from {@link resolve}, never a raw request. + * @returns the outcome; nonzero exits, timeout kills, and abort kills + * resolve with a descriptive result rather than reject. + */ abstract run(spec: BashExecSpec): Promise - /** Start a background task and return its handle immediately. */ + /** + * Start a background task and return its handle immediately. + * @param spec - a resolved spec from {@link resolve}, never a raw request. + * @returns the live task handle; completion fires {@link onTaskDone}. + */ abstract start(spec: BashExecSpec): BashTask - /** Look up a background task by id. */ + /** + * Look up a background task by id. + * @param id - the task id to look up. + * @returns the tracked task, or undefined for an id this executor never issued. + */ abstract get(id: BashTaskId): BashTask | undefined /** @@ -101,24 +117,38 @@ export abstract class BashExecutor extends Service { * loudly at the subsequent {@link readOutput}/{@link kill} ("unknown task"). * Storing ownership in the executor (disposed with ITS fiber) — not in the * tool plugin — is what makes ownership survive a `tool-bash` HMR reload. + * @param id - the background task id to look up ownership for. + * @returns the token recorded at start, verbatim; undefined for an unknown + * id or a known-but-ownerless task. */ abstract ownerOf(id: BashTaskId): OwnerToken | undefined - /** All tracked background tasks (insertion order). */ + /** + * All tracked background tasks (insertion order). + * @returns every task this executor started, running or finished. + */ abstract list(): BashTask[] - /** Read output produced since the previous read. Throws for unknown ids. */ + /** + * Read output produced since the previous read. Throws for unknown ids. + * @param id - the task to read from. + * @returns the incremental read; consecutive reads never re-deliver output. + */ abstract readOutput(id: BashTaskId): BashTaskRead /** * Kill a running background task. Returns false when it had already * finished (no-op). Throws for unknown ids. + * @param id - the task to kill. + * @returns true when this call killed it, false when it had already finished. */ abstract kill(id: BashTaskId): boolean /** * Register a background-task completion listener (disposed with the * calling fiber). Listeners never fire after this service is disposed. + * @param listener - called exactly once per task completion. + * @returns the disposer that unregisters the listener. */ onTaskDone(listener: BashTaskListener): () => void { const dispose = this.ctx.effect(() => { diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index f5d03fe2ac..37c94920f3 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -141,6 +141,7 @@ export abstract class CompactService extends Service { * prior replace can leave the surface non-monotonic in seq order), or if * either boundary is not a balanced tool-pairing cut (would split a step's * tool-call/result pair). + * @returns what the compaction did (the replaced range and its summary node). */ abstract compactRegion( session: Session, diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 9813dadda4..33672dc9b4 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -121,6 +121,9 @@ export class AgentLoop extends Service implements AgentFactory { * deliberate resume-or-create policy (resume the prior session if one exists, * else start fresh) or an explicit caller-chosen session id — revisit when the * UI/ACP path owns session selection. + * @param id - the agent id; also seeds the generated session id. + * @param options - loop options (model, limits, …); defaults applied per option. + * @returns the running agent, owned by the calling fiber (no handle). */ create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent { this.assertAgentIdFree(id) @@ -142,6 +145,9 @@ export class AgentLoop extends Service implements AgentFactory { * `seed` (a balanced completed-turn prefix of the parent's log) so the child * starts with the parent's context. Returns an {@link AgentHandle} the owner * disposes to tear down exactly this agent. + * @param options - agent id, caller-supplied session id, optional seed/meta, + * and agent options. + * @returns the handle whose dispose tears down exactly this agent. */ createAgent(options: CreateAgentOptions): AgentHandle { // Check the agent id BEFORE preparing the session: register() would reject a @@ -168,6 +174,8 @@ export class AgentLoop extends Service implements AgentFactory { * configured. NOT hard-injected (that would make non-persistent demos pend * forever) — callers that need resume (ACP) inject `sessionPersistence`, so * by the time this runs the service exists. + * @param options - the persisted session id to reload, plus agent id/options. + * @returns the handle for the agent resumed on the reconstructed session. */ async resume(options: ResumeAgentOptions): Promise { // Read the service through `ctx.get('sessionPersistence')` — a direct diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 940a5c9db1..ec9ba796b9 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -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 { 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()] } diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index dd01831130..cbc6743870 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -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): Promise @@ -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): Promise /** * 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): Promise @@ -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): Promise @@ -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 diff --git a/packages/core/agent/tests/gen-cordis-catalog.spec.ts b/packages/core/agent/tests/gen-cordis-catalog.spec.ts index ee2ce47699..2d8cd7764f 100644 --- a/packages/core/agent/tests/gen-cordis-catalog.spec.ts +++ b/packages/core/agent/tests/gen-cordis-catalog.spec.ts @@ -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): Promise', + ' /**\n * Intercept it.\n * @param x - the value under interception.\n * @mode waterfall\n */\n \'fix/intercept\'(x: number, next: () => Promise): Promise', )) 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): Promise', + ' /**\n * Mislabeled.\n * @param x - the value.\n * @mode emit\n */\n \'fix/wrong\'(x: number, next: () => Promise): Promise', ))).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): Promise', + )) + 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 needs no @returns either). */ + flush(): Promise { 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. 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) + }) }) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index fee21664ff..fa9bbb5ba4 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -30,12 +30,15 @@ declare module 'cordis' { interface Events { /** * A session was created in the store. + * @param session - the session just entered and announced. * @mode emit */ 'session/created'(session: Session): void /** * An event was appended to a session log (sync, fire-and-forget). This is * the per-append feed a UI or invariant plugin tails. + * @param session - the session whose log grew. + * @param event - the appended event, exactly as recorded. * @mode emit */ 'session/event'(session: Session, event: SessionEvent): void @@ -45,6 +48,7 @@ declare module 'cordis' { * plugins (JSONL, SQLite) drain their write-behind buffers here and on * fiber dispose. Awaited (parallel), not a waterfall: every listener runs * and the loop waits for all of them, but none can veto. + * @param session - the session whose buffered events must reach durable storage. * @mode parallel */ 'session/flush'(session: Session): Promise | void @@ -342,6 +346,9 @@ export class SessionStore extends Service { * {@link prepare} + {@link enter} + {@link announce} (see `dsh-agent-loop`'s * `startOwned`). * + * @param id - the session id; omitted, the store mints `session-`. + * @param options - seed events and/or creation metadata for the header. + * @returns the live session, already entered and announced. * @throws if a session with `id` already exists, or if `meta.cwd` is a * non-absolute path (storage backends key directories off it). */ @@ -367,6 +374,9 @@ export class SessionStore extends Service { * chain rather than as racing sibling effects — which would detach `onAppend` * before the loop's closing `session/flush`, dropping the closing events. * + * @param id - the session id; omitted, the store mints `session-`. + * @param options - seed events and/or creation metadata for the header. + * @returns the constructed session, NOT yet in the store. * @throws if a session with `id` already exists, or if `meta.cwd` is a * non-absolute path. */ @@ -404,6 +414,8 @@ export class SessionStore extends Service { * the two back-to-back so they never trip this, but the public seam cannot * assume that. * + * @param session - a {@link prepare}d session not yet in the store. + * @returns the detach disposer (`onAppend = undefined` + store removal). * @throws if a session with this id is already in the store. */ enter(session: Session): () => void { @@ -418,15 +430,25 @@ export class SessionStore extends Service { /** Emit `session/created` for an {@link enter}ed session. Separate from * {@link enter} so the caller can yield the detach disposer first (rollback - * safety — see {@link enter}). */ + * safety — see {@link enter}). + * @param session - the entered session to announce to listeners. */ announce(session: Session): void { this.ctx.emit('session/created', session) } + /** + * Look up a live session. + * @param id - the session id to look up. + * @returns the session, or undefined when no live session has that id. + */ get(id: SessionId): Session | undefined { return this.store.get(id) } + /** + * All live sessions, in creation order. + * @returns a fresh array; mutating it does not affect the store. + */ list(): Session[] { return [...this.store.values()] } diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index a5e6e4ed78..37dc10b9ee 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -19,6 +19,8 @@ declare module 'cordis' { * Waterfall around prompt assembly — mutate or extend the * {@link PromptAssembly} (sections + tool schemas) before it is rendered. * Bound to the {@link SystemPrompt} service; call `next()` to delegate. + * @param assembly - the assembly built from the registered sections and + * tool providers; listeners may mutate it or return a replacement. * @mode waterfall */ 'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, next: () => Promise): Promise @@ -80,6 +82,8 @@ export class SystemPrompt extends Service { * Contribute a text section to the system prompt. Order is determined by * `section.order` (ascending). The section is removed when the calling * fiber is disposed. Emits `system-prompt/change` on register/unregister. + * @param section - the section to contribute (name, order, text or provider). + * @returns the disposer that removes the section. */ section(section: PromptSection): () => void { const dispose = this.ctx.effect(function* (this: SystemPrompt) { @@ -105,6 +109,8 @@ export class SystemPrompt extends Service { * Contribute a tool-schema provider that is evaluated at each assembly * call (so it can reflect the live registry state). The provider is * removed when the calling fiber is disposed. Emits `system-prompt/change`. + * @param provider - evaluated at every {@link assemble} for fresh schemas. + * @returns the disposer that removes the provider. */ tools(provider: () => ToolSchema[]): () => void { const dispose = this.ctx.effect(function* (this: SystemPrompt) { @@ -132,6 +138,7 @@ export class SystemPrompt extends Service { * listeners the opportunity to mutate or replace the assembly before it * reaches the model. Await the result before reading the assembly values — * waterfall listeners may be async. + * @returns the assembly after the waterfall has run. */ assemble(): Promise { const assembly: PromptAssembly = { diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 6265e645f9..063e14708e 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -60,6 +60,7 @@ declare module 'cordis' { * tool body never runs. Input rewrite is deliberately NOT offered here (see * {@link PreToolDecision}); `ask` degrades to deny until the permission * system lands (`FIXME(permissions)`). + * @param exec - the pending call (name, parsed arguments, caller agent). * @mode waterfall */ 'tools/pre-execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise): Promise @@ -74,6 +75,8 @@ declare module 'cordis' { * `execute`'s outer try/catch (and the tool body keeps its own inner * try/catch, so a thrown tool still reaches `post-execute` as an `isError` * result). + * @param exec - the call that just ran (name, parsed arguments, caller agent). + * @param result - the dispatch outcome a listener may accept, replace, or block. * @mode waterfall */ 'tools/post-execute'(this: ToolRegistry, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise): Promise @@ -277,6 +280,9 @@ export class ToolRegistry extends Service { * registered. The tool's schema (minus the `execute` function) is * automatically contributed to the system-prompt assembly. Disposed * with the calling fiber. Emits `tools/change` on register/unregister. + * @param definition - the tool's schema plus its execute (and optional + * presentation) functions. + * @returns the disposer that unregisters the tool. */ register(definition: ToolDefinition): () => void { const dispose = this.ctx.effect(function* (this: ToolRegistry) { @@ -300,6 +306,11 @@ export class ToolRegistry extends Service { return () => void dispose() } + /** + * Look up a registered tool. + * @param name - the tool name as registered. + * @returns the definition, or undefined when no tool has that name. + */ get(name: string): ToolDefinition | undefined { return this.store.get(name) } @@ -313,6 +324,7 @@ export class ToolRegistry extends Service { * those (especially the functions) must never leak into a model request. An * allowlist can't drift when a new non-schema member is added to the * definition; a denylist (rest-destructure) would silently leak it. + * @returns one deep-cloned schema per registered tool, in registration order. */ schemas(): ToolSchema[] { return [...this.store.values()].map(({ name, description, parameters, strict }): ToolSchema => ({ @@ -334,6 +346,9 @@ export class ToolRegistry extends Service { * still inspect. If the tool is not registered, the result is an `isError` * carrying a `UNKNOWN_TOOL` structured error. A thrown {@link HarnessError} * surfaces its `{ name, code }` on the result. + * @param exec - the call to run (name, parsed arguments, caller agent, signal). + * @returns the final result after both waterfalls; failures resolve as + * `isError` results, never rejections. */ async execute(exec: ToolExecution): Promise { try { diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index db0135ba80..1e0ab03b85 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -102,6 +102,8 @@ declare module 'cordis' { * chain. The slot is first-wins: the first non-`next()` decider (registration * order, or `prepend`) occupies it; a second decider is a misconfiguration, * not layering. `actor` is the opaque tool-execution context, never read here. + * @param target - the resolved target about to be written. + * @param actor - the opaque tool-execution context the decider keys off. * @mode waterfall */ 'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise): Promise @@ -114,6 +116,8 @@ declare module 'cordis' { * `{ version: vObserved }`, or throws `FS_NOT_OBSERVED` if the actor is unset * or has not observed the target. Does NOT call `next()`: one decision, * first-wins (see {@link Events.'fs/write-intent'}). + * @param target - the resolved target about to be edited. + * @param actor - the opaque tool-execution context the decider keys off. * @mode waterfall */ 'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> @@ -126,6 +130,9 @@ declare module 'cordis' { * await listener promises — async or fallible audit/telemetry does not * belong here. No listener ⇒ nothing recorded. `actor` is the opaque * tool-execution context. + * @param target - the target that was read/written/edited. + * @param version - the version the actor now holds as its observation. + * @param actor - the observing tool-execution context; undefined records nothing useful. * @mode emit */ 'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void @@ -180,13 +187,26 @@ export abstract class FileSystem extends Service { * caller's per-session workspace (`exec.agent.session.header.cwd`) without the * provider depending on `dsh-agent`/`dsh-session`. Mirrors how `dsh-tool-bash` * defaults a bash `workdir` to the session cwd. + * @param path - the path to resolve; relative paths resolve against `opts.cwd`. + * @param opts - `cwd` overrides the backend's default base for relative paths. + * @returns the stable target; the same file yields the same `targetKey`. */ abstract resolve(path: string, opts?: { cwd?: string }): Promise - /** Return target metadata, or `undefined` when the target does not exist. */ + /** + * Return target metadata, or `undefined` when the target does not exist. + * @param target - the resolved target to stat. + * @param signal - aborts the metadata round-trip. + * @returns metadata only, never content; undefined for an absent target. + */ abstract stat(target: FsTarget, signal?: AbortSignal): Promise - /** Read the whole regular text file as a single decoded string. */ + /** + * Read the whole regular text file as a single decoded string. + * @param target - the resolved target to read. + * @param signal - aborts the read. + * @returns the full decoded UTF-8 content. + */ abstract readText(target: FsTarget, signal?: AbortSignal): Promise /** @@ -194,12 +214,18 @@ export abstract class FileSystem extends Service { * semantics as {@link readText}, for large files). The backend owns * cross-chunk UTF-8 decoding and binary rejection so the policy layer never * touches raw bytes. + * @param target - the resolved target to read. + * @param signal - aborts the stream, including between chunks. + * @returns the chunk iterable, decoded and validated like {@link readText}. */ abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> /** * List direct children of a directory in stable name order. Returns resolved * child targets plus cheap metadata only; never reads file contents. + * @param target - the resolved directory target. + * @param signal - aborts the listing. + * @returns one entry per direct child, in stable name order. */ abstract listDir(target: FsTarget, signal?: AbortSignal): Promise @@ -208,6 +234,11 @@ export abstract class FileSystem extends Service { * create-vs-replace decision and stale guard when supplied; OMITTING it is an * unconditional create-or-overwrite (the bare provider — no version guard, no * read-first requirement). Atomic either way. + * @param target - the resolved target to write. + * @param content - the full new file content. + * @param expected - the write intent guarding the write; omit for unconditional. + * @param signal - aborts before the atomic rename takes effect. + * @returns the outcome, including the version the write produced. */ abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise @@ -217,6 +248,11 @@ export abstract class FileSystem extends Service { * matching; OMITTING it edits the current content unconditionally (no version * guard). Either way applies the replacement and writes atomically — one * mutation critical section — and a missing target reports `FS_STALE_VERSION`. + * @param target - the resolved target to edit. + * @param edit - the literal search/replace request. + * @param expected - the version guard; omit for an unconditional edit. + * @param signal - aborts before the atomic rename takes effect. + * @returns the outcome, including the version the edit produced. */ abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise } diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 320838a8a6..890a5a132e 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -26,6 +26,7 @@ declare module 'cordis' { * Waterfall around every streaming model call (retry, caching, routing). * Bound to the {@link LlmService}; call `next()` to reach the resolved * adapter's stream, or yield your own chunks to short-circuit. + * @param options - the full request; listeners may rewrite it before delegating. * @mode waterfall */ 'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable): AsyncIterable @@ -77,6 +78,9 @@ export class LlmService extends Service { * Register an adapter for the given model names. Throws `LlmError` with code * `DUPLICATE_ADAPTER` if any model already has an adapter (all-or-nothing). * Disposed with the fiber. + * @param models - every model name this adapter should serve. + * @param adapter - the adapter that streams calls for those models. + * @returns the disposer that unregisters all of them. */ registerAdapter(models: string[], adapter: LlmAdapter): () => void { const dispose = this.ctx.effect(function* (this: LlmService) { @@ -95,7 +99,10 @@ export class LlmService extends Service { return () => void dispose() } - /** Model names with a registered adapter. */ + /** + * Model names with a registered adapter. + * @returns the registered names, in registration order. + */ models(): string[] { return [...this.adapters.keys()] } @@ -110,6 +117,8 @@ export class LlmService extends Service { * Stream one model call as raw chunks (token-level deltas). Throws * `LlmError` with code `NO_ADAPTER` if no adapter is registered for * `options.model`. Dispatches through the `llm/stream` waterfall. + * @param options - the full request; `options.model` selects the adapter. + * @returns the chunk stream, possibly wrapped by `llm/stream` listeners. */ stream(options: GenerateOptions): AsyncIterable { return this.ctx.waterfall(this, 'llm/stream', options, () => { diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index f28bc06d5b..5e1af6e563 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -105,6 +105,7 @@ export abstract class SessionPersistence extends Service { * until the first {@link append} (lazy materialization), in which case a * created-but-never-appended session is absent from {@link list} * — abandoned sessions leave nothing behind. + * @param meta - the immutable header (id, version, cwd, lineage) to record. */ abstract create(meta: SessionHeader): Promise @@ -114,6 +115,8 @@ export abstract class SessionPersistence extends Service { * contracts: the first event's `seq` MUST equal the stored next-seq (after * `load` has durably closed any interrupted turn). Rejects non-JSON- * serializable `event.data` with an error naming the offending event type. + * @param id - the session the batch belongs to. + * @param events - the contiguous batch to persist, in seq order. */ abstract append(id: SessionId, events: readonly SessionEvent[]): Promise @@ -138,10 +141,16 @@ export abstract class SessionPersistence extends Service { * COMMITTED region (at or before the last real `turn/end`) makes the session * unloadable (reject). Rejects an unknown format `version`. See the session-persistence RFC for * the crash-recovery contract. + * @param id - the persisted session to reload. + * @returns the header plus the event log, ending on a balanced `turn/end` — + * immediately usable as a session seed. */ abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> - /** Lightweight listing from metadata, without a full-log parse. */ + /** + * Lightweight listing from metadata, without a full-log parse. + * @returns one header per materialized session. + */ abstract list(): Promise } diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 926c22d0c8..b0514edcac 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -64,12 +64,14 @@ declare module 'cordis' { * A subagent run started — emitted after the provider is resolved and its * capabilities validated, as the child run begins. Paired with * {@link Events['subagent/end']}. + * @param info - which provider started which child agent. * @mode emit */ 'subagent/start'(info: SubagentRunInfo): void /** * A subagent run settled — emitted when {@link SubagentRun.result} * resolves (any stop reason). Paired with {@link Events['subagent/start']}. + * @param info - the run identity plus stop reason and final output. * @mode emit */ 'subagent/end'(info: SubagentRunEndInfo): void @@ -129,6 +131,8 @@ export class SubagentService extends Service { * Register a provider under its `provider.name`. Throws {@link SubagentError} * (`DUPLICATE_PROVIDER`) if the name is already taken. Effect-scoped: disposed * with the calling fiber (HMR-safe). + * @param provider - the provider; its `name` is the registry key. + * @returns the disposer that unregisters the provider. */ registerProvider(provider: SubagentProvider): () => void { const dispose = this.ctx.effect(function* (this: SubagentService) { @@ -145,12 +149,19 @@ export class SubagentService extends Service { return () => void dispose() } - /** Look up a registered provider by name (`undefined` if absent). */ + /** + * Look up a registered provider by name (`undefined` if absent). + * @param name - the provider name as registered. + * @returns the provider, or undefined when the name is unknown. + */ getProvider(name: string): SubagentProvider | undefined { return this.providers.get(name) } - /** The names of all registered providers (insertion order). */ + /** + * The names of all registered providers (insertion order). + * @returns the registered provider names. + */ list(): string[] { return [...this.providers.keys()] } @@ -162,6 +173,9 @@ export class SubagentService extends Service { * for the first unmet one — fail loud, before any child is created), then * delegates to {@link SubagentProvider.start} and emits `subagent/start` / * `subagent/end` around the run. + * @param name - the provider to run on. + * @param request - the child's prompt, capabilities, and options. + * @returns the live run (its `result` resolves when the child settles). */ start(name: string, request: SubagentStartRequest): SubagentRun { const provider = this.providers.get(name) diff --git a/packages/web/web/src/index.ts b/packages/web/web/src/index.ts index 50150f6961..76c8065b30 100644 --- a/packages/web/web/src/index.ts +++ b/packages/web/web/src/index.ts @@ -129,6 +129,8 @@ export class WebService extends Service { * if its id is already registered for search. Returns a disposer; emits * `web/providers-change` after a successful register and again on dispose. * Disposed with the calling fiber. + * @param provider - the provider; its `id` is the registry key. + * @returns the disposer that unregisters the provider. */ registerSearchProvider(provider: WebSearchProvider): () => void { return this.registerProvider(this.searchProviders, provider) @@ -139,6 +141,8 @@ export class WebService extends Service { * if its id is already registered for fetch. Returns a disposer; emits * `web/providers-change` after a successful register and again on dispose. * Disposed with the calling fiber. + * @param provider - the provider; its `id` is the registry key. + * @returns the disposer that unregisters the provider. */ registerFetchProvider(provider: WebFetchProvider): () => void { return this.registerProvider(this.fetchProviders, provider) @@ -165,7 +169,10 @@ export class WebService extends Service { return () => void dispose() } - /** Search-capability selection status, derived live (never stored). */ + /** + * Search-capability selection status, derived live (never stored). + * @returns which provider would serve a search right now, or why none would. + */ searchStatus(): WebCapabilityStatus { return resolveStatus({ providers: this.searchProviders, @@ -173,7 +180,10 @@ export class WebService extends Service { }) } - /** Fetch-capability selection status, derived live (never stored). */ + /** + * Fetch-capability selection status, derived live (never stored). + * @returns which provider would serve a fetch right now, or why none would. + */ fetchStatus(): WebCapabilityStatus { return resolveStatus({ providers: this.fetchProviders, @@ -186,6 +196,9 @@ export class WebService extends Service { * time with the selection rules above; throws {@link WebError} when the * capability cannot run. The seam enforces `request.maxResults` on the result: * if the provider over-returns, `sources[]` is truncated and `truncated` set. + * @param request - the query plus result-shaping options. + * @param exec - the tool-execution context, forwarded to the provider. + * @returns the provider's results, capped to `request.maxResults`. */ async search(request: WebSearchRequest, exec?: WebExecContext): Promise { const provider = resolveProvider({ @@ -200,6 +213,9 @@ export class WebService extends Service { * Retrieve one URL through the selected provider. Resolves the provider at * call time with the selection rules above; throws {@link WebError} when the * capability cannot run. A non-2xx response is a result, not a throw. + * @param request - the URL plus retrieval options. + * @param exec - the tool-execution context, forwarded to the provider. + * @returns the retrieval outcome; non-2xx responses resolve descriptively. */ async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise { const provider = resolveProvider({ diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index c57f42ed8f..62cf2bbdc6 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -26,7 +26,18 @@ * Every harness event MUST carry an `@mode emit|waterfall|parallel|serial` tag * — the generator hard-errors on a missing tag, and where the signature shape is * conclusive (a trailing `next: () => …` parameter is structurally a waterfall) - * it asserts the tag agrees and hard-errors on a contradiction. The INHERITED + * it asserts the tag agrees and hard-errors on a contradiction. Beyond the tag, + * the walk enforces JSDoc COMPLETENESS on the whole harness surface (the + * jsdoc-completeness-gate RFC): every event and public service method carries + * description prose; every payload parameter has a non-empty `@param` (`this` + * receivers and the trailing waterfall `next` are exempt — next's semantics are + * documented once by the mode); a service method with a non-`void`/ + * `Promise` return carries a non-empty `@returns` and needs an EXPLICIT + * return type annotation (a pure-AST walk cannot classify an inferred return); + * a stale `@param` naming no real parameter errors. Violations aggregate into + * ONE error listing every offender. The tags are enforcement-only: parseJsDoc + * stops prose at the first block tag, so they never change the rendered + * catalog. The INHERITED * tier (cordis core + loader/hmr/timer) is pinned vendor source a plugin author * also sees; it is rendered tersely (name + one-line + source pointer) from a * curated table in this script, NOT elevated to the harness tier's prominence. @@ -147,8 +158,10 @@ function rawJsDoc(text: string, node: ts.Node): string { * present). Output obeys the repo's markdown conventions so the generated file * passes verify-md-wrap: each prose paragraph collapses to ONE physical line, * and a `-` bullet list is preserved with each item on its own single line - * (continuation lines folded in). `{@link Foo}` unwraps to `Foo`; `@`-tag lines - * other than `@mode` end the current prose run. + * (continuation lines folded in). `{@link Foo}` unwraps to `Foo`. Description + * prose ends at the FIRST block tag (standard JSDoc semantics): tag lines and + * their continuation lines are never prose, so `@param`/`@returns` blocks are + * invisible to the rendered catalog. */ function parseJsDoc(raw: string): { doc: string; mode: Mode | null } { const inner = raw @@ -157,6 +170,7 @@ function parseJsDoc(raw: string): { doc: string; mode: Mode | null } { .split('\n') .map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, '')) let mode: Mode | null = null + let inTags = false const blocks: string[] = [] let para: string[] = [] let list: string[] = [] @@ -178,8 +192,9 @@ function parseJsDoc(raw: string): { doc: string; mode: Mode | null } { } for (const line of inner) { const m = /^@mode\s+(emit|waterfall|parallel|serial)\s*$/.exec(line) - if (m) { mode = m[1] as Mode; continue } - if (line.startsWith('@')) { flushPara(); continue } // other tags end the prose + if (m) { mode = m[1] as Mode; flushPara(); inTags = true; continue } + if (line.startsWith('@')) { flushPara(); inTags = true; continue } + if (inTags) continue // block-tag territory: continuations are never prose if (line.trim() === '') { flushPara(); continue } if (/^-\s+/.test(line)) { // A list item starts: a pending paragraph (e.g. an intro line directly @@ -197,6 +212,60 @@ function parseJsDoc(raw: string): { doc: string; mode: Mode | null } { return { doc, mode } } +/** + * Parse the block tags of a raw JSDoc comment for the completeness checks: + * every `@param name — description` entry plus the `@returns` description. + * Standard JSDoc block-tag semantics — a tag's description runs across + * continuation lines until the next tag or a blank line, and the `-`/`—` + * separator after a param name is optional. `[name]` optional-brackets unwrap + * to `name`. Rendering never sees these: parseJsDoc stops prose at the first + * block tag. + */ +function parseTags(raw: string): { params: Map; returns: string | null } { + const inner = raw + .replace(/^\/\*\*/, '') + .replace(/\*\/$/, '') + .split('\n') + .map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, '')) + const params = new Map() + let returns: string | null = null + let sink: ((text: string) => void) | null = null + for (const line of inner) { + const param = /^@param\s+(\[?[\w$]+\]?)\s*(?:[-—–]\s*)?(.*)$/.exec(line) + if (param) { + const name = (param[1] ?? '').replace(/^\[|\]$/g, '') + let acc = param[2] ?? '' + params.set(name, acc) + sink = (t) => { acc = acc ? `${acc} ${t}` : t; params.set(name, acc) } + continue + } + const ret = /^@returns?(?:\s+[-—–]?\s*(.*))?$/.exec(line) + if (ret) { + let acc = ret[1] ?? '' + returns = acc + sink = (t) => { acc = acc ? `${acc} ${t}` : t; returns = acc } + continue + } + if (line.startsWith('@') || line.trim() === '') { sink = null; continue } + sink?.(line.trim()) + } + return { params, returns } +} + +/** + * Throw one aggregate error for every completeness violation a walk collected. + * Aggregation (vs the fail-fast the @mode check used to do) is deliberate: a + * remediation pass sees the whole list at once instead of replaying the gate + * once per offender. + */ +function reportViolations(violations: string[]): void { + if (violations.length === 0) return + throw new Error( + `gen-cordis-catalog: ${violations.length} JSDoc completeness violation(s) (see AGENTS.md):\n` + + violations.map(v => ` ${v}`).join('\n'), + ) +} + /** Find the `declare module 'cordis'` body in a source file, or null. */ function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null { for (const stmt of sf.statements) { @@ -215,10 +284,13 @@ function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.Source return sig.replace(/\s*;?\s*$/, '').replace(/\s+/g, ' ').trim() } -/** Walk every harness `interface Events` block and extract its events. - * `scanRoot` defaults to the repo root; tests pass a fixture dir. */ +/** Walk every harness `interface Events` block and extract its events, hard- + * erroring (aggregated) on any JSDoc-completeness violation: a missing/ + * contradicted `@mode`, missing description prose, or an undocumented payload + * parameter. `scanRoot` defaults to the repo root; tests pass a fixture dir. */ export function collectEvents(scanRoot: string = root): EventEntry[] { const entries: EventEntry[] = [] + const violations: string[] = [] for (const rel of globSync('packages/*/*/src/*.ts', { cwd: scanRoot }).sort()) { const abs = resolve(scanRoot, rel) const text = readFileSync(abs, 'utf8') @@ -232,33 +304,63 @@ export function collectEvents(scanRoot: string = root): EventEntry[] { if (!ts.isMethodSignature(member)) continue const name = ts.isStringLiteral(member.name) ? member.name.text : member.name.getText(sf) const signature = memberSignature(member, sf) - const { doc, mode } = parseJsDoc(rawJsDoc(text, member)) + const raw = rawJsDoc(text, member) + const { doc, mode } = parseJsDoc(raw) const src = pointer(rel, sf, member) + const where = `event '${name}' (${src})` if (!mode) { - throw new Error(`gen-cordis-catalog: event '${name}' (${src}) is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`) + violations.push(`${where} is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`) } // Conclusive structural check: a trailing `next: () => …` parameter is a // waterfall. (emit vs parallel vs serial is not structurally // distinguishable, so it is trusted from the tag.) const last = member.parameters.at(-1) const hasNext = !!last && last.name.getText(sf) === 'next' - if (hasNext && mode !== 'waterfall') { - throw new Error(`gen-cordis-catalog: event '${name}' (${src}) has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`) + if (mode && hasNext && mode !== 'waterfall') { + violations.push(`${where} has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`) } - if (!hasNext && mode === 'waterfall') { - throw new Error(`gen-cordis-catalog: event '${name}' (${src}) is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`) + if (mode && !hasNext && mode === 'waterfall') { + violations.push(`${where} is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`) } - entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src }) + if (!doc) violations.push(`${where} has no description prose. Say what happened / what a listener may do, above the block tags.`) + // Payload parameters need a non-empty @param each. Exempt the `this` + // receiver annotation (not payload) and the trailing waterfall `next` + // (mode machinery, documented once by @mode semantics). Documenting an + // exempt parameter anyway is allowed — only absence is checked. + const { params } = parseTags(raw) + for (const p of member.parameters) { + if (!ts.isIdentifier(p.name)) { + violations.push(`${where}: parameter '${p.name.getText(sf)}' is a binding pattern; the event surface needs simple identifier parameters so @param can name them.`) + continue + } + const pname = p.name.text + if (pname === 'this' || (hasNext && p === last)) continue + const desc = params.get(pname) + if (desc === undefined) violations.push(`${where} is missing @param ${pname}.`) + else if (!desc.trim()) violations.push(`${where}: @param ${pname} has an empty description.`) + } + for (const tag of params.keys()) { + if (!member.parameters.some(p => ts.isIdentifier(p.name) && p.name.text === tag)) { + violations.push(`${where}: @param ${tag} does not match any parameter (stale tag?).`) + } + } + if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src }) } } } + reportViolations(violations) return entries } -/** Walk every harness `interface Context` block + its service class. +/** Walk every harness `interface Context` block + its service class, hard- + * erroring (aggregated) on any JSDoc-completeness violation: a class or public + * method without JSDoc prose, an undocumented parameter, a stale `@param`, a + * missing `@returns` on a non-void method, or an inferred (unannotated) return + * type the pure-AST walk cannot classify. * `scanRoot` defaults to the repo root; tests pass a fixture dir. */ export function collectServices(scanRoot: string = root): ServiceEntry[] { const entries: ServiceEntry[] = [] + const violations: string[] = [] for (const rel of globSync('packages/*/*/src/index.ts', { cwd: scanRoot }).sort()) { const abs = resolve(scanRoot, rel) const text = readFileSync(abs, 'utf8') @@ -284,6 +386,8 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] { ) if (!cls) continue // a Pick-mixin member (e.g. timer helpers), not a class here const abstract = cls.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false + const clsDoc = parseJsDoc(rawJsDoc(text, cls)).doc + if (!clsDoc) violations.push(`service ctx.${key} (${pointer(rel, sf, cls)}): class ${type} has no JSDoc.`) const methods: string[] = [] for (const member of cls.members) { if (!ts.isMethodDeclaration(member)) continue @@ -300,17 +404,52 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] { const memberName = member.name.getText(sf) if (memberName.startsWith('[')) continue // computed/symbol members methods.push(memberSignature(member, sf)) + const where = `service method ctx.${key}.${memberName} (${pointer(rel, sf, member)})` + const raw = rawJsDoc(text, member) + if (!raw) { violations.push(`${where} has no JSDoc.`); continue } + if (!parseJsDoc(raw).doc) violations.push(`${where} has no description prose above its block tags.`) + const { params, returns } = parseTags(raw) + // Every parameter needs a non-empty @param; a `this` receiver + // annotation is not payload and is exempt. + for (const p of member.parameters) { + if (!ts.isIdentifier(p.name)) { + violations.push(`${where}: parameter '${p.name.getText(sf)}' is a binding pattern; the service surface needs simple identifier parameters so @param can name them.`) + continue + } + const pname = p.name.text + if (pname === 'this') continue + const desc = params.get(pname) + if (desc === undefined) violations.push(`${where} is missing @param ${pname}.`) + else if (!desc.trim()) violations.push(`${where}: @param ${pname} has an empty description.`) + } + for (const tag of params.keys()) { + if (!member.parameters.some(p => ts.isIdentifier(p.name) && p.name.text === tag)) { + violations.push(`${where}: @param ${tag} does not match any parameter (stale tag?).`) + } + } + // A non-void result needs a non-empty @returns. The return type must be + // ANNOTATED: a pure-AST walk cannot classify an inferred return. On a + // `void`/`Promise` method @returns stays optional (resolution + // timing can be worth documenting), never required. + const rt = member.type?.getText(sf).replace(/\s+/g, ' ') + if (rt === undefined) { + violations.push(`${where} has no return type annotation; annotate it explicitly so the gate can classify the result.`) + } else if (!/^(void|Promise)$/.test(rt)) { + if (returns === null) violations.push(`${where} is missing @returns (return type: ${rt}).`) + else if (!returns.trim()) violations.push(`${where}: @returns has an empty description.`) + } } entries.push({ key, type, abstract, - doc: parseJsDoc(rawJsDoc(text, cls)).doc, + doc: clsDoc, methods, source: pointer(rel, sf, cls), }) } } + reportViolations(violations) return entries.sort((a, b) => a.key.localeCompare(b.key)) }