Merge remote-tracking branch 'origin/master' into codex/rfc-subagent-background-tasks
# Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/event-producer-consumer.md # docs/module-graph.md # docs/tool-catalog.md # packages/bash/tool-bash/tests/integration.spec.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/core/agent-core/tests/agent-core.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/src/index.ts # packages/core/agent/README.md # packages/core/agent/src/index.ts # packages/core/agent/tests/agent.spec.ts # packages/subagent/subagent/README.md # packages/subagent/subagent/src/index.ts # packages/subagent/tool-subagent/README.md # packages/subagent/tool-subagent/src/index.ts # pnpm-lock.yaml # scripts/doc-budgets.manifest.json
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"AGENTS.md": 1802,
|
||||
"docs/AGENTS.md": 1315,
|
||||
"docs/architecture.md": 1760,
|
||||
"docs/architecture.md": 1790,
|
||||
"docs/cordis-primer.md": 550,
|
||||
"docs/defensive-patterns.md": 550,
|
||||
"docs/testing.md": 800,
|
||||
|
||||
@@ -87,11 +87,14 @@ export const LINK_MAP: Record<string, string> = {
|
||||
GenerateOptions: 'core.md',
|
||||
LlmCallConfig: 'core.md',
|
||||
SessionEvent: 'core.md',
|
||||
SessionStartSource: 'core.md',
|
||||
StreamChunk: 'llm-streaming.md',
|
||||
TurnEndReason: 'session.md',
|
||||
ToolDefinition: 'tools.md',
|
||||
ToolExecution: 'tools.md',
|
||||
ToolExecutionInput: 'tools.md',
|
||||
ToolExecutionResult: 'tools.md',
|
||||
ToolExecutionToken: 'tools.md',
|
||||
ApprovalOutcome: 'approval.md',
|
||||
ApprovalPolicy: 'approval.md',
|
||||
ApprovalRequest: 'approval.md',
|
||||
|
||||
+87
-11
@@ -124,10 +124,10 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
{
|
||||
key: 'tools',
|
||||
pkg: 'tools',
|
||||
title: 'Tool registry and execution waterfall',
|
||||
title: 'Tool registry and guarded execution pipeline',
|
||||
mode: 'core',
|
||||
consumers: ['agent-loop', 'tool-ask-user', 'tool-bash', 'tool-cordis', 'tool-fs', 'tool-skill', 'tool-subagent', 'tool-todo', 'tool-web', 'acp'],
|
||||
note: 'Registers tool definitions, exposes schemas to the prompt, and routes calls through tools/pre-execute and tools/post-execute.',
|
||||
note: 'Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation.',
|
||||
},
|
||||
{
|
||||
key: 'userInteraction',
|
||||
@@ -256,11 +256,34 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
]
|
||||
|
||||
const DYNAMIC_EVENT_DISPATCHERS: Array<{ event: string; pkg: string; method: string }> = [
|
||||
// Creation notifications preserve synchronous veto/rollback but observe
|
||||
// returned promises explicitly so async listener rejection is not unhandled.
|
||||
{ event: 'agent/created', pkg: 'agent', method: 'events.dispatch' },
|
||||
// Registry disposal reuses the stable carrier captured before entry commit
|
||||
// and contains each listener directly rather than rebuilding via agentEvents.
|
||||
{ event: 'agent/disposed', pkg: 'agent', method: 'events.dispatch' },
|
||||
{ event: 'session/created', pkg: 'session', method: 'events.dispatch' },
|
||||
// Session event callbacks are likewise resolved before the log push, then
|
||||
// invoked individually after commit so observer failures are contained.
|
||||
{ event: 'session/event', pkg: 'session', method: 'events.dispatch' },
|
||||
// Flush resolves the scoped callback set directly so internal instrumentation
|
||||
// cannot substitute the accepted session before parallel invocation.
|
||||
{ event: 'session/flush', pkg: 'session', method: 'events.dispatch' },
|
||||
// Session disposal uses direct callback resolution so teardown contains each
|
||||
// synchronous throw and returned-promise rejection independently.
|
||||
{ event: 'session/disposed', pkg: 'session', method: 'events.dispatch' },
|
||||
// tools/result uses ctx.events.dispatch directly so the registry can invoke
|
||||
// every synchronous observer while containing each callback independently.
|
||||
{ event: 'tools/result', pkg: 'tools', method: 'events.dispatch' },
|
||||
// Subagent lifecycle events intentionally bypass ctx.emit and call
|
||||
// ctx.events.dispatch directly so one throwing listener cannot starve later
|
||||
// listeners or strand an already-started child run.
|
||||
{ event: 'subagent/start', pkg: 'subagent', method: 'events.dispatch' },
|
||||
{ event: 'subagent/end', pkg: 'subagent', method: 'events.dispatch' },
|
||||
// provider-removed fires inside the provider registration's DISPOSER and
|
||||
// routes through the same contained dispatch (see emitLifecycle in
|
||||
// dsh-subagent), so the AST scan cannot attribute it either.
|
||||
{ event: 'subagent/provider-removed', pkg: 'subagent', method: 'events.dispatch' },
|
||||
// The workflow/* lifecycle events dispatch the same way, for the same
|
||||
// per-listener-containment reason (WorkflowService.emitWorkflowEvent).
|
||||
{ event: 'workflow/start', pkg: 'workflow', method: 'events.dispatch' },
|
||||
@@ -271,6 +294,12 @@ const DYNAMIC_EVENT_DISPATCHERS: Array<{ event: string; pkg: string; method: str
|
||||
{ event: 'workflow/end', pkg: 'workflow', method: 'events.dispatch' },
|
||||
]
|
||||
|
||||
const DYNAMIC_EVENT_LISTENERS: Array<{ event: string; pkg: string }> = [
|
||||
// The invariants oracle marks the session started from its global
|
||||
// internal/dispatch listener before product session-start callbacks run.
|
||||
{ event: 'agent/session-start', pkg: 'invariants' },
|
||||
]
|
||||
|
||||
function generatedHeader(title: string): string[] {
|
||||
return [
|
||||
'<!-- Generated by scripts/gen-doc-graphs.ts - do not edit by hand.',
|
||||
@@ -589,12 +618,28 @@ function collectEventRelations(): Map<string, EventRelation> {
|
||||
methods.add(entry.method)
|
||||
relation.dispatchers.set(entry.pkg, methods)
|
||||
}
|
||||
for (const entry of DYNAMIC_EVENT_LISTENERS) {
|
||||
ensure(entry.event).listeners.add(entry.pkg)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function isCordisContextReceiver(expr: ts.PropertyAccessExpression, sf: ts.SourceFile): boolean {
|
||||
// The chained fused-dispatch spelling: `agentEvents(ctx, agent).emit(…)` —
|
||||
// the receiver is a call expression, not an identifier.
|
||||
if (ts.isCallExpression(expr.expression) && expr.expression.expression.getText(sf) === 'agentEvents') {
|
||||
return true
|
||||
}
|
||||
const target = expr.expression.getText(sf)
|
||||
return target === 'ctx' || target === 'this.ctx'
|
||||
if (target === 'ctx' || target === 'this.ctx') return true
|
||||
// Scoped-dispatch spellings (the agent-scoping seam): the loop's fused
|
||||
// dispatcher (`events` from `agentEvents(ctx, agent)`), an agent's setup
|
||||
// context (`childCtx`), the agent's own context handle (`this.loopCtx`), and
|
||||
// the session store's captured dispatch context (`emitCtx`). Conventional
|
||||
// receiver names, pinned by the fused-dispatch convention; a rename here
|
||||
// must update this list (the producer/consumer matrix silently losing a
|
||||
// dispatcher or listener is the failure mode this list exists to prevent).
|
||||
return target === 'events' || target === 'childCtx' || target === 'this.loopCtx' || target === 'emitCtx'
|
||||
}
|
||||
|
||||
function eventArg(args: ts.NodeArray<ts.Expression>, method: string): string | undefined {
|
||||
@@ -603,7 +648,12 @@ function eventArg(args: ts.NodeArray<ts.Expression>, method: string): string | u
|
||||
return arg?.text
|
||||
}
|
||||
const first = args[0]
|
||||
return first && ts.isStringLiteralLike(first) ? first.text : undefined
|
||||
if (first && ts.isStringLiteralLike(first)) return first.text
|
||||
// Scope-carrier dispatch: `emit(carrier, 'event/name', …)` puts the event
|
||||
// name second. Accept a string literal in position 1 when position 0 is a
|
||||
// non-literal expression (the carrier).
|
||||
const second = args[1]
|
||||
return second && ts.isStringLiteralLike(second) ? second.text : undefined
|
||||
}
|
||||
|
||||
function relationPackages(map: Map<string, Set<string>>, pkgsByShort: Map<string, Pkg>): string {
|
||||
@@ -635,6 +685,24 @@ function renderEventRelations(pkgs: Pkg[]): string {
|
||||
const relation = relations.get(event.name) ?? { dispatchers: new Map<string, Set<string>>(), listeners: new Set<string>() }
|
||||
lines.push(`| \`${event.name}\` | \`${event.mode}\` | ${sourceLink(event.source)} | ${relationPackages(relation.dispatchers, pkgsByShort)} | ${listenerPackages(relation.listeners, pkgsByShort)} |`)
|
||||
}
|
||||
// Completeness guard: every DECLARED event must have at least one dispatcher
|
||||
// edge — a zero-dispatcher row is either dead vocabulary or (the observed
|
||||
// failure mode) a dispatch spelling the AST scan does not recognize, silently
|
||||
// dropping the producer from the matrix. Fail the generation loud instead:
|
||||
// teach the scan the new spelling, add a DYNAMIC_EVENT_DISPATCHERS override,
|
||||
// or remove the dead event. Zero LISTENERS is deliberately legal — an event
|
||||
// dispatched for out-of-repo plugins is an ordinary extension point.
|
||||
const undispatched = [...events]
|
||||
.filter(event => (relations.get(event.name)?.dispatchers.size ?? 0) === 0)
|
||||
.map(event => event.name)
|
||||
.sort()
|
||||
if (undispatched.length > 0) {
|
||||
throw new Error(
|
||||
`event-producer-consumer matrix: no dispatcher found for declared event${undispatched.length > 1 ? 's' : ''} `
|
||||
+ `${undispatched.map(name => `"${name}"`).join(', ')} — dead vocabulary, or a dispatch spelling the scan misses `
|
||||
+ '(teach scripts/gen-doc-graphs.ts the spelling or add a DYNAMIC_EVENT_DISPATCHERS override)',
|
||||
)
|
||||
}
|
||||
const declared = new Set(events.map(event => event.name))
|
||||
const extra = [...relations.keys()].filter(event => !declared.has(event)).sort()
|
||||
if (extra.length > 0) {
|
||||
@@ -689,6 +757,7 @@ function renderLifecycle(): string {
|
||||
' Tools-->>Session: tool-owned events when applicable',
|
||||
` Driver->>Session: ${mermaidCode('tool/result')} and ${mermaidCode('step/end')}`,
|
||||
` Driver->>Hooks: ${mermaidCode('agent/turn-continuation')} waterfall`,
|
||||
` Driver->>Hooks: ${mermaidCode('agent/turn-stop')} serial terminal checkpoint`,
|
||||
` Driver->>Session: ${mermaidCode('turn/end')}`,
|
||||
` Driver->>Persistence: ${mermaidCode('session/flush')} parallel checkpoint`,
|
||||
` Driver-->>SDK: ${mermaidCode('agent/status')} idle`,
|
||||
@@ -704,7 +773,7 @@ function renderToolPipeline(): string {
|
||||
const maintenance = 'curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs'
|
||||
return [
|
||||
...generatedHeader('Tool Execution Pipeline'),
|
||||
'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, and UI rendering fit without changing the loop. The key extension points are the `tools/pre-execute`, `tools/execute`, and `tools/post-execute` waterfalls.',
|
||||
'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering fit without changing the loop. The transformable extension points are the `tools/pre-execute`, `tools/execute`, and `tools/post-execute` waterfalls; monotonic guards and `tools/result` are the owner-enforced boundaries around them.',
|
||||
'',
|
||||
'```mermaid',
|
||||
'flowchart TD',
|
||||
@@ -712,24 +781,29 @@ function renderToolPipeline(): string {
|
||||
` toolCall["Session event: ${mermaidCode('tool/call')}<br/>logged before execution"]`,
|
||||
' presentCall["UI pending card<br/>presentCall(args)"]',
|
||||
` pre["${mermaidCode('tools/pre-execute')} waterfall<br/>hooks, permission, sandbox"]`,
|
||||
' denied["denied<br/>tool body skipped"]',
|
||||
' guards["Registered monotonic guards<br/>deny or abstain; identity protected"]',
|
||||
' denied["denied or approval refused<br/>tool body skipped"]',
|
||||
` approval["${mermaidCode('ctx.approval')} one-shot prompt<br/>absent or unanswerable: deny"]`,
|
||||
` around["${mermaidCode('tools/execute')} waterfall<br/>timeout, retry, metrics (around dispatch)"]`,
|
||||
' toolBody["Registered tool execute() body"]',
|
||||
` fsGate["${mermaidCode('fs/write-intent')} or ${mermaidCode('fs/edit-intent')}<br/>tool-fs mutations only"]`,
|
||||
` owned["Tool-owned session events<br/>${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/code-dispatch')}"]`,
|
||||
` post["${mermaidCode('tools/post-execute')} waterfall<br/>accept, block, replace, add context"]`,
|
||||
` final["${mermaidCode('tools/result')} synchronous notification<br/>frozen authoritative outcome"]`,
|
||||
' context["Buffered additionalContext<br/>context/message after all tool results"]',
|
||||
` toolResult["Session event: ${mermaidCode('tool/result')}<br/>single model-facing outcome"]`,
|
||||
' allResults["All calls in the step settled<br/>and tool/result events recorded"]',
|
||||
' presentResult["UI completed card<br/>presentResult(args, result)"]',
|
||||
' model --> toolCall',
|
||||
' toolCall --> presentCall',
|
||||
' toolCall --> pre',
|
||||
' pre -->|allow| around',
|
||||
' pre -->|allow| guards',
|
||||
' guards -->|allow| around',
|
||||
' guards -->|deny| denied',
|
||||
' around --> toolBody',
|
||||
' pre -->|deny| denied',
|
||||
' pre -->|ask| approval',
|
||||
' approval -->|allowed-once| around',
|
||||
' approval -->|allowed-once| guards',
|
||||
' approval -->|rejected, cancelled, unavailable| denied',
|
||||
' denied --> post',
|
||||
' toolBody --> fsGate',
|
||||
@@ -737,12 +811,14 @@ function renderToolPipeline(): string {
|
||||
' toolBody --> owned',
|
||||
' toolBody --> around',
|
||||
' around --> post',
|
||||
' post --> context',
|
||||
' post --> toolResult',
|
||||
' post --> final',
|
||||
' final --> toolResult',
|
||||
' toolResult --> presentResult',
|
||||
' toolResult --> allResults',
|
||||
' allResults --> context',
|
||||
'```',
|
||||
'',
|
||||
'Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and the approval seam\'s permission prompts live on the generic pre/post tool waterfalls; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. That split lets the same hooks observe bash, fs, web, todo, and subagent calls without coupling those tools to one policy service.',
|
||||
'Filesystem read-before-edit checks live below `tool-fs` on the `fs/*` event gate; hook bridges and approval-triggering permission policy enter through the generic pre/post tool waterfalls, while `ctx.approval` resolves an `ask` before the monotonic guards; owner policy that must not be reordered uses registered guards; and around-dispatch concerns like the tool-call timeout policy (`@deepseek-ai/dsh-timeout-policy`) wrap core dispatch on `tools/execute`. The synchronous `tools/result` notification observes the immutable final outcome after every transform, lossless-JSON validation, and outer error normalization. That split lets the same hooks observe bash, fs, web, todo, skill, and subagent calls without coupling those tools to one policy service. Code Mode rides the whole pipeline twice over: `run_code` is the reserved registry-owned transport whose body enters the pipeline, and each tool call its program makes re-enters `ctx.tools.execute()` — serialized one at a time, carrying the outer execution\'s opaque token for correlation, and logged as a `tool/code-dispatch` session event, with a deny surfacing to the program as a binding rejection (a sub-call\'s `additionalContext` is deliberately dropped — no safe outlet mid-run preserves call/result adjacency).',
|
||||
'',
|
||||
...maintenanceFooter(maintenance),
|
||||
].join('\n')
|
||||
|
||||
@@ -142,7 +142,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
toolsConfig: { mode: 'code' },
|
||||
async mount() {},
|
||||
note:
|
||||
'Registered by the tool registry itself under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the ONLY wire tool; the other registered tools are declared to the model as a generated TypeScript SDK prompt section instead, and a program calls them through port-bridged bindings that dispatch through the ordinary tools/pre-execute → tools/post-execute pipeline, one at a time.',
|
||||
'Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode RFC). Under `code` it is the registry\'s only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through serialized bindings that re-enter the complete guarded tool pipeline and link each nested execution to this outer result.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-bash',
|
||||
|
||||
@@ -143,6 +143,11 @@ function gatesForMode(selected: Mode): Gate[] {
|
||||
case 'node-compat':
|
||||
return [
|
||||
pnpmScript('typecheck', 'typecheck'),
|
||||
pnpmExec('source-worker-smoke', [
|
||||
'vitest',
|
||||
'run',
|
||||
'packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts',
|
||||
], { label: 'source worker smoke' }),
|
||||
]
|
||||
case 'pre-push':
|
||||
return [
|
||||
@@ -262,6 +267,7 @@ function docSyncLeafGates(): Gate[] {
|
||||
pnpmScript('config-catalog', 'verify-config-catalog', { label: 'config catalog' }),
|
||||
pnpmScript('persistence-catalog', 'verify-persistence-catalog', { label: 'persistence catalog' }),
|
||||
pnpmScript('doc-graphs', 'verify-doc-graphs', { label: 'doc graphs' }),
|
||||
pnpmScript('scoped-dispatch', 'verify-scoped-dispatch', { label: 'scoped dispatch' }),
|
||||
pnpmScript('markdown-wrap', 'verify-md-wrap', { label: 'markdown wrap' }),
|
||||
pnpmScript('markdown-links', 'verify-md-links', { label: 'markdown links' }),
|
||||
pnpmScript('doc-refs', 'verify-doc-refs', { label: 'doc refs' }),
|
||||
|
||||
@@ -14,8 +14,17 @@
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "HookContext", "source": "packages/core/agent/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "PromptDecision", "source": "packages/core/agent/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationDecision", "source": "packages/core/agent/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationStop", "source": "packages/core/agent/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/core.md", "symbol": "SessionStartSource", "source": "packages/core/agent/src/types.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/scope.md", "symbol": "ScopeKey", "source": "packages/core/scope/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/scope.md", "symbol": "Scoped", "source": "packages/core/scope/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/scope.md", "symbol": "Scope", "source": "packages/core/scope/src/index.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/system-prompt.md", "symbol": "AssembleContext", "source": "packages/core/system-prompt/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/system-prompt.md", "symbol": "PromptSection", "source": "packages/core/system-prompt/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/system-prompt.md", "symbol": "ToolProviderResult", "source": "packages/core/system-prompt/src/index.ts" },
|
||||
|
||||
{ "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "StreamChunk", "source": "packages/llm/llm/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "TokenUsage", "source": "packages/llm/llm/src/types.ts" },
|
||||
{ "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" },
|
||||
@@ -39,7 +48,11 @@
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/core/tools/src/schema.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaSpec", "source": "packages/core/tools/src/schema.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "InferArgs", "source": "packages/core/tools/src/schema.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionToken", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionInput", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecution", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolGuard", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolRestriction", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "ToolExecutionResult", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "PreToolDecision", "source": "packages/core/tools/src/index.ts" },
|
||||
{ "doc": "docs/core-data-structures/tools.md", "symbol": "PostToolDecision", "source": "packages/core/tools/src/index.ts" },
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Scoped-dispatch drift gate: the set of scope-filtered events is declared in
|
||||
* TWO places that must never diverge — the dev-invariants runtime table (the
|
||||
* `scopedSubject` map in `packages/support/invariants/src/index.ts`, which
|
||||
* enforces carriers at dispatch time) and the event declarations' JSDoc (the
|
||||
* "Scope-filtered dispatch" sentence rendered into the events catalog, which
|
||||
* tells plugin authors what a scoped listener will and won't hear). An event
|
||||
* added to one side without the other either silently escapes runtime
|
||||
* enforcement or documents filtering that never happens; this gate fails the
|
||||
* build instead.
|
||||
*
|
||||
* Sources of truth: the invariant table is parsed from the invariants source;
|
||||
* the documented set is parsed from every `declare module 'cordis'` Events
|
||||
* JSDoc in packages/*\/*\/src carrying the marker sentence. Registry-subject
|
||||
* notifications (`tools/change`, `system-prompt/change`, `subagent/provider-*`)
|
||||
* are deliberately unfiltered and must appear in NEITHER set.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
/** The marker sentence every scope-filtered event's JSDoc carries. */
|
||||
const MARKER = 'Scope-filtered dispatch'
|
||||
|
||||
/** Events that are deliberately UNFILTERED registry-subject notifications. */
|
||||
const REGISTRY_SUBJECT = new Set(['tools/change', 'system-prompt/change', 'subagent/provider-added', 'subagent/provider-removed'])
|
||||
|
||||
function invariantTable(): Set<string> {
|
||||
const source = readFileSync(resolve(root, 'packages/support/invariants/src/index.ts'), 'utf8')
|
||||
const start = source.indexOf('const scopedSubject')
|
||||
if (start < 0) throw new Error('verify-scoped-dispatch: cannot find the scopedSubject table in dsh-invariants')
|
||||
const block = source.slice(start, source.indexOf('}', start))
|
||||
return new Set([...block.matchAll(/'([a-z-]+\/[a-z-]+)':/g)].flatMap(match => match[1] === undefined ? [] : [match[1]]))
|
||||
}
|
||||
|
||||
function documentedSet(): Set<string> {
|
||||
const documented = new Set<string>()
|
||||
for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: root })) {
|
||||
const source = readFileSync(resolve(root, rel), 'utf8')
|
||||
if (!source.includes(MARKER)) continue
|
||||
// Each event declaration: a JSDoc block followed by the quoted event name.
|
||||
// Tolerate `//` comment lines between the JSDoc and the declaration
|
||||
// (e.g. an inline TODO under the doc block).
|
||||
for (const match of source.matchAll(/\/\*\*([\s\S]*?)\*\/\s*\n(?:\s*\/\/[^\n]*\n)*\s*'([a-z-]+\/[a-z-]+)'\(/g)) {
|
||||
const [, doc, event] = match
|
||||
if (doc === undefined || event === undefined) continue
|
||||
if (doc.includes(MARKER)) documented.add(event)
|
||||
}
|
||||
}
|
||||
return documented
|
||||
}
|
||||
|
||||
const table = invariantTable()
|
||||
const documented = documentedSet()
|
||||
|
||||
const problems: string[] = []
|
||||
for (const event of table) {
|
||||
if (!documented.has(event)) {
|
||||
problems.push(`"${event}" is enforced by the dev-invariants carrier table but its declaration JSDoc carries no "${MARKER}" sentence — document the filtering plugin authors will observe.`)
|
||||
}
|
||||
if (REGISTRY_SUBJECT.has(event)) {
|
||||
problems.push(`"${event}" is a registry-subject notification (deliberately unfiltered) but appears in the dev-invariants carrier table.`)
|
||||
}
|
||||
}
|
||||
for (const event of documented) {
|
||||
if (!table.has(event)) {
|
||||
problems.push(`"${event}" documents scope-filtered dispatch but is missing from the dev-invariants carrier table (packages/support/invariants) — a bare dispatch of it would silently revert to global delivery.`)
|
||||
}
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
console.error(`verify-scoped-dispatch: ${problems.length} drift(s) between the invariant table and the documented scoped-event set:`)
|
||||
for (const problem of problems) console.error(` - ${problem}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`verify-scoped-dispatch: ${table.size} scope-filtered event(s) consistent between the invariant table and the declaration docs.`)
|
||||
Reference in New Issue
Block a user