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:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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"}}}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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.' },
|
||||
|
||||
@@ -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.`)
|
||||
Reference in New Issue
Block a user