import type { EnumDef, EnumValue, SchemaBase } from './dsl.js'; import type { DtoMessage } from './dto.js'; import type { DomainEventSchema } from './domain-event.js'; import { UnexpectedException } from './exception.js'; import type { ExceptionSchema } from './exception.js'; import type { MethodSchema } from './method.js'; import type { ConvertMethodSchema } from './convert.js'; import type { DaoMethodSchema } from './dao.js'; import type { ServiceMethodSchema } from './service.js'; import type { ThirdServiceMethodSchema } from './third-service.js'; import type { UtilsMethodSchema } from './utils.js'; // Flow model: nodes are executors, control flow lives on edges. A FlowNode is // an ordered sequence of method invocations (a basic block); when a method in // the sequence throws, the rest does not run. A GuardNode is a gate executor: // its checks are ordered decisions, the first check that holds routes out // (return or throw). A TryNode is a protected region: its body, every catch // handler, and the optional finally are all sub-flows — the body's exception // ends are matched against the catches by name, a handler's exception ends // rethrow out of the region, and all fall-through paths continue via the // TryNode's outgoing edges. // // Every flow has a built-in return exit (flow.returnEnd) plus any number of // exception exits: explicit defineExceptionEnd nodes (targets of typed throws // edges) or ends synthesized from guard checks. A guard exits implicitly — a // check returning routes to the return end, a check throwing routes to the // flow's exception end carrying the same name; no edges needed. A step's // declared exceptions (methods' contracts and TryNode rethrows) must be // routed by a typed throws edge or an untyped exception edge (catch-all). // // Data flows through slots (a compiler-like register file): the built-in args // slot carries the flow input and needs no registration, named slots carry // derived objects and are declared via defineSlots, and calls bind slots to // method args/results (converting implicitly when contracts differ). /** A method a flow step can invoke: contract references, or a pure descriptor * (MethodSchema) for the period before the contract file exists. * Third-party methods are their own contract (ThirdServiceMethodSchema) — * they can never bind a flow. */ export type FlowMethodRef = | MethodSchema | ConvertMethodSchema | DaoMethodSchema | ServiceMethodSchema | ThirdServiceMethodSchema | UtilsMethodSchema; /** One named data slot of a flow — a register holding a whole object (like a * compiler's register file). The built-in args slot carries the flow input * (flow.args) and needs no registration: parameters exist in every flow. * Named slots carry derived objects and are declared by binding a method's * args/results message. The declared type is documentation — passing a slot * to a method with a different contract converts implicitly. Slots are scoped * to their flow: they cross into a sub-flow only through the shared input * (flow.args) and out of it only through the wrapping node's reads/writes * declarations. */ export interface FlowSlot { name: string; /** The contract message this slot was declared with (documentation only — * call sites convert implicitly). */ type?: unknown; description?: string; /** Field access (e.g. slots.args.amt): resolved at access time against the * slot's declared message fields, returning a SlotFieldRef for the * comparison builders. Unknown fields throw with the declared field list. */ [field: string]: unknown; } /** A field access on a slot (slots.args.amt): the slot plus the resolved * field object from the slot's declared message. */ export interface SlotFieldRef { slot: FlowSlot; /** The resolved field (DtoField | Field) — carries its schema for codegen. */ field: unknown; } /** A flow's slot registers: the built-in args slot plus named slots. */ export type FlowSlots = { args: FlowSlot } & Record; /** Declare a flow's named slots — each bound to the message of a method's * args/results (the slot's type). The built-in args slot (the flow input) * is added automatically: parameters need no registration. To make its * fields accessible (slots.args.amt), declare its input message via the * `args` key — that declares the slot's type, not a new slot. */ export function defineSlots>( slots: T, ): { [K in keyof T]: FlowSlot & { name: K } } & { args: FlowSlot } { const wrap = (slot: FlowSlot): FlowSlot => { const proxy = new Proxy(slot, { get(target, prop, receiver) { if (typeof prop === 'symbol') return Reflect.get(target, prop, receiver); // Slot metadata wins over same-named message fields; reflection keys // (then/toJSON and Object.prototype members) keep plain object // behavior so serialization, promises, and console output never throw. if ( prop === 'type' || prop === 'description' || prop === 'then' || prop === 'toJSON' || prop in target ) { return Reflect.get(target, prop, receiver); } const typeObj = target.type as | { fields?: Record; columns?: Array<{ name: string }> } | undefined; const fields = slotFields(typeObj); if (fields !== undefined && Object.prototype.hasOwnProperty.call(fields, prop)) { return { slot: proxy, field: fields[prop] }; } throw new Error( `slot "${target.name}" has no field "${prop}" — its type declares: ${ fields ? Object.keys(fields).join(', ') : 'no fields' }`, ); }, }); return proxy; }; const registry: Record = Object.create(null); for (const key of Object.keys(slots)) { if (key === 'args') { registry.args = wrap({ name: 'args', type: slots.args, description: 'flow input (flow.args)' }); warnShadowedFields(registry.args); continue; } registry[key] = wrap({ name: key, type: slots[key] }); warnShadowedFields(registry[key]); } if (registry.args === undefined) { registry.args = wrap({ name: 'args', description: 'flow input (flow.args)' }); } return registry as { [K in keyof T]: FlowSlot & { name: K } } & { args: FlowSlot }; } // A message field named type/name/description is shadowed by slot metadata and // unreachable via dot access (slots.args.type reads the message, not the // field) — warn instead of failing: the field may never be needed. function warnShadowedFields(slot: FlowSlot): void { const fields = slotFields(slot.type); if (fields === undefined) return; const shadowed = ['type', 'name', 'description'].filter((k) => k in fields); if (shadowed.length > 0) { console.warn( `slot "${slot.name}": message fields ${shadowed.join(', ')} are shadowed by slot metadata and unreachable via field access`, ); } } /** Field map of a slot's declared type: dto/third messages carry `fields`, * entity rows carry `columns` (named Fields). */ function slotFields(type: unknown): Record | undefined { if (typeof type !== 'object' || type === null) return undefined; const t = type as { fields?: Record; columns?: Array<{ name: string }> }; if (t.fields !== undefined) return t.fields; if (t.columns !== undefined) { return Object.fromEntries(t.columns.map((c) => [c.name, c])); } return undefined; } /** A method call with slot bindings: the arg slots are passed to the method * (converted implicitly when the contract differs), the result is assigned * to the result slot. A plain method ref (or an invoke with no bindings) is * a call with args = [slots.args] and no result — the common case. */ export interface FlowCall { method: FlowMethodRef; /** Slots passed as the method's args — the input slot or derived slots. * A single-message contract takes the one slot; multiple slots are for * multi-param signatures (not modeled yet). */ args?: FlowSlot[]; /** Slot assigned from the method's results. */ result?: FlowSlot; } export function invoke( method: FlowMethodRef, options: { args?: FlowSlot[]; result?: FlowSlot } = {}, ): FlowCall { return { method, args: options.args, result: options.result }; } /** A node method: a plain contract reference or a call with slot bindings. */ export type FlowNodeMethodRef = FlowMethodRef | FlowCall; export function isCall(m: FlowNodeMethodRef | GuardCondition): m is FlowCall { return 'method' in m; } /** The underlying method of a (possibly bound) reference. */ export function methodOf(m: FlowNodeMethodRef): FlowMethodRef { return isCall(m) ? m.method : m; } /** Comparison operators of a structured condition: ordering, (in)equality * against a literal or enum value, and null checks. */ export type CompareOp = 'lt' | 'le' | 'gt' | 'ge' | 'eq' | 'ne' | 'isNull' | 'isNotNull'; /** A field comparison: a slot field (slots.args.amt) against a literal or an * enum value — the machine-readable form of a when text. isNull/isNotNull * take no value; the others require one. */ export interface Comparison { kind: 'comparison'; op: CompareOp; /** Field-level comparisons bind a slot field; slot-level null checks * (isNull/isNotNull on the slot itself) bind the slot directly. */ field: SlotFieldRef | FlowSlot; /** Compared-against value; null checks take none. */ value?: string | number | EnumValue; } /** The machine-readable condition behind a guard check or a branch edge * (optional): a utils predicate call (its boolean result decides), a field * comparison, or a composite (not/and/or over sub-conditions). */ export type GuardCondition = FlowCall | Comparison | ConditionGroup; /** A composite condition: not (exactly one sub-condition), and/or (two or * more). Renderers parenthesize sub-groups, so nesting stays unambiguous. */ export interface ConditionGroup { kind: 'not' | 'and' | 'or'; conds: GuardCondition[]; } export function isConditionGroup(c: unknown): c is ConditionGroup { return ( typeof c === 'object' && c !== null && ((c as { kind?: unknown }).kind === 'not' || (c as { kind?: unknown }).kind === 'and' || (c as { kind?: unknown }).kind === 'or') && Array.isArray((c as { conds?: unknown }).conds) ); } /** Negation — the only way to invert a condition (e.g. !predicate). */ export function not(...conds: GuardCondition[]): ConditionGroup { if (conds.length !== 1) { throw new Error('not: takes exactly one condition'); } return { kind: 'not', conds }; } /** Conjunction — every sub-condition must hold. */ export function and(...conds: GuardCondition[]): ConditionGroup { if (conds.length < 2) { throw new Error('and: requires at least two conditions'); } return { kind: 'and', conds }; } /** Disjunction — at least one sub-condition holds. */ export function or(...conds: GuardCondition[]): ConditionGroup { if (conds.length < 2) { throw new Error('or: requires at least two conditions'); } return { kind: 'or', conds }; } /** True when the condition operand is the slot itself (slot-level null check), * not a field access on it. Slot metadata (name) lives on the proxy target and * reads without field interception; a field access resolves to { slot, field }. */ export function isFlowSlot(v: unknown): v is FlowSlot { return typeof v === 'object' && v !== null && typeof (v as FlowSlot).name === 'string'; } /** True when the slot carries a scalar Field (its declared type has a jsType) * rather than a message — scalar slots support full comparisons * (gt(slots.total, 100)); message slots only slot-level null checks. */ export function isScalarSlot(slot: FlowSlot): boolean { const t = slot.type as { jsType?: unknown } | undefined; return typeof t === 'object' && t !== null && t.jsType !== undefined; } function comparison(op: CompareOp, field: unknown, value?: string | number | EnumValue): Comparison { if (isFlowSlot(field)) { const nullOp = op === 'isNull' || op === 'isNotNull'; if (nullOp) { if (value !== undefined) { throw new Error(`${op}: takes no value`); } } else { if (!isScalarSlot(field)) { throw new Error(`${op}: a slot-level check only supports isNull/isNotNull — field comparisons need slots.args.amt`); } if (value === undefined) { throw new Error(`${op}: requires a value`); } } return { kind: 'comparison', op, field, value }; } const ref = field as Partial | null; if (typeof ref !== 'object' || ref === null || typeof ref.slot !== 'object' || typeof ref.field !== 'object') { throw new Error(`${op}: field must be a slot field access like slots.args.amt`); } const nullOp = op === 'isNull' || op === 'isNotNull'; if (nullOp !== (value === undefined)) { throw new Error(`${op}: ${nullOp ? 'takes no' : 'requires a'} value`); } return { kind: 'comparison', op, field: { slot: ref.slot, field: ref.field }, value }; } export function lt(field: unknown, value: string | number): Comparison { return comparison('lt', field, value); } export function le(field: unknown, value: string | number): Comparison { return comparison('le', field, value); } export function gt(field: unknown, value: string | number): Comparison { return comparison('gt', field, value); } export function ge(field: unknown, value: string | number): Comparison { return comparison('ge', field, value); } export function eq(field: unknown, value: string | number | EnumValue): Comparison { return comparison('eq', field, value); } export function ne(field: unknown, value: string | number | EnumValue): Comparison { return comparison('ne', field, value); } export function isNull(field: unknown): Comparison { return comparison('isNull', field); } export function isNotNull(field: unknown): Comparison { return comparison('isNotNull', field); } /** An enum value referenced by symbol — the compared-against target of eq/ne * (e.g. eq(slots.args.state, enumValue(PayState, 'success'))). */ export function enumValue(def: EnumDef, symbol: string): EnumValue { const v = def.values.find((v) => v.symbol === symbol); if (v === undefined) { throw new Error(`enum ${def.jsName} has no value "${symbol}"`); } return v; } /** Event publication: a flow node may publish one domain event. The outbox * write joins the node's surrounding transaction — the event is durable with * the node's other side effects. The payload slot defaults to the flow input * (slots.args) when omitted. */ export interface FlowPublish { event: DomainEventSchema; /** The slot carrying the event payload. */ payload?: FlowSlot; } export interface FlowNode extends SchemaBase { /** Optional sub-flow. When present, entering this node runs the sub-flow; * after the sub-flow reaches any terminal node, the outer flow continues * via this node's outgoing edges. Sub-flows nest recursively. A plain * sub-flow may not declare exception ends — it is structure only; use a * tryNode for a protected region. */ flow?: FlowSchema; /** Ordered execution sequence (basic block): methods run in order; if one * throws, the remaining methods are skipped. A plain reference is a call * receiving the input slot (slots.args) — the common case. */ methods?: FlowNodeMethodRef[]; /** A domain event published by this node (same transaction as its methods). */ publish?: FlowPublish; /** Slots this node reads to decide its outgoing branches — a semantic * declaration, not enforced per branch. */ reads?: FlowSlot[]; /** Slots this node assigns without a method call (constructions). */ writes?: FlowSlot[]; } // Plain FlowNodes carry no `type` field — the discriminant against // GuardNode/TryNode/FlowEnd is its absence (see the is* predicates). /** One gate check: when the condition holds, either return (early return to * the flow's returnEnd) or throw the given exception (routed implicitly to * the flow's exception end carrying the same name). Exactly one of * return/exception must be set. */ export interface GuardCheck { /** Condition description shown on the branch edge. */ when: string; /** The check returns early (to the flow's returnEnd) — no edge needed. */ return?: boolean; /** The check throws this exception — routed implicitly to the flow's * exception end carrying the same name (no edge needed). */ exception?: ExceptionSchema; /** Slots the check reads to decide. */ reads?: FlowSlot[]; /** Machine-readable condition behind `when` (optional): a utils predicate * call or a field comparison. Omitted when the condition stays descriptive. */ check?: GuardCondition; } /** Gate executor: an ordered set of checks. The first check whose condition * holds routes out implicitly (return to the flow's return end, throw to the * matching exception end); when none holds, the guard falls through its * normal outgoing edges (linked with edge like any node). A guard is the * graph form of a run of `if (bad) throw / return` guards. */ export interface GuardNode extends SchemaBase { type: 'guard'; /** Ordered decision sequence: methods run in order; if one throws, the * remaining methods are skipped. */ methods?: FlowNodeMethodRef[]; /** Ordered checks — first hit wins. */ checks: GuardCheck[]; } /** One catch route of a TryNode: body exception type → handler sub-flow. * First match wins, UnexpectedException is the catch-all and must come * last. The handler's exception ends rethrow out of the TryNode. */ export interface TryCatch { /** Exception this catch handles — must match a body exception end. */ exception: ExceptionSchema; /** Handler sub-flow: its return end falls through, its exception ends * rethrow (declared as the TryNode's own throws). */ handler: FlowSchema; description?: string; } /** Protected region: body, catch handlers, and optional finally are all * sub-flows. Entering the TryNode runs the body; the body's exception ends * are matched against the catches, every catch handler and the body's normal * completion pass through finally (when present), then continue via the * TryNode's outgoing edges. Handler exception ends rethrow out of the region. */ export interface TryNode extends SchemaBase { type: 'try'; /** The protected region. Its exception ends are the region's throw sites. */ body: FlowSchema; /** Ordered catch routes; UnexpectedException (catch-all) must be last. */ catches: TryCatch[]; /** Cleanup sub-flow: may not declare exception ends (Java finally rules). */ finally?: FlowSchema; } /** One branch of an ifNode: when the check holds, control moves to `to`. */ export interface IfCase { /** Branch label (rendered on the branch line). */ when: string; /** Machine-readable condition — required (the ifNode's whole purpose). */ check: GuardCondition; /** Jump target: a step (node/guard/tryNode/ifNode). Flow exits — return * and throw — belong to guard checks, so ends are rejected. */ to: FlowNodeOrEnd; } /** Decision node — the jump twin of a guard: ordered cases evaluated * first-hit, the first check that holds routes to its target; when none * holds, control goes to `else`. All exits are internal (cases and else), * so the node has no outgoing edges. A guard decides exits (return/throw), * an ifNode decides where control goes next (steps only). */ export interface IfNode extends SchemaBase { type: 'if'; /** Ordered branches — first hit wins. */ cases: IfCase[]; /** Default branch: a step (never a flow exit). */ else: FlowNodeOrEnd; } /** Decision node: ordered cases (check → target) plus a default branch. */ export function ifNode( name: string, options: { cases: IfCase[]; else: FlowNodeOrEnd; description?: string; }, ): IfNode { return { type: 'if', name, description: options.description, cases: options.cases, else: options.else, }; } /** Any node an edge can start from. */ export type FlowStep = FlowNode | GuardNode | TryNode | IfNode; /** Any node an edge can point at: a step or a flow exit. */ export type FlowNodeOrEnd = FlowStep | FlowEnd; /** A flow exit: normal return or uncaught exception. Never an edge start. */ export interface FlowEnd extends SchemaBase { type: 'return' | 'exception'; /** Uncaught exception this exit throws — set when type='exception'. */ exception?: ExceptionSchema; } /** The flow's exception end carrying the given exception name — the implicit * target of a guard check throwing it. Undefined when the flow has no such * end (guard checks synthesize one at definition time, so this only happens * for hand-assembled flows). */ export function findExceptionEnd(schema: FlowSchema, exceptionName: string): FlowEnd | undefined { return schema.nodes.find( (n): n is FlowEnd => isEnd(n) && n.type === 'exception' && n.exception?.name === exceptionName, ); } /** Where a throw of `exceptionName` from step `n` lands: the target of a typed * throws edge (first match), or an untyped exception edge as catch-all. * Undefined when no route exists (validation error). Guards route implicitly * — see findExceptionEnd. * @deprecated no internal use remains; kept for API compatibility. */ export function resolveThrowTarget(schema: FlowSchema, n: FlowStep, exceptionName: string): FlowNodeOrEnd | undefined { for (const e of schema.edges) { if (e.start !== n) continue; if (e.throws && e.throws.name === exceptionName) return e.end; } for (const e of schema.edges) { if (e.start === n && e.exception === true && e.throws === undefined) return e.end; } return undefined; } export interface FlowEdge extends SchemaBase { /** Trigger condition; undefined = default path (success/normal). */ when?: string; start: FlowStep; end: FlowNodeOrEnd; /** Exception path (rendered dashed); without `throws` it is a catch-all. */ exception?: boolean; /** Typed exception propagation: this edge carries the named exception * (rendered dashed with a `throw X` label). Must target an exception end. * Carrying one of a guard's own check exceptions is rejected — guards * route those implicitly. */ throws?: ExceptionSchema; /** Machine-readable condition behind `when` (optional): a utils predicate * call or a field comparison. */ check?: GuardCondition; } export interface FlowSchema extends SchemaBase { /** Entry node. */ start: FlowStep; /** Default exit — every flow has one; edge(node, flow.returnEnd) is a return. */ returnEnd: FlowEnd; /** All nodes, collected from edges (deduplicated by object identity). */ nodes: FlowNodeOrEnd[]; /** Independent edges; a node may be start of many edges, so cycles are expressible. */ edges: FlowEdge[]; /** Input contract — mirrors a service method's args. */ args?: DtoMessage; /** Output contract — mirrors a service method's results. */ results?: DtoMessage; /** Declared slot registers of this flow (named slots + the built-in args * slot). Sub-flows analyze their own slots; sharing happens through the * same input object (args) or the wrapping node's reads/writes. */ slots?: FlowSlots; /** Slots the enclosing flow has already produced before this sub-flow's * entry (input inheritance). Only meaningful for sub-flows (try bodies, * catch handlers, named sub-flows) — a top-level flow has none. */ entrySlots?: FlowSlot[]; } /** Executor node: an ordered sequence of method invocations (and optionally * one event publication). */ export function node( name: string, options: { flow?: FlowSchema; description?: string; methods?: FlowNodeMethodRef[]; publish?: FlowPublish; reads?: FlowSlot[]; writes?: FlowSlot[]; } = {}, ): FlowNode { return { name, flow: options.flow, description: options.description, methods: options.methods, publish: options.publish, reads: options.reads, writes: options.writes, }; } /** Guard node: a gate executor — ordered checks, first hit routes out; * all misses fall through the guard's normal outgoing edges. */ export function guard( name: string, options: { checks: GuardCheck[]; methods?: FlowNodeMethodRef[]; description?: string; }, ): GuardNode { return { type: 'guard', name, description: options.description, methods: options.methods, checks: options.checks, }; } /** Try node: a protected region whose body, catch handlers, and optional * finally are sub-flows. */ export function tryNode( name: string, options: { body: FlowSchema; catches: TryCatch[]; finally?: FlowSchema; description?: string; }, ): TryNode { return { type: 'try', name, description: options.description, body: options.body, catches: options.catches, finally: options.finally, }; } export function defineExceptionEnd(exception: ExceptionSchema, description?: string): FlowEnd { return { type: 'exception', name: `throw ${exception.name}`, exception, description }; } export function edge( start: FlowStep, end: FlowNodeOrEnd, options: { when?: string; description?: string; exception?: boolean; throws?: ExceptionSchema; check?: GuardCondition; } = {}, ): FlowEdge { // Auto name for uniformity with SchemaBase; `when` stays the branch marker. return { name: `${start.name}->${end.name}`, start, end, when: options.when, description: options.description, exception: options.exception, throws: options.throws, check: options.check, }; } export function defineFlow( name: string, schema: { start: FlowStep; edges: (flow: FlowSchema) => FlowEdge[]; args?: DtoMessage; results?: DtoMessage; slots?: FlowSlots; entrySlots?: FlowSlot[]; description?: string; }, ): FlowSchema { const flow: FlowSchema = { name, description: schema.description, start: schema.start, returnEnd: { type: 'return', name: 'return' }, nodes: [], edges: [], args: schema.args, results: schema.results, slots: schema.slots, entrySlots: schema.entrySlots, }; // The input slot's type is the flow's args message: stamp it on first use, // and refuse a registry already bound to a different input contract (slot // registries belong to one call tree). Same-name messages are the same // contract regardless of instance identity. if (schema.args !== undefined && schema.slots !== undefined) { const t = schema.slots.args.type; if (t === undefined) { schema.slots.args.type = schema.args; warnShadowedFields(schema.slots.args); } else if (messageName(t) !== schema.args.name) { throw new Error( `flow ${name}: the slots args slot carries ${messageName(t)} but the flow declares ${schema.args.name}`, ); } } const edges = schema.edges(flow); flow.edges = edges; // before node collection: guard check targets resolve through it const seen = new Set(); const nodes: FlowNodeOrEnd[] = []; const addNode = (n: FlowNodeOrEnd): void => { if (seen.has(n)) return; seen.add(n); nodes.push(n); }; const addGuardTargets = (n: FlowStep): void => { if (!isGuard(n)) return; for (const c of n.checks) { const ex = c.exception; if (c.return) { addNode(flow.returnEnd); } else if (ex) { // Guard checks exit implicitly: reuse the flow's exception end of the // same name (declared by an edge or another guard), else synthesize it. let t = nodes.find( (x): x is FlowEnd => isEnd(x) && x.type === 'exception' && x.exception?.name === ex.name, ); if (!t) { t = { type: 'exception', name: `throw ${ex.name}`, exception: ex, description: 'guard implicit exit', }; } addNode(t); } } }; for (const e of edges) { addNode(e.start); addNode(e.end); } addNode(schema.start); // Guard targets resolve after every edge endpoint is collected, so an // exception end referenced by any edge is reused before synthesis. ifNode // targets (cases and else) are collected the same way — an ifNode has no // outgoing edges, so its targets enter the flow only here. Targets chain // (an ifNode may target another ifNode, or a guard whose implicit exit // ends need collecting), so the loop runs over the growing list to a // fixpoint instead of a snapshot. for (let i = 0; i < nodes.length; i++) { const n = nodes[i]; addGuardTargets(n); if (isIfNode(n)) { for (const c of n.cases) addNode(c.to); addNode(n.else); } } flow.nodes = nodes; validate(flow); return flow; } // Reverse BFS from every FlowEnd marks all nodes that can reach an exit; // unmarked nodes sit on a path that never ends (e.g. a cycle without an exit) // — reject them at definition time. Guard checks count as implicit edges of // the guard itself; TryNode internals are validated inside their own flows. function validate(schema: FlowSchema): void { const reverse = new Map(); for (const n of schema.nodes) reverse.set(n, []); for (const e of schema.edges) { reverse.get(e.end)!.push(e.start); } // guard checks are implicit edges of the guard node itself (return checks // to the return end, exception checks to the matching exception end) for (const n of schema.nodes) { if (!isGuard(n)) continue; for (const c of n.checks) { if (c.return) { const arr = reverse.get(schema.returnEnd); if (arr) arr.push(n); // returnEnd is absent when no edge targets it; the guard is then unreachable anyway } else if (c.exception) { const t = findExceptionEnd(schema, c.exception.name); if (t) reverse.get(t)!.push(n); } } } // ifNode branches are implicit edges of the decision node itself for (const n of schema.nodes) { if (!isIfNode(n)) continue; for (const c of n.cases) reverse.get(c.to)!.push(n); reverse.get(n.else)!.push(n); } const reached = new Set(); const queue: FlowNodeOrEnd[] = []; for (const n of schema.nodes) { if (isEnd(n)) { reached.add(n); queue.push(n); } } while (queue.length > 0) { const cur = queue.shift()!; for (const prev of reverse.get(cur)!) { if (!reached.has(prev)) { reached.add(prev); queue.push(prev); } } } for (const n of schema.nodes) { if (!reached.has(n)) { throw new Error(`flow ${schema.name}: node "${n.name}" cannot reach an end node`); } } // Forward BFS from the start: every node must be reachable, so dead regions // cannot silently pollute the escape set (or the catch matching). const fromStart = new Set(); const forward: FlowNodeOrEnd[] = [schema.start]; while (forward.length > 0) { const cur = forward.shift()!; if (fromStart.has(cur)) continue; fromStart.add(cur); for (const e of schema.edges) { if (e.start === cur) forward.push(e.end); } if (isGuard(cur)) { for (const c of cur.checks) { if (c.return) { forward.push(schema.returnEnd); } else if (c.exception) { const t = findExceptionEnd(schema, c.exception.name); if (t) forward.push(t); } } } if (isIfNode(cur)) { for (const c of cur.cases) forward.push(c.to); forward.push(cur.else); } } for (const n of schema.nodes) { if (!fromStart.has(n)) { throw new Error(`flow ${schema.name}: node "${n.name}" is not reachable from the start node`); } } validateGuardChecks(schema); for (const e of schema.edges) { if (e.check !== undefined) { validateCondition(`flow ${schema.name}: edge "${e.name}"`, e.check); } } for (const n of schema.nodes) { if (isIfNode(n)) validateIfNode(n, schema); } validateSlots(schema); for (const n of schema.nodes) { if (isEnd(n)) continue; validateThrowsCoverage(n, schema); if (isTryNode(n)) validateTryNode(n, schema); } // Guard check exceptions exit implicitly — a typed throws edge carrying // one of the guard's own check exceptions would duplicate the route. Edges // carrying other exceptions (or catch-all edges) stay legal: they route // throws declared by the guard's methods. for (const e of schema.edges) { if (!isGuard(e.start)) continue; if (e.throws !== undefined && e.start.checks.some((c) => c.exception?.name === e.throws!.name)) { throw new Error( `flow ${schema.name}: edge "${e.name}" from guard "${e.start.name}" duplicates the implicit route of check exception ${e.throws.name} — guard checks route implicitly to the matching exception end`, ); } } // An ifNode owns its exits internally (cases and else) — outgoing edges // would be a second way out of the decision. for (const e of schema.edges) { if (!isIfNode(e.start)) continue; throw new Error( `flow ${schema.name}: edge "${e.name}" from ifNode "${e.start.name}" is redundant — ifNode exits are internal (cases and else)`, ); } // Typed throws edges land on an exception end carrying the same exception // (escape) — never on a plain step, and no silent renames. for (const e of schema.edges) { if (e.throws === undefined) continue; if (!isEnd(e.end) || e.end.type !== 'exception') { throw new Error( `flow ${schema.name}: edge "${e.name}" carries throw ${e.throws.name} but its target is not an exception end`, ); } if (!e.end.exception) { throw new Error( `flow ${schema.name}: edge "${e.name}" targets exception end "${e.end.name}" with no exception type`, ); } if (e.end.exception.name !== e.throws.name) { throw new Error( `flow ${schema.name}: edge "${e.name}" carries throw ${e.throws.name} but targets the ${e.end.exception.name} exception end`, ); } } // Untyped catch-all edges also escape — the target must be an exception end. for (const e of schema.edges) { if (e.exception !== true || e.throws !== undefined) continue; if (!isEnd(e.end) || e.end.type !== 'exception') { throw new Error( `flow ${schema.name}: edge "${e.name}" catch-all exception path must target an exception end`, ); } } // Plain sub-flows are structure only — exceptions must go through a tryNode. for (const n of schema.nodes) { if (!isFlowNode(n) || !n.flow) continue; const ends = exceptionEndNames(n.flow); if (ends.size > 0) { throw new Error( `flow ${schema.name}: node "${n.name}" sub-flow must not declare exception ends (${[...ends].join(', ')}) — a guard check exception also creates one; use a tryNode for a protected region`, ); } } } // Every guard check must choose exactly one of return / exception; its // optional machine-readable condition must be well-formed. function validateGuardChecks(schema: FlowSchema): void { for (const n of schema.nodes) { if (!isGuard(n)) continue; for (const c of n.checks) { const hasReturn = c.return === true; const hasException = c.exception !== undefined; if (hasReturn === hasException) { throw new Error( `flow ${schema.name}: guard "${n.name}" check "${c.when}" must have exactly one of return/exception`, ); } if (c.check !== undefined) { validateCondition(`flow ${schema.name}: guard "${n.name}" check "${c.when}"`, c.check); } } } } // A condition must be a utils predicate call (boolean result, no result // slot), a field comparison whose op is known and whose value presence and // type match the operator, or a composite (not/and/or) whose sub-conditions // are each valid. const COMPARE_OPS: readonly CompareOp[] = ['lt', 'le', 'gt', 'ge', 'eq', 'ne', 'isNull', 'isNotNull']; function validateCondition(where: string, c: GuardCondition): void { if (isConditionGroup(c)) { if (c.kind === 'not') { if (c.conds.length !== 1) { throw new Error(`${where}: not() takes exactly one condition`); } } else if (c.conds.length < 2) { throw new Error(`${where}: ${c.kind}() takes at least two conditions`); } for (const sub of c.conds) validateCondition(where, sub); return; } if (!isCall(c)) { if (isFlowSlot(c.field)) { const nullOp = c.op === 'isNull' || c.op === 'isNotNull'; if (nullOp) { if (c.value !== undefined) { throw new Error(`${where}: ${c.op} takes no value`); } } else { if (!isScalarSlot(c.field)) { throw new Error(`${where}: a slot-level check only supports isNull/isNotNull`); } if (c.value === undefined) { throw new Error(`${where}: ${c.op} requires a value`); } } return; } const f = c.field as Partial | null; if (typeof f !== 'object' || f === null || typeof f.slot !== 'object' || typeof f.field !== 'object') { throw new Error(`${where}: ${c.op} field must be a slot field access like slots.args.amt`); } if (!(COMPARE_OPS as readonly unknown[]).includes(c.op)) { throw new Error(`${where}: unknown comparison op ${String(c.op)}`); } const nullOp = c.op === 'isNull' || c.op === 'isNotNull'; if (nullOp && c.value !== undefined) { throw new Error(`${where}: ${c.op} takes no value`); } if (!nullOp && c.value === undefined) { throw new Error(`${where}: ${c.op} requires a value`); } const v = c.value; if (v !== undefined && typeof v !== 'string' && typeof v !== 'number') { const ev = v as Partial | null; if (typeof ev !== 'object' || ev === null || ev.value === undefined || ev.symbol === undefined || ev.label === undefined) { throw new Error(`${where}: ${c.op} value must be a string, number, or enum value`); } } return; } const m = c.method as Partial & { type?: string; name: string }; if (m.type !== 'utilsMethod') { throw new Error(`${where}: check call must be a utils predicate, got ${m.type ?? m.name}`); } if (c.result !== undefined) { throw new Error(`${where}: a predicate call cannot bind a result slot`); } if (m.result === undefined || m.result.jsType !== 'boolean') { const declared = m.result === undefined ? 'void' : m.result.jsType; throw new Error( `${where}: utils predicate "${m.name}" must declare a boolean result (got ${declared}) — ` + `predicates (can/is/has) return boolean; defense guards (assert/validate/ensure) throw internally and are invoked, not used in IF`, ); } } /** Slots a condition reads: a comparison's field slot (or the slot itself for * slot-level checks), a predicate call's arg slots, or every sub-condition's * slots for a composite. */ function conditionSlots(c: GuardCondition): FlowSlot[] { if (isConditionGroup(c)) return c.conds.flatMap(conditionSlots); if (!isCall(c)) return [isFlowSlot(c.field) ? c.field : c.field.slot]; return c.args ?? []; } // ifNode rules: at least one case, every condition well-formed, and targets // are steps — flow exits (return/throw) belong to guard checks, not to // conditional jumps. function validateIfNode(n: IfNode, schema: FlowSchema): void { if (n.cases.length === 0) { throw new Error(`flow ${schema.name}: ifNode "${n.name}" must have at least one case`); } for (const c of n.cases) { validateCondition(`flow ${schema.name}: ifNode "${n.name}" case "${c.when}"`, c.check); validateIfTarget(schema, n, c.to, `case "${c.when}"`); } validateIfTarget(schema, n, n.else, 'else'); } function validateIfTarget(schema: FlowSchema, n: IfNode, t: FlowNodeOrEnd, where: string): void { if (isEnd(t)) { throw new Error( `flow ${schema.name}: ifNode "${n.name}" ${where} must target a step — flow exits (return/throw) belong to guard checks`, ); } } // Slots: every referenced slot must be declared in this flow's registers // (sub-flows analyze their own slots), every declared named slot must be // used, and a slot may only be consumed when every path from the start has // produced it (must-analysis over the control graph). The built-in args slot // is the flow input: it needs no registration and is available on entry. // Within one node, calls run in order, so an earlier call's result feeds a // later call's args; the node's own writes feed everything after them. // Slot/contract type differences convert implicitly and are not validated. function validateSlots(schema: FlowSchema): void { const registry = schema.slots; const argsSlot = registry?.args; const declared = new Set(registry === undefined ? [] : Object.values(registry)); const used = new Set(); const produced = new Map>(); for (const n of schema.nodes) { const p = new Set(); if (!isEnd(n) && !isTryNode(n)) { if (isIfNode(n)) { for (const c of n.cases) { for (const t of conditionSlots(c.check)) used.add(t); } } else { for (const ref of n.methods ?? []) { if (!isCall(ref)) continue; for (const t of ref.args ?? []) used.add(t); if (ref.result) { used.add(ref.result); p.add(ref.result); } } if (isFlowNode(n)) { for (const t of n.reads ?? []) used.add(t); if (n.publish?.payload) used.add(n.publish.payload); for (const t of n.writes ?? []) { used.add(t); p.add(t); } } if (isGuard(n)) { for (const ch of n.checks) { for (const t of ch.reads ?? []) used.add(t); if (ch.check) { for (const t of conditionSlots(ch.check)) used.add(t); } } } } } produced.set(n, p); } // a branch edge's condition is decided at its start node for (const e of schema.edges) { if (!e.check) continue; for (const t of conditionSlots(e.check)) used.add(t); } for (const t of used) { if (!declared.has(t)) { if (t.name === 'args') { throw new Error( `flow ${schema.name}: slot "args" is not declared — declare the flow's slots via defineSlots (its built-in args slot)`, ); } throw new Error(`flow ${schema.name}: slot "${t.name}" is not declared in the flow's slots`); } } for (const t of declared) { if (t === argsSlot) continue; // the input slot needs no use if (!used.has(t)) { throw new Error(`flow ${schema.name}: slot "${t.name}" is declared but never used`); } } // Fixpoint over the graph: available(n) = intersection over all incoming // edges of (available(start) ∪ produced(start)). The input slot is seeded // at the start. Sets only grow, so the fixpoint terminates. Incoming edges // include the implicit ones (ifNode cases/else), so availability flows // through decision nodes the same way reachability does. const reverse = new Map(); for (const n of schema.nodes) reverse.set(n, []); for (const e of schema.edges) reverse.get(e.end)!.push(e.start); for (const n of schema.nodes) { if (!isIfNode(n)) continue; for (const c of n.cases) reverse.get(c.to)!.push(n); reverse.get(n.else)!.push(n); } const avail = new Map>(); for (const n of schema.nodes) avail.set(n, new Set()); const seeded = new Set(); if (argsSlot) seeded.add(argsSlot); for (const t of schema.entrySlots ?? []) { if (!declared.has(t)) { throw new Error(`flow ${schema.name}: entry slot "${t.name}" is not declared in the flow's slots`); } seeded.add(t); } // add one by one: this engine's Set.prototype.add takes a single value for (const t of seeded) avail.get(schema.start)!.add(t); let changed = true; while (changed) { changed = false; for (const n of schema.nodes) { if (n === schema.start) continue; const incoming = reverse.get(n)!; if (incoming.length === 0) continue; let intersection: Set | undefined; for (const srcN of incoming) { const src = new Set(avail.get(srcN)!); for (const t of produced.get(srcN)!) src.add(t); intersection = intersection === undefined ? src : new Set([...intersection].filter((t) => src.has(t))); } if (intersection === undefined) continue; for (const t of intersection) { if (!avail.get(n)!.has(t)) { avail.get(n)!.add(t); changed = true; } } } } // Consumption check walks each node's ordered sequence: writes // (constructions) first, then calls in order, then branch reads/checks. const missing = (n: FlowStep, t: FlowSlot): Error => { if (n === schema.start) { return new Error( `flow ${schema.name}: start node "${n.name}" cannot consume slot "${t.name}" — nothing else is produced before entry`, ); } return new Error(`flow ${schema.name}: node "${n.name}" reads slot "${t.name}" that may not be written on every path`); }; for (const n of schema.nodes) { if (isEnd(n) || isTryNode(n)) continue; const inner = new Set(avail.get(n)!); if (isFlowNode(n)) { for (const t of n.writes ?? []) inner.add(t); } if (!isIfNode(n)) { for (const ref of n.methods ?? []) { if (!isCall(ref)) continue; for (const t of ref.args ?? []) { if (!inner.has(t)) throw missing(n, t); } if (ref.result) inner.add(ref.result); } } if (isFlowNode(n)) { for (const t of n.reads ?? []) { if (!inner.has(t)) throw missing(n, t); } if (n.publish?.payload && !inner.has(n.publish.payload)) throw missing(n, n.publish.payload); } if (isGuard(n)) { for (const ch of n.checks) { for (const t of ch.reads ?? []) { if (!inner.has(t)) throw missing(n, t); } if (ch.check) { for (const t of conditionSlots(ch.check)) { if (!inner.has(t)) throw missing(n, t); } } } } if (isIfNode(n)) { for (const c of n.cases) { for (const t of conditionSlots(c.check)) { if (!inner.has(t)) throw missing(n, t); } } } // branch decisions read their slots at the end of the node for (const e of schema.edges) { if (e.start !== n || !e.check) continue; for (const t of conditionSlots(e.check)) { if (!inner.has(t)) throw missing(n, t); } } } } // Every exception a step declares (methods throws, TryNode rethrows) must be // routed: typed throws edges or an untyped exception edge (catch-all). Guard // check exceptions route implicitly to the matching exception end and are not // covered here. The reverse direction (routed ⊆ declared) is intentionally // not validated while steps carry pure descriptors — a MethodSchema has no // throws field yet, so typed edges may document throws the descriptor cannot // express. Once contract refs replace the descriptors, the reverse check can // be enforced. function validateThrowsCoverage(n: FlowStep, schema: FlowSchema): void { const declared = new Set(); if (!isTryNode(n) && !isIfNode(n)) { for (const ref of n.methods ?? []) { const m = methodOf(ref); if ('throws' in m && m.throws) { for (const e of m.throws) declared.add(e.name); } } } if (isTryNode(n)) { for (const c of n.catches) { for (const name of exceptionEndNames(c.handler)) declared.add(name); } } if (declared.size === 0) return; const routed = new Set(); for (const e of schema.edges) { if (e.start !== n) continue; if (e.throws) { routed.add(e.throws.name); } else if (e.exception === true) { return; // untyped exception edge: catch-all } } for (const d of declared) { if (!routed.has(d)) { throw new Error( `flow ${schema.name}: node "${n.name}" declares throw ${d} but has no typed throws edge for it`, ); } } } // TryNode rules: catches must be unique with UnexpectedException last, every // body exception end needs a catch, every catch must match a body exception // end (no dead catches), and finally must be pure cleanup (no exception ends). // Body end names must be unique so catch matching is unambiguous. Catch // matching works on declared end names — reachability of a body end from the // body start is not analyzed (a dead region's end would still count). function validateTryNode(n: TryNode, schema: FlowSchema): void { const seen = new Set(); let catchAll = false; for (const c of n.catches) { if (seen.has(c.exception.name)) { throw new Error( `flow ${schema.name}: tryNode "${n.name}" has duplicate catch ${c.exception.name}`, ); } seen.add(c.exception.name); if (catchAll) { throw new Error( `flow ${schema.name}: tryNode "${n.name}" catch ${c.exception.name} must precede the ${UnexpectedException.name} catch`, ); } if (c.exception.name === UnexpectedException.name) catchAll = true; } const bodyEndCounts = new Map(); for (const node of n.body.nodes) { if (isEnd(node) && node.type === 'exception' && node.exception) { bodyEndCounts.set(node.exception.name, (bodyEndCounts.get(node.exception.name) ?? 0) + 1); } } for (const [name, count] of bodyEndCounts) { if (count > 1) { throw new Error( `flow ${schema.name}: tryNode "${n.name}" body has ${count} exception ends named ${name}`, ); } } const bodyEnds = new Set(bodyEndCounts.keys()); for (const name of bodyEnds) { if (!seen.has(name)) { throw new Error( `flow ${schema.name}: tryNode "${n.name}" body exception end ${name} has no catch`, ); } } for (const c of n.catches) { if (!bodyEnds.has(c.exception.name)) { throw new Error( `flow ${schema.name}: tryNode "${n.name}" catch ${c.exception.name} matches no body exception end`, ); } } if (n.finally) { const f = exceptionEndNames(n.finally); if (f.size > 0) { throw new Error( `flow ${schema.name}: tryNode "${n.name}" finally must not declare exception ends: ${[...f].join(', ')}`, ); } } } /** Exception names declared by a flow's exception ends — the flow's escape * set (throws that reach the caller unless caught by an outer tryNode). */ export function exceptionEndNames(flow: FlowSchema): Set { const names = new Set(); for (const n of flow.nodes) { if (!isEnd(n) || n.type !== 'exception') continue; if (!n.exception) { throw new Error(`flow ${flow.name}: exception end "${n.name}" has no exception type`); } names.add(n.exception.name); } return names; } export function isEnd(n: FlowNodeOrEnd): n is FlowEnd { return 'type' in n && (n.type === 'return' || n.type === 'exception'); } function messageName(t: unknown): string { return (t as { name?: string } | null | undefined)?.name ?? 'an unnamed message'; } export function isFlowNode(n: FlowNodeOrEnd): n is FlowNode { return !('type' in n); } export function isGuard(n: FlowNodeOrEnd): n is GuardNode { return 'type' in n && n.type === 'guard'; } export function isTryNode(n: FlowNodeOrEnd): n is TryNode { return 'type' in n && n.type === 'try'; } export function isIfNode(n: FlowNodeOrEnd): n is IfNode { return 'type' in n && n.type === 'if'; }