Merge branch 'codex/simp-unify-agent-session-id' into codex/simp-ui-identity-residue

# Conflicts:
#	docs/event-producer-consumer.md
#	docs/module-graph.md
This commit is contained in:
Tianyi Cui
2026-07-15 16:26:12 +08:00
305 changed files with 8816 additions and 2144 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
"AGENTS.md": 1370,
"docs/AGENTS.md": 1100,
"docs/architecture.md": 1790,
"docs/cordis-primer.md": 550,
"docs/cordis-primer.md": 600,
"docs/defensive-patterns.md": 550,
"docs/testing.md": 800,
"examples/AGENTS.md": 200,
+9
View File
@@ -478,6 +478,15 @@ function walkSchemaExpr(
}
return
}
// A union of objects (discriminated union config): collect keys from all
// variants. Each variant is visited the same way as an intersect element.
if (method === 'union' && call.arguments[0] && ts.isArrayLiteralExpression(call.arguments[0])) {
for (const el of call.arguments[0].elements) {
const part = unwrapExpr(el)
if (ts.isCallExpression(part)) { visit(part); continue }
}
return
}
// A chained refinement (`z.object({…}).default(…)` etc.): the keys live on
// the call the chain hangs off — keep unwrapping toward it.
const base = unwrapExpr(call.expression.expression)
+250 -114
View File
@@ -5,7 +5,7 @@
* `--check` verifies the generated set.
*/
import { existsSync, globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, relative, resolve } from 'node:path'
import ts from 'typescript'
import { collectEvents, collectServices } from './gen-cordis-catalog.ts'
@@ -15,6 +15,7 @@ import {
graphNodeId as nodeId,
type PackageGraphNode,
} from './package-graph.ts'
import { TypeScriptProject } from './ts-project.ts'
const root = resolve(import.meta.dirname, '..')
type Pkg = PackageGraphNode
@@ -45,6 +46,14 @@ interface EventRelation {
listeners: Set<string>
}
interface PackageSource {
rel: string
pkg: string
sourceFile: ts.SourceFile
}
type EventReceiverKind = 'context' | 'agent-dispatch' | 'events-service'
const GROUP_ORDER = [
'util',
'llm',
@@ -242,54 +251,6 @@ 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' },
// Config startup failures have no live Agent carrier; AgentLoop resolves the
// callbacks directly to contain each synchronous throw and async rejection.
{ event: 'agent-loop/config-start-failed', pkg: 'agent-loop', 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' },
{ event: 'workflow/phase', pkg: 'workflow', method: 'events.dispatch' },
{ event: 'workflow/log', pkg: 'workflow', method: 'events.dispatch' },
{ event: 'workflow/agent-start', pkg: 'workflow', method: 'events.dispatch' },
{ event: 'workflow/agent-end', pkg: 'workflow', method: 'events.dispatch' },
{ 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.',
@@ -508,81 +469,256 @@ function renderAppComposition(example: AppExample): string {
return lines.join('\n')
}
function collectEventRelations(): Map<string, EventRelation> {
const out = new Map<string, EventRelation>()
const ensure = (event: string): EventRelation => {
const existing = out.get(event)
if (existing) return existing
const next = { dispatchers: new Map<string, Set<string>>(), listeners: new Set<string>() }
out.set(event, next)
return next
/** Collect event dispatch/listener relations from real cross-file receiver types. */
class EventRelationCollector {
private readonly relations = new Map<string, EventRelation>()
private readonly callSites = new Map<ts.SignatureDeclaration | ts.JSDocSignature, ts.CallExpression[]>()
private readonly contextType: ts.Type
private readonly agentDispatchType: ts.Type
private readonly eventsServiceType: ts.Type
constructor(
private readonly project: TypeScriptProject,
private readonly sources: readonly PackageSource[],
) {
this.contextType = this.declaredType('vendor/cordis/src/context.ts', 'Context')
this.agentDispatchType = this.declaredType('packages/core/agent/src/dispatch.ts', 'AgentEventDispatch')
this.eventsServiceType = this.declaredType('vendor/cordis/src/events.ts', 'EventsService')
this.indexCallSites()
}
for (const rel of globSync('packages/*/*/src/**/*.ts', { cwd: root }).sort()) {
const [, , leaf] = rel.split('/')
if (leaf === undefined) continue
const text = readFileSync(resolve(root, rel), 'utf8')
const sf = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true)
/** Return all event relations discovered from the Program. */
collect(): Map<string, EventRelation> {
for (const source of this.sources) this.visitSource(source)
return this.relations
}
/** Resolve one named class/interface declaration to its merged instance type. */
private declaredType(relativePath: string, name: string): ts.Type {
const sourceFile = this.project.sourceFile(relativePath)
const declaration = sourceFile.statements.find((statement): statement is ts.ClassDeclaration | ts.InterfaceDeclaration => {
return (ts.isClassDeclaration(statement) || ts.isInterfaceDeclaration(statement)) && statement.name?.text === name
})
const symbol = declaration?.name && this.project.checker.getSymbolAtLocation(declaration.name)
if (!symbol) throw new Error(`cannot resolve TypeScript type ${name} from ${relativePath}`)
return this.project.checker.getDeclaredTypeOfSymbol(symbol)
}
/** Index resolved local function calls for narrow argument-flow recovery. */
private indexCallSites(): void {
const visit = (node: ts.Node): void => {
if (ts.isCallExpression(node)) {
const declaration = this.project.checker.getResolvedSignature(node)?.declaration
if (declaration) {
const calls = this.callSites.get(declaration) ?? []
calls.push(node)
this.callSites.set(declaration, calls)
}
}
ts.forEachChild(node, visit)
}
for (const source of this.sources) visit(source.sourceFile)
}
/** Walk one package source file and classify event API calls by receiver type. */
private visitSource(source: PackageSource): void {
const visit = (node: ts.Node): void => {
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
const receiverKind = this.receiverKind(node.expression.expression)
const method = node.expression.name.text
if (!isCordisContextReceiver(node.expression, sf)) {
ts.forEachChild(node, visit)
return
}
if (method === 'on') {
const event = eventArg(node.arguments, method)
if (event) ensure(event).listeners.add(leaf)
} else if (method === 'emit' || method === 'parallel' || method === 'serial' || method === 'waterfall') {
const event = eventArg(node.arguments, method)
if (event) {
const relation = ensure(event)
const methods = relation.dispatchers.get(leaf) ?? new Set<string>()
methods.add(method)
relation.dispatchers.set(leaf, methods)
if (receiverKind === 'events-service' && method === 'dispatch') {
const argumentList = node.arguments[1]
if (argumentList) {
for (const event of this.eventNamesFromArgumentList(argumentList, new Set())) {
this.addDispatcher(event, source.pkg, 'events.dispatch')
}
}
} else if (receiverKind === 'context' || receiverKind === 'agent-dispatch') {
const eventNames = this.eventNamesFromCall(node, receiverKind)
if (method === 'on' || method === 'once') {
for (const event of eventNames) this.ensure(event).listeners.add(source.pkg)
} else if (method === 'emit' || method === 'parallel' || method === 'serial' || method === 'waterfall') {
for (const event of eventNames) this.addDispatcher(event, source.pkg, method)
}
}
}
ts.forEachChild(node, visit)
}
visit(sf)
visit(source.sourceFile)
}
for (const entry of DYNAMIC_EVENT_DISPATCHERS) {
const relation = ensure(entry.event)
const methods = relation.dispatchers.get(entry.pkg) ?? new Set<string>()
methods.add(entry.method)
relation.dispatchers.set(entry.pkg, methods)
/** Classify a receiver using assignability to the repository's actual event API types. */
private receiverKind(receiver: ts.Expression): EventReceiverKind | undefined {
const type = this.project.checker.getTypeAtLocation(receiver)
if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)) return undefined
if (this.project.checker.isTypeAssignableTo(type, this.eventsServiceType)) return 'events-service'
if (this.project.checker.isTypeAssignableTo(type, this.contextType)) return 'context'
if (this.project.checker.isTypeAssignableTo(type, this.agentDispatchType)) return 'agent-dispatch'
return undefined
}
for (const entry of DYNAMIC_EVENT_LISTENERS) {
ensure(entry.event).listeners.add(entry.pkg)
/** Resolve the event-name argument for Context and fused agent dispatch calls. */
private eventNamesFromCall(call: ts.CallExpression, receiverKind: Exclude<EventReceiverKind, 'events-service'>): Set<string> {
const candidates = receiverKind === 'context' ? call.arguments.slice(0, 2) : call.arguments.slice(0, 1)
for (const candidate of candidates) {
const values = this.finiteStringValues(candidate)
if (values) return values
}
return new Set()
}
/** Recover the event slot from the argument array handed to EventsService.dispatch(). */
private eventNamesFromArgumentList(expression: ts.Expression, seen: Set<ts.Node>): Set<string> {
const current = unwrapExpression(expression)
if (seen.has(current)) return new Set()
seen.add(current)
if (ts.isArrayLiteralExpression(current)) {
for (const element of current.elements.slice(0, 2)) {
if (ts.isOmittedExpression(element) || ts.isSpreadElement(element)) continue
const values = this.finiteStringValues(element)
if (values) return values
}
return new Set()
}
if (ts.isConditionalExpression(current)) {
return unionSets(
this.eventNamesFromArgumentList(current.whenTrue, new Set(seen)),
this.eventNamesFromArgumentList(current.whenFalse, new Set(seen)),
)
}
if (!ts.isIdentifier(current)) return new Set()
const symbol = this.project.checker.getSymbolAtLocation(current)
if (!symbol) return new Set()
const events = new Set<string>()
for (const declaration of symbol.declarations ?? []) {
if (ts.isVariableDeclaration(declaration) && declaration.initializer && isConstDeclaration(declaration)) {
addAll(events, this.eventNamesFromArgumentList(declaration.initializer, new Set(seen)))
} else if (ts.isParameter(declaration)) {
addAll(events, this.eventNamesFromParameter(declaration, seen))
}
}
return events
}
/** Follow a non-exported local helper parameter back to every resolved call site. */
private eventNamesFromParameter(parameter: ts.ParameterDeclaration, seen: Set<ts.Node>): Set<string> {
const owner = parameter.parent
if (!ts.isFunctionDeclaration(owner) || hasExportModifier(owner)) return new Set()
const index = owner.parameters.indexOf(parameter)
if (index < 0) return new Set()
const events = new Set<string>()
for (const call of this.callSites.get(owner) ?? []) {
const argument = call.arguments[index]
if (argument) addAll(events, this.eventNamesFromArgumentList(argument, new Set(seen)))
}
return events
}
/** Return a finite string-literal value set, rejecting widened and generic strings. */
private finiteStringValues(expression: ts.Expression): Set<string> | undefined {
const current = unwrapExpression(expression)
if (ts.isStringLiteralLike(current)) return new Set([current.text])
if (this.isForwardedAgentEventParameter(current)) return undefined
return finiteStringTypeValues(this.project.checker.getTypeAtLocation(current))
}
/** Reject the contextual parameter inside the AgentEventDispatch forwarding object. */
private isForwardedAgentEventParameter(expression: ts.Expression): boolean {
if (!ts.isIdentifier(expression)) return false
const declarations = this.project.checker.getSymbolAtLocation(expression)?.declarations ?? []
return declarations.some((declaration) => {
if (!ts.isParameter(declaration)) return false
const method = declaration.parent
if (!ts.isMethodDeclaration(method) || !ts.isObjectLiteralExpression(method.parent)) return false
const contextualType = this.project.checker.getContextualType(method.parent)
return contextualType !== undefined
&& this.project.checker.isTypeAssignableTo(contextualType, this.agentDispatchType)
})
}
/** Get or create one relation row. */
private ensure(event: string): EventRelation {
const existing = this.relations.get(event)
if (existing) return existing
const relation = { dispatchers: new Map<string, Set<string>>(), listeners: new Set<string>() }
this.relations.set(event, relation)
return relation
}
/** Add one dispatcher method without duplicating package/method labels. */
private addDispatcher(event: string, pkg: string, method: string): void {
const relation = this.ensure(event)
const methods = relation.dispatchers.get(pkg) ?? new Set<string>()
methods.add(method)
relation.dispatchers.set(pkg, methods)
}
}
/** Peel syntax-only wrappers that do not change an expression's runtime value. */
function unwrapExpression(expression: ts.Expression): ts.Expression {
let current = expression
while (
ts.isParenthesizedExpression(current)
|| ts.isAsExpression(current)
|| ts.isTypeAssertionExpression(current)
|| ts.isNonNullExpression(current)
|| ts.isSatisfiesExpression(current)
) {
current = current.expression
}
return current
}
/** Return every value only when a type is a closed string-literal union. */
function finiteStringTypeValues(type: ts.Type): Set<string> | undefined {
if (type.flags & ts.TypeFlags.StringLiteral) {
return new Set([(type as ts.StringLiteralType).value])
}
if (type.flags & ts.TypeFlags.Never) return new Set()
if (!type.isUnion()) return undefined
const values = new Set<string>()
for (const member of type.types) {
const memberValues = finiteStringTypeValues(member)
if (!memberValues) return undefined
addAll(values, memberValues)
}
return values
}
/** Return whether a variable declaration belongs to a const declaration list. */
function isConstDeclaration(declaration: ts.VariableDeclaration): boolean {
return (declaration.parent.flags & ts.NodeFlags.Const) !== 0
}
/** Return whether a declaration is visible to callers outside its source module. */
function hasExportModifier(node: ts.Node): boolean {
return ts.canHaveModifiers(node) && (ts.getModifiers(node)?.some((modifier) => {
return modifier.kind === ts.SyntaxKind.ExportKeyword || modifier.kind === ts.SyntaxKind.DefaultKeyword
}) ?? false)
}
/** Add every member of source to target. */
function addAll<T>(target: Set<T>, source: ReadonlySet<T>): void {
for (const value of source) target.add(value)
}
/** Return the union of two sets without mutating either input. */
function unionSets<T>(left: ReadonlySet<T>, right: ReadonlySet<T>): Set<T> {
const out = new Set(left)
addAll(out, right)
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)
if (target === 'ctx' || target === 'this.ctx') return true
// Scoped-dispatch spellings are conventional names. Keep this list in sync
// with renames or the relationship matrix can silently lose an edge.
return target === 'events' || target === 'childCtx' || target === 'this.loopCtx' || target === 'emitCtx'
}
function eventArg(args: ts.NodeArray<ts.Expression>, method: string): string | undefined {
if (method === 'waterfall') {
const arg = args.find(ts.isStringLiteralLike)
return arg?.text
}
const first = args[0]
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 collectEventRelations(): Map<string, EventRelation> {
const project = new TypeScriptProject(root)
const sources = project.sourceFiles().flatMap((sourceFile): PackageSource[] => {
const rel = project.relativePath(sourceFile)
const match = /^packages\/[^/]+\/([^/]+)\/src\/.+\.ts$/.exec(rel)
return match?.[1] ? [{ rel, pkg: match[1], sourceFile }] : []
}).sort((left, right) => left.rel.localeCompare(right.rel))
return new EventRelationCollector(project, sources).collect()
}
function relationPackages(map: Map<string, Set<string>>, pkgsByShort: Map<string, Pkg>): string {
@@ -602,10 +738,10 @@ function renderEventRelations(pkgs: Pkg[]): string {
const events = collectEvents()
const relations = collectEventRelations()
const pkgsByShort = new Map(pkgs.map(pkg => [pkg.short, pkg]))
const maintenance = 'hybrid generated: Cordis event declarations and most producer/listener edges are AST-scanned; dynamic dispatch sites are classified in `scripts/gen-doc-graphs.ts`'
const maintenance = 'generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program'
const lines = generatedHeader('Event Producer And Consumer Matrix')
lines.push(
'This matrix shows which packages dispatch each harness-owned event and which packages listen to it. It is intentionally a table rather than one large graph: events are many-to-many, and dense relation data is easier to review in rows. Dynamic dispatch overrides cover sites that deliberately bypass `ctx.emit`, such as subagent lifecycle containment.',
'This matrix shows which packages dispatch each harness-owned event and which packages listen to it. It is intentionally a table rather than one large graph: events are many-to-many, and dense relation data is easier to review in rows. Receiver and event-name types also cover contained dispatch sites that deliberately bypass `ctx.emit`, such as subagent lifecycle containment.',
'',
'| Event | Mode | Declared in | Dispatchers | Listeners |',
'| --- | --- | --- | --- | --- |',
@@ -615,7 +751,7 @@ function renderEventRelations(pkgs: Pkg[]): string {
lines.push(`| \`${event.name}\` | \`${event.mode}\` | ${sourceLink(event.source)} | ${relationPackages(relation.dispatchers, pkgsByShort)} | ${listenerPackages(relation.listeners, pkgsByShort)} |`)
}
// Every declared event needs a dispatcher: zero means dead vocabulary or an
// unrecognized dispatch spelling. Listener-free extension points remain valid.
// unrecognized semantic dispatch shape. Listener-free extension points remain valid.
const undispatched = [...events]
.filter(event => (relations.get(event.name)?.dispatchers.size ?? 0) === 0)
.map(event => event.name)
@@ -623,8 +759,8 @@ function renderEventRelations(pkgs: Pkg[]): string {
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)',
+ `${undispatched.map(name => `"${name}"`).join(', ')} — dead vocabulary, or a dispatch shape the semantic scan misses `
+ '(teach scripts/gen-doc-graphs.ts the shape)',
)
}
const declared = new Set(events.map(event => event.name))
+441
View File
@@ -0,0 +1,441 @@
/**
* Generate the dev-invariants scoped-event resolver map from the
* repository TypeScript Program.
*
* A scoped event declares `this: Scoped<Base>`. Real `scopeTarget(base, key)`
* calls establish the routing-key type for that base. The generator searches
* every event payload parameter and one property level for exactly one type
* equivalent to that key. Each generated resolver compiles against the merged
* `Events` parameter tuple. Zero matches require `@dshScopeScan unsupported`;
* multiple matches are ambiguous and always fail loud.
*
* `tsx scripts/gen-scoped-events.ts` -> write the generated source
* `tsx scripts/gen-scoped-events.ts --check` -> exit 1 when it is stale
*/
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import ts from 'typescript'
import { pointer, rawJsDoc } from './jsdoc.ts'
import { TypeScriptProject } from './ts-project.ts'
const root = resolve(import.meta.dirname, '..')
const OUT = 'packages/support/invariants/src/scoped-events.generated.ts'
const SCOPE_DOC_MARKER = 'Scope-filtered dispatch'
interface ScopeTargetContract {
baseType: ts.Type
keyType: ts.Type
source: string
}
interface SubjectCandidate {
path: string
parameter: number
property?: string
type: ts.Type
}
interface ScopedEventResolver {
event: string
candidate: SubjectCandidate | null
ownerPackage: string
}
interface ScopeTag {
present: boolean
unsupported: boolean
}
/** Program-backed analyzer and renderer for the generated scoped-event resolvers. */
class ScopedEventGenerator {
private readonly checker: ts.TypeChecker
private readonly packageSources: ts.SourceFile[]
private readonly scopeTargetDeclaration: ts.FunctionDeclaration
private readonly scopedSymbol: ts.Symbol
private readonly violations: string[] = []
private readonly packageNames = new Map<string, string>()
constructor(private readonly project: TypeScriptProject) {
this.checker = project.checker
this.packageSources = project.sourceFiles().filter((sourceFile) => {
return /^packages\/[^/]+\/[^/]+\/src\/.+\.ts$/.test(project.relativePath(sourceFile))
})
this.scopeTargetDeclaration = this.functionDeclaration(
'packages/core/scope/src/index.ts',
'scopeTarget',
)
this.scopedSymbol = this.typeAliasSymbol(
'packages/core/scope/src/index.ts',
'Scoped',
)
}
/** Render the complete generated TypeScript module or throw every contract violation. */
render(): string {
const contracts = this.collectScopeTargetContracts()
const resolvers = this.collectScopedEventResolvers(contracts)
if (this.violations.length > 0) {
throw new Error(
`gen-scoped-events: ${this.violations.length} scoped-event contract violation(s):\n`
+ this.violations.map(violation => ` - ${violation}`).join('\n'),
)
}
const ownerImports = [...new Set(resolvers.map(resolver => resolver.ownerPackage))]
.sort()
.map(packageName => `import type {} from ${quote(packageName)}`)
return [
'/**',
' * Generated scoped-event routing-subject resolvers for dsh-invariants.',
' * Do not edit by hand; run `pnpm run gen-scoped-events`.',
' *',
' * @module @deepseek-ai/dsh-invariants/scoped-events.generated',
' */',
'',
"import type { Events } from 'cordis'",
"import type { Scoped } from '@deepseek-ai/dsh-scope'",
...ownerImports,
'',
'type ScopedEventName = {',
' [K in keyof Events]: ThisParameterType<Events[K]> extends Scoped<object> ? K : never',
'}[keyof Events]',
'',
'type ScopedSubjectResolver = (args: readonly unknown[]) => unknown',
'',
'function adapt<K extends ScopedEventName>(',
' resolver: (args: Parameters<Events[K]>) => unknown,',
'): ScopedSubjectResolver {',
' return args => resolver(args as Parameters<Events[K]>)',
'}',
'',
'const scopedSubjectResolvers = Object.freeze({',
...resolvers.map(({ event, candidate }) => {
if (candidate === null) return ` '${event}': null,`
const subject = candidate.property === undefined
? `args[${candidate.parameter}]`
: `args[${candidate.parameter}].${candidate.property}`
return ` '${event}': adapt<'${event}'>(args => ${subject}),`
}),
'} as const satisfies Readonly<Record<ScopedEventName, ScopedSubjectResolver | null>>)',
'',
'const scopedSubjectResolverIndex: Readonly<Record<string, ScopedSubjectResolver | null>> = scopedSubjectResolvers',
'',
'/**',
' * Resolve the routing key named by one scoped event payload. A null',
' * resolver means the payload cannot expose its external routing key, so the',
' * invariant checks carrier presence only.',
' * @param event - runtime Cordis event name.',
' * @returns the generated subject resolver, null for presence-only,',
' * or undefined when the event is not scope-filtered.',
' */',
'export function scopedSubjectResolverFor(event: string): ScopedSubjectResolver | null | undefined {',
' return scopedSubjectResolverIndex[event]',
'}',
'',
].join('\n')
}
/** Resolve one named function declaration from a known source file. */
private functionDeclaration(relativePath: string, name: string): ts.FunctionDeclaration {
const sourceFile = this.project.sourceFile(relativePath)
const declaration = sourceFile.statements.find((statement): statement is ts.FunctionDeclaration => {
return ts.isFunctionDeclaration(statement) && statement.name?.text === name
})
if (!declaration) throw new Error(`gen-scoped-events: cannot resolve function ${name} from ${relativePath}`)
return declaration
}
/** Resolve one named type-alias symbol from a known source file. */
private typeAliasSymbol(relativePath: string, name: string): ts.Symbol {
const sourceFile = this.project.sourceFile(relativePath)
const declaration = sourceFile.statements.find((statement): statement is ts.TypeAliasDeclaration => {
return ts.isTypeAliasDeclaration(statement) && statement.name.text === name
})
const symbol = declaration && this.checker.getSymbolAtLocation(declaration.name)
if (!symbol) throw new Error(`gen-scoped-events: cannot resolve type ${name} from ${relativePath}`)
return symbol
}
/** Collect every real scopeTarget(base, key) base/key type contract. */
private collectScopeTargetContracts(): ScopeTargetContract[] {
const contracts: ScopeTargetContract[] = []
const visit = (sourceFile: ts.SourceFile, node: ts.Node): void => {
if (ts.isCallExpression(node)
&& this.checker.getResolvedSignature(node)?.declaration === this.scopeTargetDeclaration) {
const base = node.arguments[0]
const key = node.arguments[1]
if (!base || !key) {
const source = pointer(this.project.relativePath(sourceFile), sourceFile, node)
this.violations.push(`${source} calls scopeTarget without base and key arguments`)
} else {
contracts.push({
baseType: this.checker.getTypeAtLocation(base),
keyType: this.checker.getTypeAtLocation(key),
source: pointer(this.project.relativePath(sourceFile), sourceFile, node),
})
}
}
ts.forEachChild(node, (child) => { visit(sourceFile, child) })
}
for (const sourceFile of this.packageSources) visit(sourceFile, sourceFile)
return contracts
}
/** Collect every Events member and derive its generated resolver. */
private collectScopedEventResolvers(contracts: readonly ScopeTargetContract[]): ScopedEventResolver[] {
const resolvers: ScopedEventResolver[] = []
for (const sourceFile of this.packageSources) {
const rel = this.project.relativePath(sourceFile)
const ownerPackage = this.packageName(packageRootFor(rel))
const visit = (node: ts.Node): void => {
if (ts.isInterfaceDeclaration(node) && node.name.text === 'Events' && isCordisModuleInterface(node)) {
for (const member of node.members) {
if (!ts.isMethodSignature(member) || !ts.isStringLiteral(member.name)) continue
const event = member.name.text
const raw = rawJsDoc(sourceFile.text, member)
const where = `event '${event}' (${pointer(rel, sourceFile, member)})`
const tag = parseScopeTag(raw, where, this.violations)
const thisParameter = member.parameters.find(isThisParameter)
const scopedBase = thisParameter && this.scopedBaseType(thisParameter)
if (!scopedBase) {
if (raw.includes(SCOPE_DOC_MARKER)) {
this.violations.push(
`${where} documents scope-filtered dispatch but its signature has no this: Scoped<...> receiver`,
)
}
if (tag.present) {
this.violations.push(`${where} has @dshScopeScan metadata but is not a Scoped event`)
}
continue
}
if (!raw.includes(SCOPE_DOC_MARKER)) {
this.violations.push(
`${where} has this: Scoped<...> but its JSDoc does not explain "${SCOPE_DOC_MARKER}"`,
)
}
const keyType = this.routingKeyType(where, scopedBase, contracts)
if (!keyType) continue
const candidates = this.subjectCandidates(member)
.filter(candidate => this.typesEquivalent(candidate.type, keyType))
if (candidates.length > 1) {
this.violations.push(
`${where} has multiple routing-key candidates for ${this.typeText(keyType)}: `
+ candidates.map(candidate => `${candidate.path}: ${this.typeText(candidate.type)}`).join(', '),
)
continue
}
if (candidates.length === 0) {
if (!tag.unsupported) {
const keyLabel = this.typeText(keyType)
this.violations.push(
`${where} exposes no parameter or one-level property equivalent to routing key type ${keyLabel}; `
+ 'add @dshScopeScan unsupported only when the key is intentionally absent from the payload',
)
}
resolvers.push({ event, candidate: null, ownerPackage })
continue
}
if (tag.unsupported) {
this.violations.push(
`${where} has unnecessary @dshScopeScan unsupported; ${candidates[0]?.path} exposes the routing key`,
)
continue
}
resolvers.push({ event, candidate: candidates[0] ?? null, ownerPackage })
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
}
return resolvers.sort((left, right) => left.event.localeCompare(right.event))
}
/** Extract the Base type from one exact this: Scoped<Base> parameter. */
private scopedBaseType(parameter: ts.ParameterDeclaration): ts.Type | undefined {
const type = this.checker.getTypeAtLocation(parameter)
if (type.aliasSymbol !== this.scopedSymbol) return undefined
return type.aliasTypeArguments?.[0]
}
/** Resolve one unambiguous key type for a scoped carrier base. */
private routingKeyType(
where: string,
scopedBase: ts.Type,
contracts: readonly ScopeTargetContract[],
): ts.Type | undefined {
const matches = contracts.filter((contract) => {
return this.checker.isTypeAssignableTo(this.normalizedType(contract.baseType), this.normalizedType(scopedBase))
})
if (matches.length === 0) {
this.violations.push(
`${where} has no matching scopeTarget(base, key) call for carrier base ${this.typeText(scopedBase)}`,
)
return undefined
}
const keyTypes: ts.Type[] = []
for (const match of matches) {
if (!keyTypes.some(type => this.typesEquivalent(type, match.keyType))) keyTypes.push(match.keyType)
}
if (keyTypes.length > 1) {
this.violations.push(
`${where} carrier base ${this.typeText(scopedBase)} has inconsistent routing-key types: `
+ matches.map(match => `${this.typeText(match.keyType)} at ${match.source}`).join(', '),
)
return undefined
}
return keyTypes[0]
}
/** Enumerate every payload parameter and every accessible one-level property. */
private subjectCandidates(member: ts.MethodSignature): SubjectCandidate[] {
const candidates: SubjectCandidate[] = []
let runtimeIndex = 0
for (const parameter of member.parameters) {
if (isThisParameter(parameter)) continue
const directPath = `args[${runtimeIndex}]`
const parameterType = this.checker.getTypeAtLocation(parameter)
candidates.push({ path: directPath, parameter: runtimeIndex, type: parameterType })
for (const property of this.checker.getPropertiesOfType(this.normalizedType(parameterType))) {
const name = property.getName()
if (name.startsWith('__@') || hasNonPublicDeclaration(property)) continue
candidates.push({
path: `${directPath}.${name}`,
parameter: runtimeIndex,
property: name,
type: this.checker.getTypeOfSymbolAtLocation(property, parameter),
})
}
runtimeIndex += 1
}
return dedupeCandidates(candidates)
}
/** Read and cache one workspace package name. */
private packageName(packageRoot: string): string {
const cached = this.packageNames.get(packageRoot)
if (cached) return cached
const manifest: unknown = JSON.parse(readFileSync(resolve(root, packageRoot, 'package.json'), 'utf8'))
const name: unknown = typeof manifest === 'object' && manifest !== null
? Reflect.get(manifest, 'name')
: undefined
if (typeof name !== 'string') throw new Error(`gen-scoped-events: ${packageRoot}/package.json has no name`)
this.packageNames.set(packageRoot, name)
return name
}
/** Compare exact Program type identities after removing null and undefined. */
private typesEquivalent(left: ts.Type, right: ts.Type): boolean {
const normalizedLeft = this.normalizedType(left)
const normalizedRight = this.normalizedType(right)
if (normalizedLeft.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) return false
if (normalizedRight.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) return false
return normalizedLeft === normalizedRight
}
/** Remove null and undefined from a routing or candidate type. */
private normalizedType(type: ts.Type): ts.Type {
return this.checker.getNonNullableType(type)
}
/** Render a stable diagnostic type label. */
private typeText(type: ts.Type): string {
return this.checker.typeToString(type, undefined, ts.TypeFormatFlags.NoTruncation)
}
}
/** Return whether an Events interface is inside declare module 'cordis'. */
function isCordisModuleInterface(node: ts.InterfaceDeclaration): boolean {
const block = node.parent
const declaration = block.parent
return ts.isModuleBlock(block)
&& ts.isModuleDeclaration(declaration)
&& ts.isStringLiteral(declaration.name)
&& declaration.name.text === 'cordis'
}
/** Return whether a parameter is the explicit TypeScript this receiver. */
function isThisParameter(parameter: ts.ParameterDeclaration): boolean {
return ts.isIdentifier(parameter.name) && parameter.name.text === 'this'
}
/** Parse and validate the optional @dshScopeScan unsupported tag. */
function parseScopeTag(raw: string, where: string, violations: string[]): ScopeTag {
const tags = raw
.replace(/^\/\*\*/, '')
.replace(/\*\/$/, '')
.split('\n')
.map(line => line.replace(/^\s*\*?\s?/, '').trim())
.filter(line => line.startsWith('@dshScopeScan'))
if (tags.length > 1) violations.push(`${where} has multiple @dshScopeScan tags`)
if (tags.length === 0) return { present: false, unsupported: false }
const unsupported = tags[0] === '@dshScopeScan unsupported'
if (!unsupported) {
violations.push(
`${where} has invalid scoped-event scan metadata '${tags[0]}'; expected '@dshScopeScan unsupported'`,
)
}
return { present: true, unsupported }
}
/** Return whether a property has a private or protected declaration. */
function hasNonPublicDeclaration(symbol: ts.Symbol): boolean {
return (symbol.declarations ?? []).some((declaration) => {
if (!ts.canHaveModifiers(declaration)) return false
return ts.getModifiers(declaration)?.some((modifier) => {
return modifier.kind === ts.SyntaxKind.PrivateKeyword || modifier.kind === ts.SyntaxKind.ProtectedKeyword
}) ?? false
})
}
/** Deduplicate candidate paths contributed by merged/intersection types. */
function dedupeCandidates(candidates: readonly SubjectCandidate[]): SubjectCandidate[] {
const seen = new Set<string>()
return candidates.filter((candidate) => {
if (seen.has(candidate.path)) return false
seen.add(candidate.path)
return true
})
}
/** Return the workspace package root owning one package source file. */
function packageRootFor(relativePath: string): string {
const match = /^(packages\/[^/]+\/[^/]+)\/src\//.exec(relativePath)
if (!match?.[1]) throw new Error(`gen-scoped-events: cannot derive package root from ${relativePath}`)
return match[1]
}
/** Quote a generated property key as a single-quoted TypeScript string. */
function quote(value: string): string {
return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`
}
/**
* Render the generated scoped-event resolver module for one repository root.
* @param projectRoot - repository root carrying tsconfig.json.
* @returns complete generated TypeScript source.
*/
export function renderScopedEvents(projectRoot: string = root): string {
return new ScopedEventGenerator(new TypeScriptProject(projectRoot)).render()
}
/** Generate or freshness-check the fixed invariants source file. */
function main(): void {
const content = renderScopedEvents()
const output = resolve(root, OUT)
if (process.argv.includes('--check')) {
const committed = existsSync(output) ? readFileSync(output, 'utf8') : null
if (committed === content) {
console.log(`gen-scoped-events: ${OUT} is up to date.`)
return
}
console.error(`gen-scoped-events: ${OUT} is stale. Run \`pnpm run gen-scoped-events\` and commit it.`)
process.exit(1)
}
writeFileSync(output, content)
console.log(`gen-scoped-events: wrote ${OUT}.`)
}
if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
main()
}
+4 -1
View File
@@ -153,6 +153,7 @@ function gatesForMode(selected: Mode): Gate[] {
case 'pre-push':
return [
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
pnpmScript('test', 'test'),
pnpmScript('duplication', 'duplication'),
pnpmScript('snapshot', 'test:snapshot'),
@@ -168,6 +169,7 @@ function ciPrimaryGates(): Gate[] {
return [
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
pnpmScript('constraints', 'constraints'),
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
pnpmScript('typecheck', 'typecheck'),
lintGate(),
pnpmScript('duplication', 'duplication'),
@@ -191,6 +193,7 @@ function ciStaticGates(): Gate[] {
return [
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
pnpmScript('constraints', 'constraints'),
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
demoSmokeGate(),
...docSyncLeafGates(),
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
@@ -273,7 +276,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('scoped-events', 'verify-scoped-events', { label: 'scoped events' }),
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' }),
+1 -24
View File
@@ -703,7 +703,7 @@ def normalize_snapshot_value(
def scrub_snapshot_header(value: dict[object, object]) -> None:
"""Tokenize request-header bulk while retaining delta tool names."""
"""Tokenize full request-header bulk while retaining tool names."""
data = value.get("data")
if not isinstance(data, dict):
return
@@ -721,29 +721,6 @@ def scrub_snapshot_header(value: dict[object, object]) -> None:
]
if isinstance(header.get("messagePrefix"), list):
header["messagePrefix"] = ["{{messagePrefix}}" for _ in header["messagePrefix"]]
return
if value.get("type") != "request/header-delta":
return
system = data.get("system")
if isinstance(system, dict) and isinstance(system.get("insert"), list):
system["insert"] = ["{{system}}" for _ in system["insert"]]
tools = data.get("tools")
if isinstance(tools, dict):
for key in ("added", "changed"):
if isinstance(tools.get(key), list):
tools[key] = [scrub_snapshot_tool_schema(tool) for tool in tools[key]]
if isinstance(data.get("messagePrefix"), list):
data["messagePrefix"] = ["{{messagePrefix}}" for _ in data["messagePrefix"]]
def scrub_snapshot_tool_schema(value: object) -> object:
"""Keep a changed tool's name while tokenizing its schema bulk."""
if not isinstance(value, dict):
return value
return {
key: item if key == "name" else "{{tools}}"
for key, item in value.items()
}
def render_jsonl(records: list[object]) -> str:
@@ -408,8 +408,7 @@
],
"isError": false,
"meta": {
"logs": [],
"dispatches": 1
"logs": []
}
},
"sourceEventSeqs": [
@@ -1613,8 +1612,7 @@
],
"isError": false,
"meta": {
"logs": [],
"dispatches": 1
"logs": []
}
},
"sourceEventSeqs": [
@@ -22,7 +22,7 @@
{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}
{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}}
{"type":"tool/code-dispatch","seq":22,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"resultSummary":"42"}}
{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false,"meta":{"logs":[],"dispatches":1}},"sourceEventSeqs":[21],"surfaceOp":"append"}
{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[21],"surfaceOp":"append"}
{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}}
{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}
{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
+113
View File
@@ -0,0 +1,113 @@
/**
* Shared TypeScript Program construction for repository gates that need real
* cross-file symbols and types instead of isolated syntax trees.
*/
import { relative, resolve } from 'node:path'
import ts from 'typescript'
interface ProjectGraph {
rootNames: string[]
options: ts.CompilerOptions
}
const configHost: ts.ParseConfigFileHost = {
useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames,
readDirectory: (...args) => ts.sys.readDirectory(...args),
fileExists: fileName => ts.sys.fileExists(fileName),
readFile: fileName => ts.sys.readFile(fileName),
getCurrentDirectory: () => ts.sys.getCurrentDirectory(),
onUnRecoverableConfigFileDiagnostic(diagnostic) {
throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))
},
}
/** Parse a root tsconfig and flatten all referenced projects into one semantic graph. */
function loadProjectGraph(projectRoot: string): ProjectGraph {
const rootConfigPath = resolve(projectRoot, 'tsconfig.json')
const rootConfig = parseConfig(rootConfigPath)
const rootNames = new Set<string>()
const visited = new Set<string>()
const collect = (configPath: string, parsed: ts.ParsedCommandLine): void => {
if (visited.has(configPath)) return
visited.add(configPath)
for (const fileName of parsed.fileNames) rootNames.add(fileName)
for (const reference of parsed.projectReferences ?? []) {
const referencePath = ts.resolveProjectReferencePath(reference)
collect(referencePath, parseConfig(referencePath))
}
}
collect(rootConfigPath, rootConfig)
return {
rootNames: [...rootNames],
options: rootConfig.options,
}
}
/** Parse one config file and fail loud on any config diagnostic. */
function parseConfig(configPath: string): ts.ParsedCommandLine {
const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, configHost)
if (!parsed) throw new Error(`cannot parse TypeScript config ${configPath}`)
if (parsed.errors.length > 0) {
throw new Error(parsed.errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n'))
}
return parsed
}
/** Disable emit-only options after loading the root solution config. */
function semanticCompilerOptions(options: ts.CompilerOptions): ts.CompilerOptions {
return {
...options,
noEmit: true,
composite: false,
declaration: false,
declarationMap: false,
sourceMap: false,
incremental: false,
}
}
/** A repository-scoped TypeScript Program and its shared TypeChecker. */
export class TypeScriptProject {
/** The bound cross-file TypeScript program. */
readonly program: ts.Program
/** The checker shared by every semantic query in this project. */
readonly checker: ts.TypeChecker
constructor(private readonly projectRoot: string) {
const graph = loadProjectGraph(projectRoot)
this.program = ts.createProgram(graph.rootNames, semanticCompilerOptions(graph.options))
this.checker = this.program.getTypeChecker()
}
/**
* Return every source file loaded into the flattened root project graph.
* @returns program source files, including libraries and external dependencies.
*/
sourceFiles(): readonly ts.SourceFile[] {
return this.program.getSourceFiles()
}
/**
* Render a loaded source file relative to the project root.
* @param sourceFile - a source file from this project.
* @returns a slash-separated repository-relative path.
*/
relativePath(sourceFile: ts.SourceFile): string {
return relative(this.projectRoot, sourceFile.fileName).replaceAll('\\', '/')
}
/**
* Return one program source file by repository-relative path.
* @param relativePath - path relative to the project root.
* @returns the source file bound into this project.
* @throws if a requested root or imported source was not loaded.
*/
sourceFile(relativePath: string): ts.SourceFile {
const sourceFile = this.program.getSourceFile(resolve(this.projectRoot, relativePath))
if (!sourceFile) throw new Error(`TypeScript project did not load ${relativePath}`)
return sourceFile
}
}
-2
View File
@@ -100,7 +100,6 @@
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunResult", "source": "packages/code-runtime/code-runtime/src/types.ts" },
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeBindingNamespace", "source": "packages/code-runtime/code-runtime/src/types.ts" },
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeBindingFunction", "source": "packages/code-runtime/code-runtime/src/types.ts" },
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeLogEntry", "source": "packages/code-runtime/code-runtime/src/types.ts" },
{ "doc": "docs/core-data-structures/code-runtime.md", "symbol": "CodeRunFailure", "source": "packages/code-runtime/code-runtime/src/types.ts" },
{ "doc": "docs/core-data-structures/filesystem.md", "symbol": "FsTarget", "source": "packages/fs/fs/src/types.ts" },
@@ -141,7 +140,6 @@
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchRequest", "source": "packages/web/web/src/types.ts" },
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchResult", "source": "packages/web/web/src/types.ts" },
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchBody", "source": "packages/web/web/src/types.ts" },
{ "doc": "docs/core-data-structures/web.md", "symbol": "WebProviderStatus", "source": "packages/web/web/src/types.ts" },
{ "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowStartRequest", "source": "packages/workflow/workflow/src/types.ts" },
{ "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowMeta", "source": "packages/workflow/workflow/src/types.ts" },
+111
View File
@@ -0,0 +1,111 @@
/**
* Reject JavaScript expressions in Cordis Loader entry metadata.
*
* The Loader interpolates only a plugin entry's `config`; expression objects in
* fields such as `disabled` remain truthy data and silently change composition.
*/
import { globSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import * as yaml from 'js-yaml'
interface JsExpr {
__jsExpr: string
}
const root = resolve(import.meta.dirname, '..')
const metadataFields = ['id', 'name', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const
const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
kind: 'scalar',
resolve: data => typeof data === 'string',
construct: (data: unknown): JsExpr => {
if (typeof data !== 'string') throw new TypeError('!!js requires a scalar string')
return { __jsExpr: data }
},
})
const schema = yaml.JSON_SCHEMA.extend(jsExprType)
const files = globSync(['**/*cordis*.yml', '**/*cordis*.yaml'], {
cwd: root,
exclude: ['.claude/**', 'node_modules/**', 'vendor/**'],
}).sort()
const errors: string[] = []
for (const file of files) {
const document: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'), { schema })
if (!isUnknownArray(document)) {
errors.push(`${file}: root must be a Loader entry array`)
continue
}
for (let index = 0; index < document.length; index++) {
validateEntry(document[index], file, `[${index}]`)
}
}
if (errors.length > 0) {
console.error('verify-cordis-config: Loader entry metadata is static; move !!js under plugin config or select an explicit overlay.')
for (const error of errors) console.error(`- ${error}`)
process.exitCode = 1
} else {
console.log(`verify-cordis-config: ${files.length} config files passed.`)
}
function validateEntry(value: unknown, file: string, path: string): void {
if (!isRecord(value)) {
errors.push(`${file}${path}: entry must be an object`)
return
}
validateMetadata(value, file, path)
if ((value.group === true || value.name === '@cordisjs/plugin-group') && isUnknownArray(value.config)) {
for (let index = 0; index < value.config.length; index++) {
validateEntry(value.config[index], file, `${path}.config[${index}]`)
}
}
if (value.name !== '@cordisjs/plugin-include') return
const config = value.config
if (!isRecord(config) || !isUnknownArray(config.patches)) return
for (let index = 0; index < config.patches.length; index++) {
const patch = config.patches[index]
const patchPath = `${path}.config.patches[${index}]`
if (!isRecord(patch)) continue
validateMetadata(patch, file, patchPath)
if (!isUnknownArray(patch.insert)) continue
for (let insertIndex = 0; insertIndex < patch.insert.length; insertIndex++) {
validateEntry(patch.insert[insertIndex], file, `${patchPath}.insert[${insertIndex}]`)
}
}
}
function validateMetadata(entry: Record<string, unknown>, file: string, path: string): void {
for (const field of metadataFields) {
if (!(field in entry)) continue
const expressionPaths: string[] = []
collectExpressionPaths(entry[field], `${path}.${field}`, expressionPaths)
for (const expressionPath of expressionPaths) errors.push(`${file}${expressionPath}: !!js is not interpolated here`)
}
}
function collectExpressionPaths(value: unknown, path: string, output: string[]): void {
if (isJsExpr(value)) {
output.push(path)
return
}
if (isUnknownArray(value)) {
for (let index = 0; index < value.length; index++) collectExpressionPaths(value[index], `${path}[${index}]`, output)
return
}
if (!isRecord(value)) return
for (const [key, child] of Object.entries(value)) collectExpressionPaths(child, `${path}.${key}`, output)
}
function isJsExpr(value: unknown): value is JsExpr {
return isRecord(value) && typeof value.__jsExpr === 'string'
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object'
}
function isUnknownArray(value: unknown): value is unknown[] {
return Array.isArray(value)
}
@@ -55,6 +55,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/subagent/subagent-subprocess': { kind: 'indirect', reason: 'Only process-based subagent backends compose a child model request.' },
'packages/support/acp-snapshot': { kind: 'none', reason: 'The test harness observes and normalizes transcripts without changing live requests.' },
'packages/support/invariants': { kind: 'none', reason: 'The observer validates requests but never rewrites their context.' },
'packages/support/loader-smoke': { kind: 'none', reason: 'The test harness observes child-process streams without changing live requests.' },
'packages/support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' },
'packages/support/subagent-mock': { kind: 'indirect', reason: 'Only dsh-tool-subagent renders its configured test outcome.' },
'packages/ui/acp-agent': { kind: 'indirect', reason: 'The app bundle delegates request composition to dsh-agent-core and dsh-acp.' },
-69
View File
@@ -1,69 +0,0 @@
/**
* 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).
* Registry-subject notifications are intentionally unfiltered and belong 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.`)