Gate JSDoc completeness on every package export

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

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

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

RFC: docs/rfc/implemented/process/2026-07-06-export-surface-jsdoc-gate.md
This commit is contained in:
Tianyi Cui
2026-07-06 22:09:30 +08:00
parent 1c999804d8
commit cd9737d569
92 changed files with 1802 additions and 289 deletions

View File

@@ -22,6 +22,10 @@ import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
* the agent/* event taxonomy — plugins never need this class.
*/
export class ReactLoopAgent implements Agent {
/**
* The queued + steering FIFOs behind {@link send}/{@link steer}. Public so
* the driver loop can drain it; {@link cancel} clears it wholesale.
*/
readonly inbox = new Inbox()
private _status: AgentStatus = 'idle'
@@ -256,6 +260,8 @@ export class ReactLoopAgent implements Agent {
* promise (unblocking the idle wait), releases any `whenIdle` waiters, and
* aborts the current request if any. The returned `agent.done` promise
* resolves once the loop exits.
* @returns the disposer — idempotent and infallible (it runs inside the
* fiber's LIFO disposal chain, where a throw would skip later disposers).
*/
start(): () => void {
this.done = runLoop(this.ctx, this, {

View File

@@ -24,30 +24,47 @@ export class Inbox {
private steeringMessages: InboxMessage[] = []
private wakeup: (() => void) | undefined
/** Resolves when a queued message arrives (used by the idle loop). */
/** True while queued messages are pending — read by the idle wait's fast path and the loop's turn-start checks. */
get hasQueued(): boolean {
return this.queuedMessages.length > 0
}
/** True while steering messages are pending — read by `cancel()`'s arm gate and the loop's stop-override check. */
get hasSteering(): boolean {
return this.steeringMessages.length > 0
}
/**
* Add a message to the queued FIFO and wake a parked {@link waitForQueued}.
* @param message - the message to queue for the next turn start.
*/
enqueue(message: InboxMessage): void {
this.queuedMessages.push(message)
this.wakeup?.()
}
/**
* Add a message to the steering FIFO. Deliberately no wakeup: steering is
* drained between steps of a running turn, never by the idle wait —
* `Agent.steer()` on an idle agent falls back to `send()` instead.
* @param message - the message to inject between steps of the running turn.
*/
steer(message: InboxMessage): void {
this.steeringMessages.push(message)
}
/** Drain all queued messages (turn start). */
/**
* Drain all queued messages (turn start).
* @returns the drained messages in arrival order; the queued FIFO is left empty.
*/
drainQueued(): InboxMessage[] {
return this.queuedMessages.splice(0)
}
/** Drain all steering messages (between steps). */
/**
* Drain all steering messages (between steps).
* @returns the drained messages in arrival order; the steering FIFO is left empty.
*/
drainSteering(): InboxMessage[] {
return this.steeringMessages.splice(0)
}
@@ -62,7 +79,12 @@ export class Inbox {
this.steeringMessages.length = 0
}
/** Wait until a queued message arrives or `cancel` resolves. */
/**
* Wait until a queued message arrives or `cancel` resolves.
* @param cancel - a promise whose resolution abandons the wait without a
* message (the driver loop passes the agent's disposed promise so a parked
* loop can exit).
*/
waitForQueued(cancel: Promise<void>): Promise<void> {
if (this.hasQueued) return Promise.resolve()
const { promise, resolve } = Promise.withResolvers<void>()

View File

@@ -29,6 +29,10 @@ declare module 'cordis' {
}
}
/**
* Plugin config: the agents to create — or resume, via `resumeSessionId` —
* declaratively at startup, so a cordis.yml deployment needs no code.
*/
export interface Config {
/** Agents created from configuration at startup. */
agents: (AgentOptions & {

View File

@@ -185,6 +185,9 @@ export interface LoopHandle {
* re-enqueue leftover steering as queued ⟵ steering is never stranded
* idle (emit agent/status) unless more queued
* ```
* @param ctx - the plugin context the loop reaches events (agent/…, session/flush) and services (systemPrompt, llm, tools) through.
* @param agent - the agent this invocation drives for its whole lifetime (its inbox, session, and options).
* @param handle - the bridge to the agent's mutable state: status/abort setters plus the disposal and cancel-marker reads.
*/
export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle): Promise<void> {
// Per-instance transmission bookkeeping: whether THIS loop instance has
@@ -875,7 +878,11 @@ function withoutToolCalls(message: Message): Message {
return { ...message, content: message.content.filter(block => block.type !== 'tool-call') }
}
/** The last turn number in a (possibly seeded) session log, or 0. */
/**
* The last turn number in a (possibly seeded) session log, or 0.
* @param session - the session whose log is scanned for the latest `turn/start`.
* @returns the latest `turn/start`'s turn number, or 0 when the log has none (the next turn is this plus one).
*/
export function lastTurnNumber(session: Session): number {
const lastStart = session.events.findLast(event => event.type === 'turn/start')
return lastStart?.data.turn ?? 0
@@ -889,6 +896,8 @@ export function lastTurnNumber(session: Session): number {
* returns to idle), so status is not a reliable open-turn signal. Used by
* `inject()` to choose between appending into an open turn vs. wrapping the
* injection in its own one-shot turn (the turn-enclosure RFC).
* @param session - the session whose log is inspected.
* @returns true when the log's last turn boundary is a `turn/start` with no matching `turn/end` yet.
*/
export function isTurnOpen(session: Session): boolean {
const last = session.events.findLast(e => e.type === 'turn/start' || e.type === 'turn/end')

View File

@@ -19,7 +19,10 @@ export interface TransmissionLog {
loggedHeader: boolean
}
/** Fresh bookkeeping for a newly-started loop instance. */
/**
* Fresh bookkeeping for a newly-started loop instance.
* @returns state with `loggedHeader` false, so the instance's first request appends an anchoring snapshot.
*/
export function createTransmissionLog(): TransmissionLog {
return { loggedHeader: false }
}

View File

@@ -50,7 +50,11 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
/** Identifies one live agent in the registry. */
export type AgentId = Branded<'AgentId'>
/** Brand a string as an {@link AgentId}. */
/**
* Brand a string as an {@link AgentId}.
* @param id - the raw agent id string.
* @returns the same string, branded (a compile-time cast — no runtime cost).
*/
export function AgentId(id: string): AgentId {
return id as AgentId
}
@@ -80,10 +84,22 @@ export interface AgentOptions {
model?: string
}
/**
* Options for {@link Agent.send}/{@link Agent.steer}/{@link Agent.inject}. An
* absent `source` resolves to `{ kind: 'user' }`, so a plugin supplying content
* must label itself here or its message is recorded as a user prompt (see
* {@link HookContext} on why that label is load-bearing).
*/
export interface SendOptions {
source?: MessageSource
}
/**
* An agent's lifecycle state, emitted on every transition as `agent/status`:
* `idle` (parked, waiting for queued work), `running` (a turn is in progress),
* `disposed` (terminal — no transition leaves it, and `send`/`steer`/`inject`
* throw).
*/
export type AgentStatus = 'idle' | 'running' | 'disposed'
/**

View File

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

View File

@@ -153,10 +153,15 @@ export class Session {
this.header = header ?? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
}
/**
* The append-only event log, exposed live by reference (readonly-typed, not
* a snapshot): later appends are visible through the same array.
*/
get events(): readonly SessionEvent[] {
return this.log
}
/** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */
get seq(): number {
return this.log.length
}
@@ -175,6 +180,9 @@ export class Session {
* declare how it joins the surface, the sole source of derived history) and
* rejected by the compiler for non-surface types like `turn/start` or
* `assistant/chunk`.
* @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
* `data` that entered the log, so reading `event.data` back sees the logged
* value, never the caller's still-mutable input.
* @throws if `data` is not losslessly JSON-serializable (BigInt, function,
* symbol, undefined, non-finite number, circular ref, or an exotic object
* like Map/Set/Date). The event log is the durable source of truth, so this

View File

@@ -40,6 +40,10 @@ export type JsonValue = null | boolean | number | string | JsonValue[] | { [key:
* hiding under a symbol/non-enumerable key cannot make the round-trip lossy.
* Getters are invoked during the check (again as `JSON.stringify` would), so the
* contract is for plain data records, not objects with side-effecting accessors.
* @param value - the candidate event data to test.
* @param seen - objects on the current descent path, for circular-reference
* detection; the recursion threads it — callers omit it.
* @returns true when `value` survives a JSON round-trip losslessly.
*/
export function isJsonValue(value: unknown, seen: Set<object> = new Set()): boolean {
if (value === null) return true

View File

@@ -54,6 +54,8 @@ import type { SessionEvent } from './types.ts'
* Only the LAST turn can be open: the invariants plugin guarantees a `turn/end`
* before any later `turn/start`, so an interior open turn is impossible in a
* valid committed log. Likewise at most one step is open within that turn.
* @param events - the loaded durable log to scan (a valid committed prefix, possibly with a crash tail).
* @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced.
*/
export function interruptedTurnClosers(events: readonly SessionEvent[]): SessionEvent[] {
let openTurn: number | null = null

View File

@@ -29,6 +29,8 @@ const SURFACE_EVENT_TYPES = new Set<string>([
* surface-eligible event that is MISSING its mandatory marker (e.g. validating
* a seed/load log); use {@link isSurfaceEvent} to narrow to a fully-formed
* {@link SurfaceEvent} with `surfaceOp` present.
* @param type - the event type string to test.
* @returns true when the type is one of the five message-producing types.
*/
export function isSurfaceEligibleType(type: string): boolean {
return SURFACE_EVENT_TYPES.has(type)
@@ -38,6 +40,8 @@ export function isSurfaceEligibleType(type: string): boolean {
* Narrow a {@link SessionEvent} to {@link SurfaceEvent}: checks that the
* event's `type` is surface-eligible AND that `surfaceOp` is present.
* The narrowed type has mandatory {@link SurfaceOp}.
* @param event - the event to narrow.
* @returns true when the event is surface-eligible and carries its `surfaceOp` marker.
*/
export function isSurfaceEvent(event: SessionEvent): event is SurfaceEvent {
if (!SURFACE_EVENT_TYPES.has(event.type)) return false

View File

@@ -74,6 +74,12 @@ function nodeDelta(event: SessionEvent): number {
* surface successor (`SurfaceNode.next`), or `null` when `end` is the tail —
* for the cut after `end`.
*
* @param nodes - the surface linked list in head→tail order.
* @param events - the session log each node's `seq` indexes into.
* @param beforeSeq - names the cut (the node it sits immediately before);
* `null` — or any seq not on the surface — means the after-tail cut.
* @returns true when every `tool-call` before the cut is answered before it
* (the unanswered-call depth at the cut is zero).
* @throws if the surface prefix drives the unanswered-call depth negative — a
* `tool/result` with no preceding open `tool-call` on the surface. That is a
* corrupt surface (a structural invariant violation), surfaced loudly here

View File

@@ -4,7 +4,11 @@ import type { CallId, ContentBlock, LlmCallConfig, MessageSource, StreamChunk, T
/** Identifies one session in the store (and its persistence artifacts). */
export type SessionId = Branded<'SessionId'>
/** Brand a string as a {@link SessionId}. */
/**
* Brand a string as a {@link SessionId}.
* @param id - the raw session id string.
* @returns the same string, branded (a compile-time cast — no runtime cost).
*/
export function SessionId(id: string): SessionId {
return id as SessionId
}
@@ -102,6 +106,7 @@ export interface TurnTriggerMap {
injection: { kind: 'injection'; source: MessageSource }
}
/** The union over {@link TurnTriggerMap} — what started a turn; plugins extend it by merging variants into the map. */
export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap]
/**
@@ -156,6 +161,7 @@ export interface TurnEndReasonMap {
interrupted: { kind: 'interrupted' }
}
/** The union over {@link TurnEndReasonMap} — why a turn ended; plugins extend it by merging variants into the map. */
export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap]
/**
@@ -361,6 +367,7 @@ export interface SessionEventMap {
'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig }
}
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
export type SessionEventType = keyof SessionEventMap
/**

View File

@@ -110,6 +110,7 @@ const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/
/** A complete `{{...}}` reference group at the scan position (validated after). */
const GROUP_AT = /^\{\{([^{}]*)\}\}/
/** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.persona} for its contract). */
export interface Config {
/**
* The deployment's persona — the ONE deployment-authored fragment of the
@@ -138,6 +139,10 @@ export interface Config {
* while a `}}` still follows (e.g. `{{{model}}}`, `{{a{b}}`) all throw. A
* lone `{{` with no `}}` anywhere after it is ordinary prose and passes
* through verbatim. Substituted values are never re-scanned.
* @param assembly - the assembly to render (typically the awaited result of
* {@link SystemPrompt.assemble}); only `sections` and `variables` are read.
* @returns the full system prompt text; `''` when every section renders empty
* (the caller then sends no system prompt at all).
*/
export function renderPrompt(assembly: PromptAssembly): string {
return assembly.sections

View File

@@ -155,6 +155,9 @@ export interface JsonSchemaObject {
* `properties`, `required` array).
*
* This is a plain function — no schemastery or other framework dependency.
* @param spec - the author-facing per-property schema to convert.
* @returns the wire-format JSON Schema; the top-level `required` array is
* omitted entirely when no property is marked required.
*/
export function schemaSpecToJsonSchema(spec: SchemaSpec): JsonSchemaObject {
const properties: Record<string, unknown> = {}
@@ -269,6 +272,9 @@ function checkSpec(spec: SchemaSpec, value: unknown, path: string): string[] {
* keys are allowed (no `additionalProperties: false`); `default` is not
* applied; an `object`/`array` prop without `properties`/`items` only
* type-checks; `enum` is membership (strings only).
* @param spec - the declared parameter schema to validate against.
* @param args - the model-generated arguments, however malformed.
* @returns the violation messages in declaration order; empty means valid.
*/
export function validateArgs(spec: SchemaSpec, args: unknown): string[] {
return checkSpec(spec, args, '')
@@ -340,6 +346,13 @@ export interface DefineToolOptions<S extends SchemaSpec> {
* Raw JSON-Schema tool definitions (from MCP servers) are still accepted
* by `ToolRegistry.register()` directly — `defineTool` is sugar for
* first-party plugin authors.
* @param options - the tool's name, description, typed parameter schema,
* execute body, and optional presenters.
* @returns a registry-ready {@link ToolDefinition}: its `execute` validates the
* raw args first (throwing {@link ToolArgsError} on mismatch, which the
* registry turns into an isError result), and its presenters validate softly
* (returning undefined on mismatch, since replay may feed them older-schema
* args).
*/
export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>): ToolDefinition {
// Object-literal execute methods don't use `this`; the reference is safe.