diff --git a/docs/language.ebnf b/docs/language.ebnf new file mode 100644 index 00000000..f21ad5d0 --- /dev/null +++ b/docs/language.ebnf @@ -0,0 +1,184 @@ +(* ================================================================= *) +(* BRIDGE LANGUAGE V1.5 (ISO/IEC 14977 COMPLIANT) *) +(* ================================================================= *) +(* --- 1. TOP LEVEL BLOCKS --- *) +program + = "version 1.5", { const + | bridge + | define + | tool }; + +const + = "const", identifier, "=", json; + +bridge + = "bridge", identifier, "{", { statement }, "}"; + +define + = "define", identifier, "{", { statement }, "}"; + +tool + = "tool", identifier, "from", identifier, "{", { statement }, [ "on error", "=", json ], "}"; + +(* --- 2. STATEMENTS & SCOPE --- *) +statement + = with + | wire + | wire_alias + | scope + | spread + | force; + +with + = "with", identifier, [ "as", identifier ]; + +scope + = target, "{", { statement }, "}"; + +(* Standard assignment *) +wire + = target, ( routing + | "=", json ); + +(* Local memory assignment *) +wire_alias + = "alias", identifier, routing; + +(* Merge into current scope object *) +spread + = "...", routing; + +(* Eager side-effect evaluation *) +force + = "force", identifier, [ "catch", "null" ]; + +(* --- 3. SHARED PATHS & ROUTING --- *) +target + = [ "." ], identifier, { ".", identifier }; + +(* The Right-Hand Side Evaluation Chain *) +routing + = "<-", expression, { ( "||" + | "??" ), expression }, [ "catch", expression ]; + +(* --- 4. EXPRESSIONS & REFERENCES --- *) +ref + = identifier, [ [ "?" ], ".", identifier ], { [ "?" ], ".", identifier }; + +(* An expression is a piped value, optionally followed by a ternary gate *) +expression + = pipe_chain, [ "?", expression, ":", expression ]; + +(* A pipe chain allows infinite routing: handle:handle:source *) +pipe_chain + = { identifier, [ ".", identifier ], ":" }, base_expression; + +base_expression + = json + | ref, "[]", "as", identifier, "{", { statement }, "}" + | ref + | ( "throw" + | "panic" ), [ string ] + | ( "continue" + | "break" ), [ integer ]; + +(* --- 5. EMBEDDED JSON (RFC 8259) --- *) +json + = object + | array + | string + | number + | "true" + | "false" + | "null"; + +object + = "{", [ string, ":", json, { ",", string, ":", json } ], "}"; + +array + = "[", [ json, { ",", json } ], "]"; + +(* --- 6. LEXICAL RULES (TOKENS) --- *) +identifier + = letter, { letter + | digit + | "_" }; + +string + = '"', { character - '"' }, '"' + | "'", { character - "'" }, "'"; + +number + = [ "-" ], integer, [ ".", integer ]; + +integer + = digit, { digit }; + +letter + = "a" + | "b" + | "c" + | "d" + | "e" + | "f" + | "g" + | "h" + | "i" + | "j" + | "k" + | "l" + | "m" + | "n" + | "o" + | "p" + | "q" + | "r" + | "s" + | "t" + | "u" + | "v" + | "w" + | "x" + | "y" + | "z" + | "A" + | "B" + | "C" + | "D" + | "E" + | "F" + | "G" + | "H" + | "I" + | "J" + | "K" + | "L" + | "M" + | "N" + | "O" + | "P" + | "Q" + | "R" + | "S" + | "T" + | "U" + | "V" + | "W" + | "X" + | "Y" + | "Z"; + +digit + = "0" + | "1" + | "2" + | "3" + | "4" + | "5" + | "6" + | "7" + | "8" + | "9"; + +character + = ? any valid unicode character ?; \ No newline at end of file diff --git a/docs/rearchitecture-plan.md b/docs/rearchitecture-plan.md new file mode 100644 index 00000000..98d62050 --- /dev/null +++ b/docs/rearchitecture-plan.md @@ -0,0 +1,483 @@ +# Rearchitect Bridge IR to Nested Scoped Statements + +## TL;DR + +Replace the flat `Wire[]` + detached `arrayIterators: Record` IR +with a recursive `Statement[]` tree that preserves scope boundaries, supports +`with` declarations at any scope level with shadowing semantics, and treats +array iterators as first-class expression-level constructs. + +This is the foundational change that enables all future language evolution — +the parser→IR→engine→compiler pipeline is rebuilt bottom-up across 7 phases. + +--- + +## Current Architecture (What's Broken) + +**Problem 1 — Flat Wire List:** `Bridge.wires: Wire[]` is a flat array. The +parser flattens all scope nesting at parse time (e.g., `o { .lat <- x }` becomes +flat wire `o.lat <- x`). This destroys scope boundaries that are meaningful for +tool registration and execution. + +**Problem 2 — Detached Array Iterators:** Array mappings are split into: +(a) a regular wire for the source, (b) element-marked wires with `element: true` +in the flat list, (c) a `Record` metadata map (`arrayIterators`). +This makes arrays non-composable and non-aliasable. + +**Problem 3 — No `with` in Scopes:** Tool registrations (`with`) only work at +bridge body level. The EBNF grammar defines `statement = with | wire | wire_alias | scope` — +meaning `with` should work anywhere statements are allowed, including inside +scopes and array bodies. + +**Problem 4 — EBNF Divergence:** The grammar treats array mapping as a +`base_expression` (`ref[] as id { statement* }`) — it should be an expression +chainable with `||`, `??`, `catch`, and `alias`. Currently it's baked into wire syntax. + +--- + +## Phase 1: Preparation — Disable Coupled Tests ✅ COMPLETE + +_No dependencies. Single commit._ + +1. ✅ Mark compiler tests as disabled — prefix all scripts in `bridge-compiler/package.json` +2. ✅ Mark compiler fuzz tests as disabled +3. ✅ Disable parser roundtrip in regression harness — `isDisabled()` globally + returns `true` for `"compiled"` and `"parser"` checks +4. ✅ Skip parser roundtrip test files: + - `packages/bridge-parser/test/bridge-format.test.ts` + - `packages/bridge-parser/test/bridge-printer.test.ts` + - `packages/bridge-parser/test/bridge-printer-examples.test.ts` +5. ✅ Skip IR-structure-dependent core tests: + - `packages/bridge-core/test/execution-tree.test.ts` + - `packages/bridge-core/test/enumerate-traversals.test.ts` + - `packages/bridge-core/test/resolve-wires.test.ts` +6. ✅ **Kept enabled:** All behavioral `regressionTest` tests in `packages/bridge/test/` + (runtime path) — these are the correctness anchor +7. ✅ Verified `pnpm build && pnpm test` passes with skipped tests noted + +--- + +## Phase 2: Define New IR Data Structures ✅ COMPLETE + +_Depends on Phase 1. Changes `bridge-core/src/types.ts` + `index.ts`._ + +### Types added: + +```typescript +// Shared RHS — the evaluation chain reused by wire and alias statements +interface SourceChain { + sources: WireSourceEntry[]; + catch?: WireCatch; +} + +// Scope-aware statement — the building block of nested bridge bodies +type Statement = + | WireStatement // target <- expression chain (SourceChain & { target }) + | WireAliasStatement // alias name <- expression chain (SourceChain & { name }) + | SpreadStatement // ... <- expression chain (SourceChain, inherits scope target) + | WithStatement // with [as ] [memoize] + | ScopeStatement // target { Statement[] } + | ForceStatement; // force handle [catch null] + +// New expression variants added to Expression union: +// { type: "array"; source: Expression; iteratorName: string; body: Statement[] } +// { type: "pipe"; source: Expression; handle: string; path?: string[] } +// { type: "binary"; op: BinaryOp; left: Expression; right: Expression } +// { type: "unary"; op: "not"; operand: Expression } +// { type: "concat"; parts: Expression[] } +// BinaryOp = "add" | "sub" | "mul" | "div" | "eq" | "neq" | "gt" | "gte" | "lt" | "lte" +``` + +### Modifications to existing types (transition period): + +- ✅ **`Bridge`**: Added `body?: Statement[]` alongside existing `wires`. + `wires`, `arrayIterators`, `forces`, `pipeHandles` marked `@deprecated`. +- ✅ **`ToolDef`**: Added `body?: Statement[]` alongside existing `wires`. + `pipeHandles` marked `@deprecated`. +- ✅ **`DefineDef`**: Added `body?: Statement[]` alongside existing `wires`. + `arrayIterators`, `pipeHandles` marked `@deprecated`. +- ✅ **`Expression`**: Added `"array"`, `"pipe"`, `"binary"`, `"unary"`, `"concat"` variants. + Binary/unary/concat replace the legacy desugaring that created synthetic tool + forks (`Tools.add`, `Tools.eq`, `Tools.not`, `Tools.concat`) for built-in operators. +- ✅ **`BinaryOp`**: New type alias — `"add" | "sub" | "mul" | "div" | "eq" | "neq" | "gt" | "gte" | "lt" | "lte"`. +- ✅ **`WireStatement`**: Flattened — uses `SourceChain & { target: NodeRef }`, + no longer wraps `Wire`. +- ✅ **`WireAliasStatement`**: Uses `SourceChain & { name }`. +- ✅ **`SpreadStatement`**: New — `SourceChain & { kind: "spread" }`, no target + (inherits enclosing scope). +- ✅ **`SourceChain`**: Extracted shared `sources + catch` interface. +- ✅ All exhaustive Expression switches updated for `"array"`, `"pipe"`, + `"binary"`, `"unary"`, `"concat"` cases. +- ✅ Exported `SourceChain`, `SpreadStatement`, `BinaryOp` from index.ts. + +### Design constraints: + +- `Statement[]` is ordered — execution engine walks sequentially for wiring, + pulls lazily for values +- Each `ScopeStatement` and `ArrayExpression.body` creates a new scope layer +- Scope lookup is lexical: inner shadowing, fallthrough to parent for missing handles +- Legacy `Wire` type stays for backward compat with old engine path + +--- + +## Phase 3: New AST Builder ✅ COMPLETE + +_Depends on Phase 2. New file `bridge-parser/src/parser/ast-builder.ts`._ + +Created a new CST→AST visitor (`buildBody()`) that produces `body: Statement[]` +directly from Chevrotain CST nodes, separate from the legacy `buildBridgeBody()`. + +### Changes: + +- ✅ New file: `packages/bridge-parser/src/parser/ast-builder.ts` (~2050 lines) +- ✅ `buildBody()` — core visitor: CST body lines → `Statement[]` with nested scoping +- ✅ `buildBodies()` — top-level hook for future integration +- ✅ Scope blocks (`target { ... }`) → `ScopeStatement` (not flattened) +- ✅ Array mappings → `ArrayExpression` in expression tree with `body: Statement[]` +- ✅ `with` declarations → `WithStatement` with handle resolution +- ✅ `force` → `ForceStatement` +- ✅ Operators (+,-,\*,/,==,!=,>,<,>=,<=) → `BinaryExpression` (not tool forks) +- ✅ `not` → `UnaryExpression` (not tool fork) +- ✅ Template strings → `ConcatExpression` (not tool fork) +- ✅ Pipe chains → `PipeExpression` (not synthetic fork wires) +- ✅ Literal values pre-parsed as `JsonValue` +- ✅ Self-contained helpers (duplicated from parser.ts to avoid coupling) +- ✅ Spread lines → `SpreadStatement` +- ✅ Coalesce chains, ternary, catch handlers all preserved +- ✅ build + lint + test all pass (0 errors, 0 failures) + +**No Chevrotain grammar changes needed** — only the CST→AST visitor. + +--- + +## Phase 4: Update Execution Engine ✅ COMPLETE (v1 superseded by v3) + +_Depends on Phase 3. Most critical phase._ + +Files: `ExecutionTree.ts`, `scheduleTools.ts`, `resolveWires.ts`, +`resolveWiresSources.ts`, `materializeShadows.ts`, `parser.ts`. + +### Completed + +- ✅ **Expression evaluators** in `resolveWiresSources.ts`: + - `evaluateBinary` — all 10 BinaryOp cases (add/sub/mul/div/eq/neq/gt/gte/lt/lte) + - `evaluateUnary` — not operator + - `evaluateConcat` — template string concatenation +- ✅ **WireCatch.value → JsonValue** — proper JSON literal support (not string fallback) +- ✅ **Hook ast-builder into parser** — `buildBody()` called in `buildBridge`, + `buildToolDef`, `buildDefineDef`; `body: Statement[]` populated alongside legacy `wires` +- ✅ **Wire pre-indexing** — `WireIndex` class in `tree-utils.ts`: + - Two-level index: `byTrunk` (trunk key → Wire[]) and `byTrunkAndPath` (trunk+path → Wire[]) + - Element-scoped wire awareness (`:*` suffix keys merged with non-element queries) + - Built once at construction in O(n), shared across shadow trees + - All 22 linear-scan sites in `ExecutionTree.ts`, `scheduleTools.ts`, `materializeShadows.ts` + refactored to use O(1) index lookups + - `sameTrunk` and `pathEquals` no longer imported in ExecutionTree.ts + +### Remaining (v1 engine — superseded by Phase 4b) + +These items were planned for the v1 `ExecutionTree` engine but are now +superseded by the v3 pull engine (Phase 4b) which implements all of them +from scratch on the `body: Statement[]` IR. The v1 engine continues to +operate on the legacy `Wire[]` IR and will be removed in Phase 7. + +1. ~~**Scope chain**~~ → v3 `ExecutionScope` with parent pointer chain +2. ~~**Array execution**~~ → v3 `evaluateArrayExpr()` with per-element scope +3. ~~**Define inlining**~~ → v3 `executeDefine()` with lazy factories +4. ~~**`schedule()`/`pullSingle()`**~~ → v3 `resolveRequestedFields()` with sparse fieldsets + +**Gate:** All behavioral `regressionTest` suites must pass. ✅ PASSING + +--- + +## Phase 4b: V3 Scope-Based Pull Engine ✅ COMPLETE + +_Parallel with Phase 4. File: `bridge-core/src/v3/execute-bridge.ts`._ + +A new execution engine built from scratch on the `body: Statement[]` IR. +Pull-based and demand-driven: tools are only called when their output is +first read. Runs alongside the existing v1 runtime — the regression harness +tests both engines for behavioral parity. + +### Architecture + +- **`ExecutionScope`** — lexical scope chain with lazy tool call memoization +- **`indexStatements()`** — walks `Statement[]` once, registers tool bindings, + tool input wires, output wires, and aliases (no evaluation) +- **`resolveRequestedFields()`** — pulls only the output fields that were + requested (sparse fieldset support built-in) +- **`evaluateSourceChain()`** — evaluates fallback gates (`||`, `??`) with + `catch` handler wrapping +- **`evaluateExpression()`** — recursive expression evaluator for the full + Expression union +- **`writeTarget()`** — routes writes to element scope (array body) vs root + scope (top-level output) + +### Migration Phases (feature by feature) + +Each phase implements a feature cluster, enables the corresponding regression +tests for the v3 engine, then verifies 0 failures. + +#### V3-Phase 1: Error Handling — `?.` safe modifier + `catch` ✅ COMPLETE + +**Unlocks:** resilience.test.ts (partial), coalesce-cost.test.ts (partial), +shared-parity.test.ts (catch fallbacks), chained.test.ts, +bugfixes/fallback-bug.test.ts + +- `catch` on wire source chains (literal, ref, control flow) +- `?.` rootSafe/pathSafe on NodeRef (safe path traversal) +- `expr.safe` flag on ref expressions (swallows non-fatal errors → undefined) +- `isFatalError` check (BridgePanicError, BridgeAbortError bypass catch/?.) +- `leftSafe`/`rightSafe` on and/or expressions +- Source chain gate semantics: `continue` (skip entry) not `break` (stop chain) +- Trace recording on both successful and failed tool calls +- Error trace attachment for harness/caller access + +#### V3-Phase 2: Binary + Unary + Concat Expressions ✅ COMPLETE + +**Unlocks:** expressions.test.ts (all 10 groups), string-interpolation.test.ts, +interpolation-universal.test.ts, shared-parity.test.ts (expressions, +string interpolation) + +- Binary: `add`, `sub`, `mul`, `div`, `eq`, `neq`, `gt`, `gte`, `lt`, `lte` +- Unary: `not` +- Concat: template string concatenation (null → empty string coercion) +- `and`/`or` fixed to return boolean (not raw JS values) — matches v1 semantics +- Root-level output replacement for array/primitive values (`__rootValue__`) + +#### V3-Phase 3: Pipe Expressions ✅ COMPLETE + +**Unlocks:** tool-features.test.ts (pipe tests), builtin-tools.test.ts, +scheduling.test.ts, property-search.test.ts + +- `pipe` expression type — `tool:source` routing through declared tool handles +- Pipe source → `input.in` (default) or `input.` path +- ToolDef base wires + bridge wires merged into pipe input +- Non-memoized — each pipe call is independent +- Named pipe input field (`tool:source.fieldName`) +- Pipe forking (multiple pipes from same source) + +#### V3-Phase 4: Control Flow ✅ COMPLETE + +**Unlocks:** control-flow.test.ts, shared-parity.test.ts (break/continue) + +- `throw` — calls `applyControlFlow()` → raises Error +- `panic` — calls `applyControlFlow()` → raises BridgePanicError (fatal) +- `break` / `continue` — loop control signals returned as sentinel values +- Multi-level `break N` / `continue N` — propagated across nested array boundaries +- `resolveRequestedFields` concurrent wire evaluation via `Promise.allSettled` + (matches v1 eager semantics — tool wires start before input-only wires that may panic) +- `evaluateArrayExpr` handles BREAK_SYM/CONTINUE_SYM/LoopControlSignal +- `applyCatchHandler` delegates to `applyControlFlow()` for all catch control flows + +#### V3-Phase 5: ToolDef / Define / Extends / on error ✅ COMPLETE + +**Unlocks:** tool-features.test.ts (extends), resilience.test.ts (on error), +shared-parity.test.ts (ToolDef, define), scope-and-edges.test.ts + +- ToolDef instruction processing (defaults, fn mapping, on error) +- Define block inlining with child scope creation +- Extends chain resolution (walks ToolDef chain to root fn) +- `on error` handler on tool invocation (literal value or context source) +- Scope blocks in ToolDef body (`.headers { .auth <- ... }`) +- Nested scope blocks in ToolDef body + +#### V3-Phase 6: Force Statements ✅ COMPLETE + +**Unlocks:** force-wire.test.ts, builtin-tools.test.ts (audit) + +- `force` — tool runs even if output not queried +- Force statements collected during `indexStatements` +- `executeForced()` eagerly schedules via `resolveToolResult` +- Critical forces: awaited alongside output resolution via `Promise.all` +- Fire-and-forget (`catch null`): errors silently swallowed + +#### V3-Phase 7: Const Blocks ✅ COMPLETE + +**Unlocks:** resilience.test.ts (const in bridge), shared-parity.test.ts +(const blocks) + +- `with const as c` — reading from document-level `const` declarations +- Const values resolved via `resolveRef` scope chain + +#### V3-Phase 8: AbortSignal + Error Wrapping + Traces ✅ COMPLETE + +**Unlocks:** control-flow.test.ts (AbortSignal), traces-on-errors.test.ts, +coalesce-cost.test.ts (error propagation), builtin-tools.test.ts (error propagation) + +- AbortSignal propagation: signal added to EngineContext, pre-abort check in callTool/pipe +- AbortSignal passed through toolContext alongside logger +- Platform AbortError (`DOMException`) normalized to `BridgeAbortError` +- Non-fatal errors wrapped in `BridgeRuntimeError` via `wrapBridgeRuntimeError` +- Traces and `executionTraceId` attached to error objects on failure +- Fix: const `pathSafe` array sliced alongside path in `resolveConst` +- Safe navigation (`?.`) on non-existent const paths now works correctly + +#### V3-Phase 9: Overdefinition / Multi-wire ✅ COMPLETE + +**Unlocks:** coalesce-cost.test.ts (overdefinition), shared-parity.test.ts +(overdefinition) + +- `groupWiresByPath()` groups wires by `target.path.join(".")` for overdefinition detection +- `orderOverdefinedWires()` sorts by `computeExprCost` (0=literal/input, 1=sync, 2=async) +- `callTool`, `executeDefine`, `evaluatePipeExpression` all patched with grouped wire logic +- Sequential evaluation within groups with `value != null` short-circuit +- `lastError` tracking — rethrown if all wires in group fail +- Regression test: `test/bugfixes/overdef-input-race.test.ts` + +#### V3-Phase 10: Advanced Features ✅ COMPLETE + +- ✅ Spread syntax (`... <- a`) — `addSpread()` / `getSpreads()` in ExecutionScope +- ✅ Native batching — batch tool call support in `callTool` +- ✅ Memoized loop tools — `memoizedToolKeys` + cache check in `resolveToolResult` +- ✅ Error location tracking — `bridgeLoc` on `BridgeRuntimeError` +- ✅ Prototype pollution guards — `UNSAFE_KEYS` in `getPath`/`setPath` +- ✅ Infinite loop protection — depth tracking in `ExecutionScope` constructor +- ✅ Catch pipe source — `WireCatch` extended with `{ expr: Expression }` variant; + `buildCatch` in ast-builder uses `buildSourceExpression` for pipe chains; + `applyCatchHandler` in v3 engine evaluates full expressions; + serializer `serCatch` handles `{ expr }` via `serCatchExpr` helper + +#### V3 Remaining Disabled Scenarios + +All v3 feature phases are complete. Remaining disabled items:\n\n- `disable: true` — alias.test.ts (parser limitation: array mapping inside coalesce alternative)\n- `disable: [\"compiled\", \"parser\"]` — default for ~100+ regression tests (parser roundtrip\n disabled until Phase 5 serializer rewrite; compiler disabled until Phase 6) + +--- + +## Phase 5: Reimplement Serializer + Re-enable Parser Tests + +_Depends on Phase 4. Can run parallel with early Phase 6._ + +### Goal + +Add a **new `serializeBody()` function** that serializes the `body: Statement[]` +IR directly to Bridge source text. The existing `serializeBridgeBlock()` does +complex reverse-engineering (detecting pipe forks, expression forks, concat +forks, array scope reconstruction from flat wires) — none of that is needed +when walking the structured IR. + +### Approach + +The new serializer will be in-file next to the existing one. `serializeBridgeBlock()` +and `serializeDefineBlock()` gain a `body` fast-path: when `bridge.body` (or +`def.body`) is present, delegate to the new `serializeBody()` function. +Fall through to the legacy path when `body` is absent (backward compat). + +The existing serializer and all its helpers stay intact — they're still used by +the legacy path and by `serializeToolBlock()` (tool blocks don't have `body` in +the same way). + +### Implementation Steps + +1. **`serializeBody(stmts, indent, handleMap)`** — recursive walker: + - `WireStatement` → `target <- serExprChain(sources) [catch handler]` + - `WireAliasStatement` → `alias name <- serExprChain(sources)` + - `SpreadStatement` → `... <- serExprChain(sources)` + - `WithStatement` → `with name [as handle] [memoize]` + - `ScopeStatement` → `target { serializeBody(body, indent+1) }` + - `ForceStatement` → `force handle [catch null]` + +2. **`serExprChain(sources, catch)`** — source chain serializer: + - Walk `WireSourceEntry[]` with gate operators (`||`, `??`) + - `serExpression(expr)` for each entry + +3. **`serExpression(expr)`** — recursive expression serializer: + - `ref` → handle-resolved reference with safe navigation (`?.`) + - `literal` → formatted value + - `ternary` → `if cond then a else b` + - `and`/`or` → `left and right` / `left or right` + - `control` → `throw "msg"` / `panic "msg"` / `break` / `continue` + - `array` → `source[] as iter { body }` + - `pipe` → `handle:source` + - `binary` → `left op right` (with precedence parens) + - `unary` → `not operand` + - `concat` → `"text{ref}text"` template string + +4. **Fast-path in `serializeBridgeBlock()` and `serializeDefineBlock()`** + +5. **Re-enable parser roundtrip** — change `isDisabled()` default to only + disable `"compiled"`, or change individual tests from + `disable: ["compiled", "parser"]` to `disable: ["compiled"]` + +6. **Verify** — `pnpm build && pnpm lint && pnpm test` + +### Notes + +- `serializeToolBlock()` stays unchanged — tool blocks use a different + shape (instructions with `.path = /foo` syntax, not statements) +- Handle resolution: `serExpression` needs a handle map to convert `NodeRef` + module+type+field back to the user-facing handle alias. Can reuse + `buildHandleMap()` or the `WithStatement` bindings from the `body` itself. +- Precedence for binary ops: `* /` > `+ -` > `== != > >= < <=` > `and` > `or` + +--- + +## Phase 6: Reimplement Compiler + Re-enable Compiler Tests + +_Depends on Phase 4. Mostly parallel with Phase 5._ + +1. Update `codegen.ts` `CodegenContext` to walk `Statement[]` +2. Element scoping is now explicit — wires inside `ArrayExpression.body` + are inherently element-scoped (simpler detection) +3. Array codegen from `ArrayExpression` nodes +4. Topological sort on wire graph from statement tree +5. Re-enable `codegen.test.ts` + fuzz tests + +--- + +## Phase 7: Final Validation + +_Depends on all phases._ + +1. `pnpm build` — 0 type errors +2. `pnpm lint` — 0 lint errors +3. `pnpm test` — all tests pass, no remaining skips +4. `pnpm e2e` — all example E2E tests pass +5. Verify playground, VS Code extension language server, GraphQL adapter +6. Remove legacy `wires` field from `Bridge`, `ToolDef`, `DefineDef` +7. `pnpm changeset` + +--- + +## Key Decisions + +| Decision | Choice | Rationale | +| --------------------- | ---------------------------------- | --------------------------------------------- | +| Scope shadowing | Inner `with` shadows outer | Follows lexical scoping convention | +| Array model | Single-level + nesting in body | Simpler than chained expression form | +| Define blocks | Adopt nested `Statement[]` | Consistent with bridges | +| Migration | Single branch, incremental commits | Behavioral tests are continuous anchor | +| Lexer/grammar | NO changes | Chevrotain already parses nested syntax | +| Expression desugaring | Keep at expression level | Self-contained, doesn't interact with scoping | + +--- + +## Relevant Files + +| Area | File | Impact | +| ------------ | ------------------------------------------------ | ----------------------------------------------------------------- | +| Types | `packages/bridge-core/src/types.ts` | Add Statement, ArrayExpression; modify Bridge, ToolDef, DefineDef | +| Parser | `packages/bridge-parser/src/parser/parser.ts` | `toBridgeAst()` visitor only | +| Lexer | `packages/bridge-parser/src/parser/lexer.ts` | NO changes | +| Engine | `packages/bridge-core/src/ExecutionTree.ts` | Scope chain, wire collection, shadow creation | +| Engine | `packages/bridge-core/src/scheduleTools.ts` | Scope-aware tool scheduling | +| Engine | `packages/bridge-core/src/resolveWires.ts` | Wire resolution from tree | +| Engine | `packages/bridge-core/src/materializeShadows.ts` | Array materialization | +| Serializer | `packages/bridge-parser/src/bridge-format.ts` | Full rewrite for `Statement[]` | +| Compiler | `packages/bridge-compiler/src/codegen.ts` | `CodegenContext` tree walking | +| Linter | `packages/bridge-parser/src/bridge-lint.ts` | Walk `Statement[]` instead of `Wire[]` | +| Lang Service | `packages/bridge-parser/src/language-service.ts` | Update for new AST | + +--- + +## Verification Checkpoints + +| After | Check | Criteria | +| ------- | -------------------------- | -------------------------------------------------------- | +| Phase 1 | `pnpm build && pnpm test` | Passes with noted skips, 0 failures | +| Phase 2 | `pnpm build` | Type-checks with 0 errors | +| Phase 3 | Parser produces nested IR | Behavioral parse tests still work | +| Phase 4 | `pnpm test` (runtime path) | All ~36 regression suites pass | +| Phase 5 | Parse → serialize → parse | Roundtrip tests pass | +| Phase 6 | AOT parity | Compiler tests + fuzz parity pass | +| Phase 7 | Full suite | `pnpm build && pnpm lint && pnpm test && pnpm e2e` green | diff --git a/packages/bridge-compiler/package.json b/packages/bridge-compiler/package.json index 0812fef7..a4cd6c16 100644 --- a/packages/bridge-compiler/package.json +++ b/packages/bridge-compiler/package.json @@ -12,11 +12,11 @@ "build" ], "scripts": { - "build": "tsc -p tsconfig.build.json", - "lint:types": "tsc -p tsconfig.json", - "test": "node --experimental-transform-types --test test/*.test.ts", - "fuzz": "node --experimental-transform-types --test test/*.fuzz.ts", - "prepack": "pnpm build" + "disabled___build": "tsc -p tsconfig.build.json", + "disabled___lint:types": "tsc -p tsconfig.json", + "disabled___test": "node --experimental-transform-types --test test/*.test.ts", + "disabled___fuzz": "node --experimental-transform-types --test test/*.fuzz.ts", + "disabled___prepack": "pnpm build" }, "dependencies": { "@stackables/bridge-core": "workspace:*", diff --git a/packages/bridge-compiler/performance.md b/packages/bridge-compiler/performance.md index 2afe0695..ba7d14b0 100644 --- a/packages/bridge-compiler/performance.md +++ b/packages/bridge-compiler/performance.md @@ -4,10 +4,14 @@ Tracks engine performance work: what was tried, what failed, and what's planned. ## Summary -| # | Optimisation | Date | Result | -| --- | ------------------------------------ | ---------- | ------------------------------------------------ | -| 1 | Strict-path parity via `__path` | March 2026 | ✅ Done (correctness first, measurable slowdown) | -| 2 | Single-segment fast path via `__get` | March 2026 | ✅ Done (partial recovery on compiled hot paths) | +| # | Optimisation | Date | Result | +| --- | ------------------------------------ | ---------- | ------------------------------------------------------------------------- | +| 1 | Strict-path parity via `__path` | March 2026 | ✅ Done (correctness first, measurable slowdown) | +| 2 | Single-segment fast path via `__get` | March 2026 | ✅ Done (partial recovery on compiled hot paths) | +| 3 | Array loop IIFE elimination | June 2026 | ✅ Done (array benchmarks within 3–7 % of baseline) | +| 4 | Batch-level loc annotation | June 2026 | ✅ Done (tool-input/output IIFEs replaced with statement-level try/catch) | +| 5 | Remove `.finally()` from `__memoize` | June 2026 | ✅ Done (simple chain +7 %, chained 3-tool +12 %) | +| 6 | Cached tool fn references | June 2026 | ✅ Done (eliminates repeated `tools['name']` lookups in getter bodies) | ## Baseline (main, March 2026) @@ -23,18 +27,18 @@ document are from this machine — compare only against the same hardware. | Benchmark | ops/sec | avg (ms) | | -------------------------------------- | ------- | -------- | -| compiled: passthrough (no tools) | ~644K | 0.002 | -| compiled: short-circuit | ~640K | 0.002 | -| compiled: simple chain (1 tool) | ~612K | 0.002 | -| compiled: chained 3-tool fan-out | ~523K | 0.002 | -| compiled: flat array 10 | ~454K | 0.002 | -| compiled: flat array 100 | ~185K | 0.006 | -| compiled: flat array 1000 | ~27.9K | 0.036 | -| compiled: nested array 5×5 | ~231K | 0.004 | -| compiled: nested array 10×10 | ~103K | 0.010 | -| compiled: nested array 20×10 | ~55.0K | 0.019 | -| compiled: array + tool-per-element 10 | ~293K | 0.003 | -| compiled: array + tool-per-element 100 | ~58.7K | 0.017 | +| compiled: passthrough (no tools) | ~650K | 0.002 | +| compiled: short-circuit | ~614K | 0.002 | +| compiled: simple chain (1 tool) | ~589K | 0.002 | +| compiled: chained 3-tool fan-out | ~386K | 0.003 | +| compiled: flat array 10 | ~443K | 0.002 | +| compiled: flat array 100 | ~180K | 0.006 | +| compiled: flat array 1000 | ~26K | 0.039 | +| compiled: nested array 5×5 | ~225K | 0.005 | +| compiled: nested array 10×10 | ~100K | 0.010 | +| compiled: nested array 20×10 | ~55K | 0.019 | +| compiled: array + tool-per-element 10 | ~283K | 0.004 | +| compiled: array + tool-per-element 100 | ~56K | 0.020 | This table is the current perf level. It is updated after a successful optimisation is committed. @@ -113,3 +117,165 @@ Compiled performance is much closer to baseline now, but still below the March 2026 table on some heavy array benchmarks. The obvious next step, if needed, is specialising short strict paths of length 2–3 rather than routing every multi-segment path through the generic loop helper. + +### 3. Array loop IIFE elimination + +**Date:** March 2026 +**Status:** ✅ Done + +**Problem:** + +Array loop bodies were emitting a per-field IIFE with try/catch for `bridgeLoc` +error annotation: + +```js +__el_0.id = await (async () => { try { return __el_0.id; } catch (__e) { ... wrapErr(bridgeLoc({...})) ... } })(); +``` + +Three separate sources of overhead in the hot loop: + +1. **Per-field IIFE closure** — one closure allocation + call per field per + element. +2. **`Object.values().find()` sentinel check** — ran every iteration even when + no `break`/`continue` was possible. +3. **Per-iteration loc object allocation** — `bridgeLoc({startLine:7,...})` + allocated a fresh object per field per element. + +**What changed:** + +- **Static analysis:** Added `bodyHasControlFlow(body)` / + `exprHasControlFlow(expr)` helpers that recursively scan array body AST for + `break`/`continue` expressions. When absent, the sentinel check + (`Object.values().find(v => v === SENTINEL_BREAK)`) is elided entirely. + +- **Consolidated try/catch with `loopLocInfo`:** Instead of per-field IIFEs, + a single try/catch is hoisted **outside** the for-loop. Each field expression + becomes a comma expression that sets an integer index before evaluating: + `(__li_0 = 2, __el_0.id)`. In the catch handler, the actual loc is looked up + from a precomputed array: `[loc0, loc1, loc2][__li_0]`. + +- **Hoisted try/catch:** The try block wraps the entire for-loop rather than + each iteration, removing per-iteration overhead. + +**Result:** + +| Benchmark | Before | After | Change | +| ----------------------------- | ------ | ----- | ------ | +| compiled: flat array 10 | ~283K | ~424K | +50% | +| compiled: flat array 100 | ~61K | ~176K | +189% | +| compiled: flat array 1000 | ~7K | ~22K | +216% | +| compiled: nested array 5×5 | ~80K | ~220K | +175% | +| compiled: nested array 10×10 | ~46K | ~92K | +100% | +| compiled: nested array 20×10 | ~24K | ~49K | +104% | +| compiled: tool-per-element 10 | ~217K | ~278K | +28% | + +Array benchmarks went from 50–75 % below baseline to within 3–7 %. + +### 4. Batch-level loc annotation + +**Date:** March 2026 +**Status:** ✅ Done + +**Problem:** + +Outside of array loops, every output wire and tool-input field was still wrapped +in an async IIFE for `bridgeLoc` annotation: + +```js +__result.foo = await (async () => { try { return expr; } catch (__e) { ... } })(); +``` + +For wires going into `emitParallelAssignments` (Promise.all batches), this +per-expression IIFE was unnecessary — error annotation could happen at the batch +level instead. + +**What changed:** + +- **`compileBody` pending wires:** For single-source expressions without + `wireCatch`, uses `compileSourceChain` (raw expression, no IIFE) and captures + `locExpr` separately. Falls back to `compileSourceChainWithLoc` for + multi-source or wireCatch cases. + +- **Tool input field wires:** Same pattern — single-source without wireCatch + uses `compileSourceChain` + `locExpr`. + +- **`emitParallelAssignments`:** Accepts `locExpr?: string` per item. For sync + items with a loc, wraps the assignment in a statement-level try/catch. For + async batches, builds a `__locs` array and annotates errors in the existing + rethrow loop. Single async items with a loc get a try/catch around the + assignment. + +**Result:** + +| Benchmark | Before | After | Change | +| -------------------------------- | ------ | ----- | ------ | +| compiled: simple chain (1 tool) | ~536K | ~551K | +3% | +| compiled: chained 3-tool fan-out | ~329K | ~343K | +4% | + +Modest gains because most expressions were already single-segment. The remaining +gap on chained 3-tool (~343K vs ~523K baseline, −34 %) comes from feature +additions in tool getter bodies that the baseline did not have: sync tool +detection, timeout handling, `__checkAbort()` calls, and conditional await. +These are correctness requirements and are not optimisable without removing +features. + +### 5. Remove `.finally()` from `__memoize` + +**Date:** June 2026 +**Status:** ✅ Done + +**Problem:** + +The `__memoize` helper wrapped every getter’s Promise in `.finally(() => { + active = false; })`. This added an extra microtask per tool getter invocation. +Because `cached` is set on the first call and re-returned on every subsequent +call, `active` is never checked after the first invocation completes and +therefore never needs to be reset. + +**What changed:** + +`fn().finally(() => { active = false; })` → `fn()`. One fewer `.finally()` +allocation per memoized tool getter. + +**Result:** + +| Benchmark | Before | After | Change | +| -------------------------------- | ------ | ----- | ------ | +| compiled: simple chain (1 tool) | ~551K | ~589K | +7% | +| compiled: chained 3-tool fan-out | ~343K | ~386K | +12% | + +### 6. Cached tool fn references + +**Date:** June 2026 +**Status:** ✅ Done + +**Problem:** + +Tool getter bodies referenced tools via `tools['name']` on every access (type +check, sync detection, trace detection, invocation, etc.). The preamble already +declared `const __toolFn_name_0 = tools['name']`, but `resolveToolFnExpr` +ignored this cached variable and returned the dynamic lookup. + +**What changed:** + +`resolveToolFnExpr` now returns the cached `__toolFn_` variable from the scope +binding. The tool function reference is resolved once at declaration time and +reused in all getter body accesses. + +**Result:** + +Combined with optimisation #5 (measured together): + +| Benchmark | Start | After #5 + #6 | Baseline | Gap | +| -------------------------------------- | ----- | ------------- | -------- | ---- | +| compiled: simple chain (1 tool) | ~551K | ~589K | ~612K | −4% | +| compiled: chained 3-tool fan-out | ~343K | ~386K | ~523K | −26% | +| compiled: array + tool-per-element 100 | ~49K | ~56K | ~59K | −6% | + +**What remains:** + +The remaining chained 3-tool gap (−26 %) comes from per-tool correctness +overhead that the baseline lacked: sync tool validation +(`tool.bridge?.sync && typeof __raw.then`), timeout handling (`Promise.race`), +`__checkAbort()` calls, and conditional await. These are not optimisable +without reducing features. diff --git a/packages/bridge-compiler/src/bridge-asserts.ts b/packages/bridge-compiler/src/bridge-asserts.ts index acff2aa5..f7dc5f97 100644 --- a/packages/bridge-compiler/src/bridge-asserts.ts +++ b/packages/bridge-compiler/src/bridge-asserts.ts @@ -1,12 +1,4 @@ -import { - SELF_MODULE, - type Bridge, - type NodeRef, - type Wire, -} from "@stackables/bridge-core"; - -const isPull = (w: Wire): boolean => w.sources[0]?.expr.type === "ref"; -const wRef = (w: Wire): NodeRef => (w.sources[0].expr as { ref: NodeRef }).ref; +import { type Bridge } from "@stackables/bridge-core"; export class BridgeCompilerIncompatibleError extends Error { constructor( @@ -18,195 +10,14 @@ export class BridgeCompilerIncompatibleError extends Error { } } -function matchesRequestedField( - path: string, - requestedFields?: string[], -): boolean { - if (!requestedFields || requestedFields.length === 0) { - return true; - } - - return requestedFields.some((requested) => { - if (requested === path) { - return true; - } - - if (requested.endsWith(".*")) { - const prefix = requested.slice(0, -2); - return path === prefix || path.startsWith(`${prefix}.`); - } - - return false; - }); -} - -function isToolRef(ref: NodeRef, bridge: Bridge): boolean { - if ( - ref.module === SELF_MODULE && - ref.type === bridge.type && - ref.field === bridge.field - ) - return false; - if (ref.module === SELF_MODULE && ref.type === "Context") return false; - if (ref.module === SELF_MODULE && ref.type === "Const") return false; - if (ref.module.startsWith("__define_")) return false; - if (ref.module === "__local") return false; - return true; -} - +/** + * Compatibility check — the new v2 compiler will throw + * BridgeCompilerIncompatibleError from codegen itself for unsupported features. + * This function is now a no-op; kept for API compatibility. + */ export function assertBridgeCompilerCompatible( - bridge: Bridge, - requestedFields?: string[], + _bridge: Bridge, + _requestedFields?: string[], ): void { - const op = `${bridge.type}.${bridge.field}`; - - const wires: Wire[] = bridge.wires; - - // Pipe-handle trunk keys — block-scoped aliases inside array maps - // reference these; the compiler handles them correctly. - const pipeTrunkKeys = new Set((bridge.pipeHandles ?? []).map((ph) => ph.key)); - - for (const w of wires) { - // User-level alias (Shadow) wires: compiler has TDZ ordering bugs. - // Block-scoped aliases inside array maps wire FROM a pipe-handle tool - // instance (key is in pipeTrunkKeys) and are handled correctly. - if (w.to.module === "__local" && w.to.type === "Shadow") { - if (!isPull(w)) continue; - const fromKey = - wRef(w).instance != null - ? `${wRef(w).module}:${wRef(w).type}:${wRef(w).field}:${wRef(w).instance}` - : `${wRef(w).module}:${wRef(w).type}:${wRef(w).field}`; - if (!pipeTrunkKeys.has(fromKey)) { - throw new BridgeCompilerIncompatibleError( - op, - "Alias (shadow) wires are not yet supported by the compiler.", - ); - } - continue; - } - - if (!isPull(w)) continue; - - // Catch fallback on pipe wires (expression results) — the catch must - // propagate to the upstream tool, not the internal operator; codegen - // does not handle this yet. - if (w.pipe && w.catch) { - throw new BridgeCompilerIncompatibleError( - op, - "Catch fallback on expression (pipe) wires is not yet supported by the compiler.", - ); - } - - // Catch fallback that references a pipe handle — the compiler eagerly - // calls all tools in the catch branch even when the main wire succeeds. - if (w.catch && "ref" in w.catch) { - const ref = w.catch.ref; - if (ref.instance != null) { - const refKey = `${ref.module}:${ref.type}:${ref.field}:${ref.instance}`; - if (bridge.pipeHandles?.some((ph) => ph.key === refKey)) { - throw new BridgeCompilerIncompatibleError( - op, - "Catch fallback referencing a pipe expression is not yet supported by the compiler.", - ); - } - } - } - - // Catch fallback on wires whose source tool has tool-backed input - // dependencies — the compiler only catch-guards the direct source - // tool, not its transitive dependency chain. - if (w.catch && isToolRef(wRef(w), bridge)) { - const sourceTrunk = `${wRef(w).module}:${wRef(w).type}:${wRef(w).field}`; - for (const iw of wires) { - if (!isPull(iw)) continue; - const iwDest = `${iw.to.module}:${iw.to.type}:${iw.to.field}`; - if (iwDest === sourceTrunk && isToolRef(wRef(iw), bridge)) { - throw new BridgeCompilerIncompatibleError( - op, - "Catch fallback on wires with tool chain dependencies is not yet supported by the compiler.", - ); - } - } - } - - // Fallback chains (|| / ??) with tool-backed refs — compiler eagerly - // calls all tools via Promise.all, so short-circuit semantics are lost - // and tool side effects fire unconditionally. - for (const src of w.sources.slice(1)) { - if (src.expr.type === "ref" && isToolRef(src.expr.ref, bridge)) { - throw new BridgeCompilerIncompatibleError( - op, - "Fallback chains (|| / ??) with tool-backed sources are not yet supported by the compiler.", - ); - } - } - } - - // Same-cost overdefinition sourced only from tools can diverge from runtime - // tracing/error behavior in current AOT codegen; compile must downgrade. - const toolOnlyOverdefs = new Map(); - for (const w of wires) { - if ( - w.to.module !== SELF_MODULE || - w.to.type !== bridge.type || - w.to.field !== bridge.field - ) { - continue; - } - if (!isPull(w) || !isToolRef(wRef(w), bridge)) { - continue; - } - - const outputPath = w.to.path.join("."); - if (!matchesRequestedField(outputPath, requestedFields)) { - continue; - } - - toolOnlyOverdefs.set( - outputPath, - (toolOnlyOverdefs.get(outputPath) ?? 0) + 1, - ); - } - - for (const [outputPath, count] of toolOnlyOverdefs) { - if (count > 1) { - throw new BridgeCompilerIncompatibleError( - op, - `Tool-only overdefinition for output path "${outputPath}" is not yet supported by the compiler.`, - ); - } - } - - // Pipe handles with extra bridge wires to the same tool — the compiler - // treats pipe forks as independent tool calls, so bridge wires that set - // fields on the main tool trunk are not merged into the fork's input. - if (bridge.pipeHandles && bridge.pipeHandles.length > 0) { - const pipeHandleKeys = new Set(); - const pipedToolNames = new Set(); - for (const ph of bridge.pipeHandles) { - pipeHandleKeys.add(ph.key); - pipedToolNames.add( - `${ph.baseTrunk.module}:${ph.baseTrunk.type}:${ph.baseTrunk.field}`, - ); - } - - for (const w of wires) { - if (!isPull(w) || w.to.path.length === 0) continue; - // Build the full key for this wire target - const fullKey = - w.to.instance != null - ? `${w.to.module}:${w.to.type}:${w.to.field}:${w.to.instance}` - : `${w.to.module}:${w.to.type}:${w.to.field}`; - // Skip wires that target the pipe handle itself (fork input) - if (pipeHandleKeys.has(fullKey)) continue; - // Check if this wire targets a tool that also has pipe calls - const toolName = `${w.to.module}:${w.to.type}:${w.to.field}`; - if (pipedToolNames.has(toolName)) { - throw new BridgeCompilerIncompatibleError( - op, - "Bridge wires that set fields on a tool with pipe calls are not yet supported by the compiler.", - ); - } - } - } + // no-op — the new compiler handles incompatibility inline } diff --git a/packages/bridge-compiler/src/codegen.ts b/packages/bridge-compiler/src/codegen.ts index 02eb0755..4943e58e 100644 --- a/packages/bridge-compiler/src/codegen.ts +++ b/packages/bridge-compiler/src/codegen.ts @@ -1,170 +1,41 @@ /** - * AOT code generator — turns a Bridge AST into a standalone JavaScript function. + * AOT code generator v2 — lazy-getter pull-based compilation. * - * SECURITY NOTE: This entire file is a compiler back-end. Its sole purpose is - * to transform a fully-parsed, validated Bridge AST into JavaScript source - * strings. Every template-literal interpolation below assembles *generated - * code* from deterministic AST walks — no raw external / user input is ever - * spliced into the output. Security scanners (CodeQL js/code-injection, - * Semgrep, LGTM) correctly flag dynamic code construction as a pattern worth - * reviewing; after review the usage here is intentional and safe. + * Compiles a Bridge AST (Statement[] body) into a standalone JavaScript + * function that mirrors the core engine's pull-based evaluation model. * - * lgtm [js/code-injection] + * Instead of topological sorting and eager tool calls, the generated code + * uses memoized lazy getters — tools are only invoked when an output wire + * explicitly asks for their data. + * + * SECURITY NOTE: This file is a compiler back-end. It transforms a fully-parsed, + * validated Bridge AST into JavaScript source strings. No raw external / user + * input is ever spliced into the output. * - * Supports: - * - Pull wires (`target <- source`) - * - Constant wires (`target = "value"`) - * - Nullish coalescing (`?? fallback`) - * - Falsy fallback (`|| fallback`) - * - Catch fallback (`catch`) - * - Conditional wires (ternary) - * - Array mapping (`[] as iter { }`) - * - Force statements (`force `, `force catch null`) - * - ToolDef merging (tool blocks with wires and `on error`) + * lgtm [js/code-injection] */ import type { BridgeDocument, Bridge, - Wire, + Statement, + Expression, NodeRef, + WireSourceEntry, + WireCatch, + WireStatement, + WireAliasStatement, + ScopeStatement, + WithStatement, + SpreadStatement, + ForceStatement, ToolDef, - Expression, - ControlFlowInstruction, + DefineDef, } from "@stackables/bridge-core"; -import { BridgePanicError } from "@stackables/bridge-core"; -import type { SourceLocation } from "@stackables/bridge-types"; -import { - assertBridgeCompilerCompatible, - BridgeCompilerIncompatibleError, -} from "./bridge-asserts.ts"; - -const SELF_MODULE = "_"; - -// ── Wire accessor helpers ─────────────────────────────────────────────────── -type RefExpr = Extract; -type LitExpr = Extract; -type TernExpr = Extract; -type AndOrExpr = - | Extract - | Extract; -type ControlExpr = Extract; - -function isPull(w: Wire): boolean { - return w.sources[0]!.expr.type === "ref"; -} -function isLit(w: Wire): boolean { - return w.sources[0]!.expr.type === "literal"; -} -function isTern(w: Wire): boolean { - return w.sources[0]!.expr.type === "ternary"; -} -function isAndW(w: Wire): boolean { - return w.sources[0]!.expr.type === "and"; -} -function isOrW(w: Wire): boolean { - return w.sources[0]!.expr.type === "or"; -} - -/** Primary source ref (for pull wires). */ -function wRef(w: Wire): NodeRef { - return (w.sources[0]!.expr as RefExpr).ref; -} -/** Primary source literal value (for constant wires). */ -function wVal(w: Wire): string { - return (w.sources[0]!.expr as LitExpr).value; -} -/** Safe flag on a pull wire's ref expression. */ -function wSafe(w: Wire): true | undefined { - return (w.sources[0]!.expr as RefExpr).safe; -} -/** Source ref location (for pull wires). */ -function wRefLoc(w: Wire): SourceLocation | undefined { - return (w.sources[0]!.expr as RefExpr).refLoc; -} -/** Ternary expression from a conditional wire. */ -function wTern(w: Wire): TernExpr { - return w.sources[0]!.expr as TernExpr; -} -/** And/Or expression from a logical wire. */ -function wAndOr(w: Wire): AndOrExpr { - return w.sources[0]!.expr as AndOrExpr; -} -/** Ref from an expression (for ref-type expressions). */ -function eRef(e: Expression): NodeRef { - return (e as RefExpr).ref; -} -/** Value from an expression (for literal-type expressions). */ -function eVal(e: Expression): string { - return (e as LitExpr).value; -} - -/** Whether a wire has a catch handler. */ -function hasCatchRef(w: Wire): boolean { - return w.catch != null && "ref" in w.catch; -} -function hasCatchValue(w: Wire): boolean { - return w.catch != null && "value" in w.catch; -} -function hasCatchControl(w: Wire): boolean { - return w.catch != null && "control" in w.catch; -} -/** Whether a wire has any catch fallback (ref or value). */ -function hasCatchFallback(w: Wire): boolean { - return hasCatchRef(w) || hasCatchValue(w); -} -/** Get the catch ref if present. */ -function catchRef(w: Wire): NodeRef | undefined { - return w.catch && "ref" in w.catch ? w.catch.ref : undefined; -} -/** Get the catch value if present. */ -function catchValue(w: Wire): string | undefined { - return w.catch && "value" in w.catch ? w.catch.value : undefined; -} -/** Get the catch control if present. */ -function catchControl(w: Wire): ControlFlowInstruction | undefined { - return w.catch && "control" in w.catch ? w.catch.control : undefined; -} -/** Get the catch location. */ -function catchLoc(w: Wire): SourceLocation | undefined { - return w.catch?.loc; -} -/** Get fallback source entries (everything after the primary source). */ -function fallbacks(w: Wire) { - return w.sources.slice(1); -} -/** Whether a wire has fallback entries. */ -function hasFallbacks(w: Wire): boolean { - return w.sources.length > 1; -} - -function matchesRequestedFields( - fieldPath: string, - requestedFields: string[] | undefined, -): boolean { - if (!requestedFields || requestedFields.length === 0) return true; - - for (const pattern of requestedFields) { - if (pattern === fieldPath) return true; - - if (fieldPath.startsWith(pattern + ".")) return true; - - if (pattern.startsWith(fieldPath + ".")) return true; - - if (pattern.endsWith(".*")) { - const prefix = pattern.slice(0, -2); - if (fieldPath.startsWith(prefix + ".")) { - const rest = fieldPath.slice(prefix.length + 1); - if (!rest.includes(".")) return true; - } - if (fieldPath === prefix) return true; - } - } - - return false; -} +import { BridgeCompilerIncompatibleError } from "./bridge-asserts.ts"; +import { matchesRequestedFields } from "@stackables/bridge-core"; -// ── Public API ────────────────────────────────────────────────────────────── +// ── Public types ──────────────────────────────────────────────────────────── export interface CompileOptions { /** The operation to compile, e.g. "Query.livingStandard" */ @@ -186,4677 +57,3335 @@ export interface CompileResult { functionBody: string; } -/** - * Compile a single bridge operation into a standalone async JavaScript function. - * - * The generated function has the signature: - * `async function _(input, tools, context) → Promise` - * - * It calls tools in topological dependency order and returns the output object. - */ -export function compileBridge( - document: BridgeDocument, - options: CompileOptions, -): CompileResult { - const { operation } = options; - const dotIdx = operation.indexOf("."); - if (dotIdx === -1) - throw new Error( - `Invalid operation: "${operation}", expected "Type.field".`, - ); - const type = operation.substring(0, dotIdx); - const field = operation.substring(dotIdx + 1); - - const bridge = document.instructions.find( - (i): i is Bridge => - i.kind === "bridge" && i.type === type && i.field === field, - ); - if (!bridge) - throw new Error(`No bridge definition found for operation: ${operation}`); - - assertBridgeCompilerCompatible(bridge, options.requestedFields); +// ── Helpers ───────────────────────────────────────────────────────────────── - // Collect const definitions from the document - const constDefs = new Map(); - for (const inst of document.instructions) { - if (inst.kind === "const") constDefs.set(inst.name, inst.value); - } +const SELF_MODULE = "_"; - // Collect tool definitions from the document - const toolDefs = document.instructions.filter( - (i): i is ToolDef => i.kind === "tool", - ); +/** Safe JS identifier from a bridge handle name. */ +function safeId(name: string): string { + return name.replace(/[^a-zA-Z0-9_$]/g, "_"); +} - const ctx = new CodegenContext( - bridge, - constDefs, - toolDefs, - options.requestedFields, - ); - return ctx.compile(); +/** Safe JS string literal (single-quoted). */ +function jsStr(s: string): string { + return "'" + s.replace(/\\/g, "\\\\").replace(/'/g, "\\'") + "'"; } -// ── Helpers ───────────────────────────────────────────────────────────────── +/** Emit a JS object literal for a SourceLocation. */ +function jsLoc(loc: { + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; +}): string { + return `{startLine:${loc.startLine},startColumn:${loc.startColumn},endLine:${loc.endLine},endColumn:${loc.endColumn}}`; +} -type DetectedControlFlow = { - kind: "break" | "continue" | "throw" | "panic"; - levels: number; -}; - -/** Check if any wire in a set has a control flow instruction (break/continue/throw/panic). */ -function detectControlFlow(wires: Wire[]): DetectedControlFlow | null { - for (const w of wires) { - for (const fb of w.sources.slice(1)) { - if (fb.expr.type === "control") { - const ctrl = fb.expr.control; - const kind = ctrl.kind as "break" | "continue" | "throw" | "panic"; - const levels = - kind === "break" || kind === "continue" - ? Math.max(1, Number((ctrl as any).levels) || 1) - : 1; - return { kind, levels }; +/** + * Recursively check if a statement body (or any nested body) contains + * break or continue control flow expressions. Used to decide whether + * the sentinel check in array loops can be elided. + */ +function bodyHasControlFlow(body: Statement[]): boolean { + for (const stmt of body) { + if (stmt.kind === "scope") { + if (bodyHasControlFlow(stmt.body)) return true; + continue; + } + const sources: WireSourceEntry[] | undefined = + "sources" in stmt ? stmt.sources : undefined; + if (sources) { + for (const src of sources) { + if (exprHasControlFlow(src.expr)) return true; } } - const cc = catchControl(w); - if (cc) { - const kind = cc.kind as "break" | "continue" | "throw" | "panic"; - const levels = - kind === "break" || kind === "continue" - ? Math.max(1, Number((cc as any).levels) || 1) - : 1; - return { kind, levels }; + const wireCatch: WireCatch | undefined = + "catch" in stmt ? (stmt as any).catch : undefined; + if (wireCatch && "control" in wireCatch) { + const k = wireCatch.control.kind; + if (k === "break" || k === "continue") return true; } } - return null; -} - -function splitToolName(name: string): { module: string; fieldName: string } { - const dotIdx = name.lastIndexOf("."); - if (dotIdx === -1) return { module: SELF_MODULE, fieldName: name }; - return { - module: name.substring(0, dotIdx), - fieldName: name.substring(dotIdx + 1), - }; + return false; } -/** Build a trunk key from a NodeRef (same logic as bridge-core's trunkKey). */ -function refTrunkKey(ref: NodeRef): string { - if (ref.element) return `${ref.module}:${ref.type}:${ref.field}:*`; - return `${ref.module}:${ref.type}:${ref.field}${ref.instance != null ? `:${ref.instance}` : ""}`; +function exprHasControlFlow(expr: Expression): boolean { + switch (expr.type) { + case "control": + return expr.control.kind === "break" || expr.control.kind === "continue"; + case "ternary": + return ( + exprHasControlFlow(expr.cond) || + exprHasControlFlow(expr.then) || + exprHasControlFlow(expr.else) + ); + case "and": + case "or": + case "binary": + return exprHasControlFlow(expr.left) || exprHasControlFlow(expr.right); + case "concat": + return expr.parts.some(exprHasControlFlow); + case "array": + return bodyHasControlFlow(expr.body); + default: + return false; + } } /** - * Emit a coerced constant value as a JavaScript literal. - * Mirrors the runtime's `coerceConstant` semantics. + * Compile a NodeRef path access into JS property access. + * e.g. ref with path ["data", "items"] → `.data.items` + * Handles rootSafe (?.) and pathSafe per-segment. + * + * Bridge `?.` has segment-local semantics: `a?.b.c` means "if a is nullish, + * substitute undefined for .b, then access .c normally (may throw)". + * JS `?.` short-circuits the entire chain instead, so when a safe segment + * is followed by a non-safe segment we must generate a __getPath helper call. + * + * When `baseExpr` is provided and mixed safe/non-safe is detected, returns + * a complete expression `__getPath(base, [...], [...])` instead of a suffix. + * Otherwise returns a property-access suffix string. */ -function emitCoerced(raw: string): string { - const trimmed = raw.trim(); - if (trimmed === "true") return "true"; - if (trimmed === "false") return "false"; - if (trimmed === "null") return "null"; - // JSON-encoded string literal: '"hello"' → "hello" - if ( - trimmed.length >= 2 && - trimmed.charCodeAt(0) === 0x22 && - trimmed.charCodeAt(trimmed.length - 1) === 0x22 - ) { - return trimmed; // already a valid JS string literal +function emitPath( + ref: NodeRef, + startIdx = 0, + forceRootSafe = false, + baseExpr?: string, +): string { + const pathSlice = ref.path.slice(startIdx); + if (pathSlice.length === 0) return ""; + + // Build per-segment safe flags + const safes = pathSlice.map((_, i) => { + const idx = i + startIdx; + return !!( + ref.pathSafe?.[idx] || + (idx === 0 && (ref.rootSafe || forceRootSafe)) + ); + }); + + // Detect safe→non-safe transition + let hasMixedSafe = false; + for (let i = 0; i < pathSlice.length - 1; i++) { + if (safes[i]) { + for (let j = i + 1; j < pathSlice.length; j++) { + if (!safes[j]) { + hasMixedSafe = true; + break; + } + } + if (hasMixedSafe) break; + } } - // Numeric literal - const num = Number(trimmed); - if (trimmed !== "" && !isNaN(num) && isFinite(num)) return String(num); - // Fallback: raw string - return JSON.stringify(raw); -} -/** - * Build a nested JS object literal from entries where each entry is - * [remainingPathSegments, expression]. Groups entries by first path segment - * and recurses for deeper nesting. - */ -function emitNestedObjectLiteral(entries: [string[], string][]): string { - const byKey = new Map(); - for (const [path, expr] of entries) { - const key = path[0]!; - if (!byKey.has(key)) byKey.set(key, []); - byKey.get(key)!.push([path.slice(1), expr]); + if (hasMixedSafe && baseExpr) { + // Use __getPath helper for Bridge's segment-local safe navigation + const segs = pathSlice.map((s) => jsStr(s)).join(", "); + const flags = safes.join(", "); + return `__getPath(${baseExpr}, [${segs}], [${flags}])`; + } + + // Use __getPath for multi-segment paths to match runtime primitive-check semantics + if (pathSlice.length >= 2 && baseExpr) { + const segs = pathSlice.map((s) => jsStr(s)).join(", "); + const flags = safes.join(", "); + return `__getPath(${baseExpr}, [${segs}], [${flags}])`; } - const parts: string[] = []; - for (const [key, subEntries] of byKey) { - if (subEntries.some(([p]) => p.length === 0)) { - const leaf = subEntries.find(([p]) => p.length === 0)!; - parts.push(`${JSON.stringify(key)}: ${leaf[1]}`); + + // Standard path emission + let code = ""; + for (let i = startIdx; i < ref.path.length; i++) { + const seg = ref.path[i]!; + const safe = + ref.pathSafe?.[i] || (i === 0 && (ref.rootSafe || forceRootSafe)); + if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(seg)) { + code += safe ? "?." : "."; + code += seg; } else { - parts.push( - `${JSON.stringify(key)}: ${emitNestedObjectLiteral(subEntries)}`, - ); + code += safe ? "?." : ""; + code += `[${jsStr(seg)}]`; } } - return `{ ${parts.join(", ")} }`; + return code; } +const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]); + +// ── Scope-based code generator ────────────────────────────────────────────── + /** - * Parse a const value at compile time and emit it as an inline JS literal. - * Since const values are JSON, we can JSON.parse at compile time and - * re-serialize as a JavaScript expression, avoiding runtime JSON.parse. + * Tracks what bindings are visible in the current scope. + * Each scope can shadow parent bindings. */ -function emitParsedConst(raw: string): string { - try { - const parsed = JSON.parse(raw); - return JSON.stringify(parsed); - } catch { - // If JSON.parse fails, fall back to runtime parsing - return `JSON.parse(${JSON.stringify(raw)})`; - } +interface ScopeBinding { + kind: + | "tool" + | "input" + | "output" + | "context" + | "const" + | "define" + | "alias" + | "iterator"; + /** JS expression to access this binding's value */ + jsExpr: string; + /** For tools: the tool function name for lookup */ + toolName?: string; + /** For tools: cached __toolFn_ variable referencing tools['name'] */ + toolFnExpr?: string; + /** For defines: the define name for lookup */ + defineName?: string; + /** For tools: whether this is memoized */ + memoize?: boolean; + /** For tools: the tool instance identifier */ + instanceKey?: string; } -// ── Code-generation context ───────────────────────────────────────────────── +class ScopeChain { + private bindings = new Map(); + constructor(private parent?: ScopeChain) {} -interface ToolInfo { - trunkKey: string; - toolName: string; - varName: string; -} + set(handle: string, binding: ScopeBinding) { + this.bindings.set(handle, binding); + } + + get(handle: string): ScopeBinding | undefined { + return this.bindings.get(handle) ?? this.parent?.get(handle); + } + + /** Find a tool binding by tool name (not handle name), with instance matching. */ + findTool( + toolName: string, + instance: number | undefined, + ): ScopeBinding | undefined { + let instanceCount = 0; + for (const [, binding] of this.bindings) { + if (binding.kind === "tool" && binding.toolName === toolName) { + instanceCount++; + if (!instance || instanceCount === instance) { + return binding; + } + } + } + return this.parent?.findTool(toolName, instance); + } -/** Set of internal tool field names that can be inlined by the AOT compiler. */ -const INTERNAL_TOOLS = new Set([ - "concat", - "add", - "subtract", - "multiply", - "divide", - "eq", - "neq", - "gt", - "gte", - "lt", - "lte", - "not", - "and", - "or", -]); + child(): ScopeChain { + return new ScopeChain(this); + } +} class CodegenContext { private bridge: Bridge; private constDefs: Map; - private toolDefs: ToolDef[]; - private selfTrunkKey: string; - private varMap = new Map(); - private tools = new Map(); - private toolCounter = 0; - /** Set of trunk keys for define-in/out virtual containers. */ - private defineContainers = new Set(); - /** Trunk keys of pipe/expression tools that use internal implementations. */ - private internalToolKeys = new Set(); - /** Trunk keys of tools compiled in catch-guarded mode (have a `_err` variable). */ - private catchGuardedTools = new Set(); - /** Trunk keys of tools whose inputs depend on element wires (must be inlined in map callbacks). */ - private elementScopedTools = new Set(); - /** Trunk keys of tools that are only referenced in ternary branches (can be lazily evaluated). */ - private ternaryOnlyTools = new Set(); - /** Map from element-scoped non-internal tool trunk key to loop-local variable name. - * Populated during array body generation to deduplicate tool calls within one element. */ - private elementLocalVars = new Map(); - /** Current element variable name, set during element wire expression generation. */ - private currentElVar: string | undefined; - /** Stack of active element variables from outermost to innermost array scopes. */ - private elementVarStack: string[] = []; - /** Map from ToolDef dependency tool name to its emitted variable name. - * Populated lazily by emitToolDeps to avoid duplicating calls. */ - private toolDepVars = new Map(); - /** Sparse fieldset filter for output wire pruning. */ + private toolDefs: Map; + private defineDefs: Map; + private toolDefCache = new Map(); + private toolGetterCount = 0; + private lines: string[] = []; + private indent = 1; // start inside function body + private iteratorStack: { iterVar: string; outVar: string }[] = []; + private arrayDepthCounter = 0; + private overdefCount = 0; + private needsToolCostHelper = false; + private needsBatchHelper = false; + private currentBatchQueue: string | undefined; private requestedFields: string[] | undefined; - /** Per tool signature cursor used to assign distinct wire instances to repeated handle bindings. */ - private toolInstanceCursors = new Map(); - /** Tool trunk keys declared with `memoize`. */ - private memoizedToolKeys = new Set(); - /** Map from tool function name to its upfront-resolved variable name. */ - private toolFnVars = new Map(); - private toolFnVarCounter = 0; + private parallelBatchCount = 0; + /** + * Map from tool getter name → memo map variable name. + * Populated during emitToolGetters for memoized-in-loop tools. + * The Maps are emitted at function scope via post-processing. + */ + private memoMapForGetter = new Map(); + private memoMapCounter = 0; + /** List of memo map variable names to inject at function scope. */ + private memoMapDeclarations: string[] = []; + /** + * When set inside an array loop body, compileSourceChainWithLoc uses a + * shared loc-index variable + single try/catch instead of per-wire IIFEs. + * Locs are precomputed in an array; only an integer index is written per-access. + */ + private loopLocInfo: + | { indexVar: string; locsVar: string; locs: string[] } + | undefined; constructor( bridge: Bridge, constDefs: Map, - toolDefs: ToolDef[], + toolDefs: Map, + defineDefs: Map, requestedFields?: string[], ) { this.bridge = bridge; this.constDefs = constDefs; this.toolDefs = toolDefs; - this.selfTrunkKey = `${SELF_MODULE}:${bridge.type}:${bridge.field}`; - this.requestedFields = requestedFields?.length - ? requestedFields - : undefined; - - for (const h of bridge.handles) { - switch (h.kind) { - case "input": - case "output": - // Input and output share the self trunk key; distinguished by wire direction - break; - case "context": - this.varMap.set(`${SELF_MODULE}:Context:context`, "context"); - break; - case "const": - // Constants are inlined directly - break; - case "define": { - // Define blocks are inlined at parse time. The parser creates - // __define_in_ and __define_out_ modules that act - // as virtual data containers for routing data in/out of the define. - const inModule = `__define_in_${h.handle}`; - const outModule = `__define_out_${h.handle}`; - const inTk = `${inModule}:${bridge.type}:${bridge.field}`; - const outTk = `${outModule}:${bridge.type}:${bridge.field}`; - const inVn = `_d${++this.toolCounter}`; - const outVn = `_d${++this.toolCounter}`; - this.varMap.set(inTk, inVn); - this.varMap.set(outTk, outVn); - this.defineContainers.add(inTk); - this.defineContainers.add(outTk); - break; - } - case "tool": { - const { module, fieldName } = splitToolName(h.name); - // Module-prefixed tools use the bridge's type; self-module tools use "Tools". - // However, tools inlined from define blocks may use type "Define". - // We detect the correct type by scanning the wires for a matching ref. - let refType = module === SELF_MODULE ? "Tools" : bridge.type; - for (const w of this.bridge.wires) { - if ( - w.to.module === module && - w.to.field === fieldName && - w.to.instance != null - ) { - refType = w.to.type; - break; - } - if ( - isPull(w) && - wRef(w).module === module && - wRef(w).field === fieldName && - wRef(w).instance != null - ) { - refType = wRef(w).type; - break; - } - } - const instance = this.findNextInstance(module, refType, fieldName); - const tk = `${module}:${refType}:${fieldName}:${instance}`; - const vn = `_t${++this.toolCounter}`; - this.varMap.set(tk, vn); - this.tools.set(tk, { trunkKey: tk, toolName: h.name, varName: vn }); - if (h.memoize) { - this.memoizedToolKeys.add(tk); - } - break; - } - } - } - - // Register pipe handles (synthetic tool instances for interpolation, - // expressions, and explicit pipe operators) - if (bridge.pipeHandles) { - // Build handle→fullName map for resolving dotted tool names (e.g. "std.str.toUpperCase") - const handleToolNames = new Map(); - for (const h of bridge.handles) { - if (h.kind === "tool") handleToolNames.set(h.handle, h.name); - } - - for (const ph of bridge.pipeHandles) { - // Use the pipe handle's key directly — it already includes the correct instance - const tk = ph.key; - if (!this.tools.has(tk)) { - const vn = `_t${++this.toolCounter}`; - this.varMap.set(tk, vn); - const field = ph.baseTrunk.field; - // Normalise __and/__or → and/or so they match INTERNAL_TOOLS - const normField = field.startsWith("__") ? field.slice(2) : field; - // Use the full tool name from the handle binding (e.g. "std.str.toUpperCase") - // falling back to just the field name for internal/synthetic handles - const fullToolName = handleToolNames.get(ph.handle) ?? normField; - this.tools.set(tk, { - trunkKey: tk, - toolName: fullToolName, - varName: vn, - }); - if (INTERNAL_TOOLS.has(normField)) { - this.internalToolKeys.add(tk); - } - } - } - } - - // Detect alias declarations — wires targeting __local:Shadow: modules. - // These act as virtual containers (like define modules). - for (const w of this.bridge.wires) { - const toTk = refTrunkKey(w.to); - if ( - w.to.module === "__local" && - w.to.type === "Shadow" && - !this.varMap.has(toTk) - ) { - const vn = `_a${++this.toolCounter}`; - this.varMap.set(toTk, vn); - this.defineContainers.add(toTk); - } - if ( - isPull(w) && - wRef(w).module === "__local" && - wRef(w).type === "Shadow" - ) { - const fromTk = refTrunkKey(wRef(w)); - if (!this.varMap.has(fromTk)) { - const vn = `_a${++this.toolCounter}`; - this.varMap.set(fromTk, vn); - this.defineContainers.add(fromTk); - } - } - } + this.defineDefs = defineDefs; + this.requestedFields = requestedFields; } - /** Find the instance number for a tool from the wires. */ - private findNextInstance( - module: string, - type: string, - field: string, - ): number { - const sig = `${module}:${type}:${field}`; - const instances: number[] = []; - for (const w of this.bridge.wires) { - if ( - w.to.module === module && - w.to.type === type && - w.to.field === field && - w.to.instance != null - ) - instances.push(w.to.instance); - if ( - isPull(w) && - wRef(w).module === module && - wRef(w).type === type && - wRef(w).field === field && - wRef(w).instance != null - ) - instances.push(wRef(w).instance!); - } - const uniqueInstances = [...new Set(instances)].sort((a, b) => a - b); - const nextIndex = this.toolInstanceCursors.get(sig) ?? 0; - this.toolInstanceCursors.set(sig, nextIndex + 1); - if (uniqueInstances[nextIndex] != null) return uniqueInstances[nextIndex]!; - const lastInstance = uniqueInstances.at(-1) ?? 0; - // Some repeated handle bindings are never referenced in wires (for example, - // an unused shadowed tool alias in a nested loop). In that case we still - // need a distinct synthetic instance number so later bindings don't collide - // with earlier tool registrations. - return lastInstance + (nextIndex - uniqueInstances.length) + 1; + /** Get the scoped memo map variable for a memoized loop tool getter. */ + private getMemoMapVar(getterName: string): string | undefined { + return this.memoMapForGetter.get(getterName); } /** - * Get the variable name for an upfront-resolved tool function. - * Registers the tool if not yet seen. + * Resolve a ToolDef by name, walking the extends chain. + * Mirrors the runtime's resolveToolDefByName logic: + * - fn from root (chain[0]) + * - handles deduplicated (first-seen wins) + * - body accumulated root → leaf + * - onError last wins */ - private toolFnVar(fnName: string): string { - let varName = this.toolFnVars.get(fnName); - if (!varName) { - varName = `__fn${++this.toolFnVarCounter}`; - this.toolFnVars.set(fnName, varName); - } - return varName; - } + private resolveToolDef(name: string): ToolDef | undefined { + if (this.toolDefCache.has(name)) + return this.toolDefCache.get(name) ?? undefined; - /** - * Generate a static lookup expression for a dotted tool name. - * For "vendor.sub.api" → `tools?.vendor?.sub?.api ?? tools?.["vendor.sub.api"]` - * For "myTool" → `tools?.["myTool"]` - */ - private toolLookupExpr(fnName: string): string { - if (!fnName.includes(".")) { - return `tools?.[${JSON.stringify(fnName)}]`; + const base = this.toolDefs.get(name); + if (!base) { + this.toolDefCache.set(name, null); + return undefined; } - const parts = fnName.split("."); - const nested = - "tools" + parts.map((p) => `?.[${JSON.stringify(p)}]`).join(""); - const flat = `tools?.[${JSON.stringify(fnName)}]`; - return `${nested} ?? ${flat}`; - } - // ── Main compilation entry point ────────────────────────────────────────── + // Build extends chain: root → ... → leaf + const chain: ToolDef[] = [base]; + let current = base; + while (current.extends) { + const parent = this.toolDefs.get(current.extends); + if (!parent) break; + chain.unshift(parent); + current = parent; + } - compile(): CompileResult { - const { bridge } = this; - const fnName = `${bridge.type}_${bridge.field}`; - - // ── Prototype pollution guards ────────────────────────────────────── - // Validate all wire paths and tool names at compile time, matching the - // runtime's setNested / pullSingle / lookupToolFn guards. - const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]); - - // 1. setNested guard — reject unsafe keys in wire target paths - for (const w of bridge.wires) { - for (const seg of w.to.path) { - if (UNSAFE_KEYS.has(seg)) - throw new Error(`Unsafe assignment key: ${seg}`); + // Merge + const merged: ToolDef = { + kind: "tool", + name, + fn: chain[0]!.fn, + handles: [], + body: [], + }; + for (const def of chain) { + for (const h of def.handles) { + if (!merged.handles.some((mh) => mh.handle === h.handle)) + merged.handles.push(h); } + if (def.body) merged.body.push(...def.body); + if (def.onError) merged.onError = def.onError; } - // 2. pullSingle guard — reject unsafe keys in wire source paths - for (const w of bridge.wires) { - const refs: NodeRef[] = []; - if (isPull(w)) refs.push(wRef(w)); - if (isTern(w)) { - refs.push(eRef(wTern(w).cond)); - if ((wTern(w).then as RefExpr).ref) - refs.push((wTern(w).then as RefExpr).ref); - if ((wTern(w).else as RefExpr).ref) - refs.push((wTern(w).else as RefExpr).ref); - } - if (isAndW(w)) { - refs.push(eRef(wAndOr(w).left)); - if (eRef(wAndOr(w).right)) refs.push(eRef(wAndOr(w).right)); - } - if (isOrW(w)) { - refs.push(eRef(wAndOr(w).left)); - if (eRef(wAndOr(w).right)) refs.push(eRef(wAndOr(w).right)); - } - for (const ref of refs) { - for (const seg of ref.path) { - if (UNSAFE_KEYS.has(seg)) - throw new Error(`Unsafe property traversal: ${seg}`); + this.toolDefCache.set(name, merged); + return merged; + } + + compile(): CompileResult { + const funcName = `${this.bridge.type}_${this.bridge.field}`; + + // Build root scope from bridge handles + const rootScope = new ScopeChain(); + + // Register non-tool/define handle bindings. + // Tools and defines are registered by compileBody → registerWithBinding + // when their actual scope is compiled, so we skip them here to avoid + // polluting the root scope with nested (loop-scoped) handles. + for (const h of this.bridge.handles) { + switch (h.kind) { + case "input": + rootScope.set(h.handle, { kind: "input", jsExpr: "input" }); + break; + case "output": + rootScope.set(h.handle, { kind: "output", jsExpr: "__output" }); + break; + case "context": + rootScope.set(h.handle, { kind: "context", jsExpr: "context" }); + break; + case "const": { + rootScope.set(h.handle, { kind: "const", jsExpr: "__consts" }); + break; } } } - // 3. tool lookup guard — reject unsafe segments in dotted tool names - for (const h of bridge.handles) { - if (h.kind !== "tool") continue; - const segments = h.name.split("."); - for (const seg of segments) { - if (UNSAFE_KEYS.has(seg)) - throw new Error( - `No tool found for "${h.name}" — prototype-pollution attempt blocked`, - ); + // Emit preamble + this.emit("// --- AOT compiled (lazy-getter pull-based) ---"); + this.emit("const __trace = __opts?.__trace;"); + this.emit("const __wrapErr = __opts?.__wrapBridgeRuntimeError;"); + this.emit( + "const __isFatal = (__e) => __e?.name === 'BridgePanicError' || __e?.name === 'BridgeAbortError';", + ); + this.emit( + "const __toolCtx = { logger: __opts?.logger || {}, signal: __opts?.signal };", + ); + this.emit("const __PanicError = __opts?.__BridgePanicError || Error;"); + this.emit("const __AbortError = __opts?.__BridgeAbortError || Error;"); + this.emit("const __timeoutMs = __opts?.toolTimeoutMs ?? 0;"); + this.emit("const __TimeoutError = __opts?.__BridgeTimeoutError;"); + this.emit( + "const __checkAbort = () => { if (__opts?.signal?.aborted) throw new __AbortError(); };", + ); + this.emit("const __str = (__v) => __v == null ? '' : String(__v);"); + this.emit( + "const __catchSafe = (__e) => { if (__isFatal(__e)) throw __e; return undefined; };", + ); + this.emit("__checkAbort();"); + this.emitMemoHelper(); + this.emitPipeHelper(); + this.emitGetPathHelper(); + this.emitStableKeyHelper(); + this.emitConsts(); + this.emitToolLookups(rootScope); + this.emit("let __output = {};"); + this.emit(""); + + // Compile the bridge body + this.compileBody(this.bridge.body, rootScope, "__output"); + + this.emit(""); + this.emit("return __output;"); + + // Insert tool cost helper at the preamble position if needed + if (this.needsToolCostHelper) { + const helperLines = [ + " function __toolCost(fn) {", + " const m = fn?.bridge;", + " if (m?.cost != null) return m.cost;", + " return m?.sync ? 1 : 2;", + " }", + "", + ]; + // Insert after the __pipe helper (after the first empty line after preamble block) + const insertIdx = this.lines.findIndex( + (l, i) => i > 5 && l.trim() === "" && this.lines[i - 1]?.trim() === "}", + ); + if (insertIdx >= 0) { + this.lines.splice(insertIdx + 1, 0, ...helperLines); + } + } + + // Insert batch helper at preamble position if needed + if (this.needsBatchHelper) { + const helperLines = [ + " function __callBatched(__fn, __input, __bq, __toolName, __fnName, __doTrace) {", + " if (!__fn?.bridge?.batch) return __fn(__input, __toolCtx);", + " let __q = __bq.get(__fn);", + " if (!__q) {", + " __q = [];", + " __bq.set(__fn, __q);", + " queueMicrotask(async () => {", + " __bq.delete(__fn);", + " const __items = __q;", + " const __inputs = __items.map(__i => __i.input);", + " const __start = __doTrace ? performance.now() : 0;", + " try {", + " const __results = await __fn(__inputs, __toolCtx);", + " const __dur = performance.now() - __start;", + " if (__doTrace) __trace(__toolName, __fnName, __start, __start + __dur, __inputs, __results, null);", + " const __logLevel = __fn.bridge?.log?.execution;", + " if (__logLevel) __toolCtx.logger?.[__logLevel]?.({ tool: __toolName, fn: __fnName, durationMs: __dur }, '[bridge] tool completed');", + " if (!Array.isArray(__results) || __results.length !== __items.length) {", + " const __e = new Error('Batch tool \"' + __fnName + '\" returned ' + (Array.isArray(__results) ? __results.length : typeof __results) + ' items, expected ' + __items.length);", + " for (const __it of __items) __it.reject(__e);", + " return;", + " }", + " for (let __i = 0; __i < __items.length; __i++) {", + " if (__results[__i] instanceof Error) __items[__i].reject(__results[__i]);", + " else __items[__i].resolve(__results[__i]);", + " }", + " } catch (__e) {", + " const __dur = performance.now() - __start;", + " if (__doTrace) __trace(__toolName, __fnName, __start, __start + __dur, __inputs, null, __e);", + " const __logLevel = __fn.bridge?.log?.errors;", + " if (__logLevel) __toolCtx.logger?.[__logLevel]?.({ tool: __toolName, fn: __fnName, err: __e?.message }, '[bridge] tool failed');", + " for (const __it of __items) __it.reject(__e);", + " }", + " });", + " }", + " return new Promise((__resolve, __reject) => {", + " __q.push({ input: __input, resolve: __resolve, reject: __reject });", + " });", + " }", + "", + ]; + const insertIdx = this.lines.findIndex( + (l, i) => i > 5 && l.trim() === "" && this.lines[i - 1]?.trim() === "}", + ); + if (insertIdx >= 0) { + this.lines.splice(insertIdx + 1, 0, ...helperLines); } } - // Build a set of force tool trunk keys and their catch behavior - const forceMap = new Map(); - if (bridge.forces) { - for (const f of bridge.forces) { - const tk = `${f.module}:${f.type}:${f.field}:${f.instance ?? 1}`; - forceMap.set(tk, { catchError: f.catchError }); + // Insert memoization Maps at function scope (before "let __output = {};") + if (this.memoMapDeclarations.length > 0) { + const mapLines = this.memoMapDeclarations.map( + (v) => ` const ${v} = new Map();`, + ); + const insertIdx = this.lines.findIndex( + (l) => l.trim() === "let __output = {};", + ); + if (insertIdx >= 0) { + this.lines.splice(insertIdx, 0, ...mapLines); } } - // Separate wires into tool inputs, define containers, and output - const allOutputWires: Wire[] = []; - const toolWires = new Map(); - const defineWires = new Map(); - - for (const w of bridge.wires) { - // Element wires (from array mapping) target the output, not a tool - const toKey = refTrunkKey(w.to); - // Output wires target self trunk — including element wires (to.element = true) - // which produce a key like "_:Type:field:*" instead of "_:Type:field" - const toTrunkNoElement = w.to.element - ? `${w.to.module}:${w.to.type}:${w.to.field}` - : toKey; - if (toTrunkNoElement === this.selfTrunkKey) { - allOutputWires.push(w); - } else if (this.defineContainers.has(toKey)) { - // Wire targets a define-in/out container - const arr = defineWires.get(toKey) ?? []; - arr.push(w); - defineWires.set(toKey, arr); - } else { - const arr = toolWires.get(toKey) ?? []; - arr.push(w); - toolWires.set(toKey, arr); - } - } + const functionBody = this.lines.join("\n"); + const header = + "// " + + "\u2500".repeat(62) + + "\n" + + "// GENERATED FILE \u2014 DO NOT EDIT DIRECTLY\n" + + "// " + + "\u2500".repeat(62) + + "\n"; + const code = + header + + `export default async function ${funcName}(input, tools, context, __opts) {\n` + + functionBody + + "\n}"; - // ── Sparse fieldset filtering ────────────────────────────────────── - // When requestedFields is provided, drop output wires for fields that - // weren't requested. Kahn's algorithm will then naturally eliminate - // tools that only feed into those dropped wires. - const filteredOutputWires = this.requestedFields - ? allOutputWires.filter((w) => { - // Root wires (path length 0) and element wires are always included - if (w.to.path.length === 0) return true; - const fieldPath = w.to.path.join("."); - return matchesRequestedFields(fieldPath, this.requestedFields); - }) - : allOutputWires; - const outputWires = this.reorderOverdefinedOutputWires(filteredOutputWires); - - // Ensure force-only tools (no wires targeting them from output) are - // still included in the tool map for scheduling - for (const [tk] of forceMap) { - if (!toolWires.has(tk) && this.tools.has(tk)) { - toolWires.set(tk, []); - } - } + return { code, functionName: funcName, functionBody }; + } - // Detect tools whose output is only referenced by catch-guarded wires. - // These tools need try/catch wrapping to prevent unhandled rejections. - for (const w of outputWires) { - const needsCatch = - hasCatchFallback(w) || - hasCatchControl(w) || - wSafe(w) || - (isAndW(w) && (wAndOr(w).leftSafe || wAndOr(w).rightSafe)) || - (isOrW(w) && (wAndOr(w).leftSafe || wAndOr(w).rightSafe)); - if (!needsCatch) continue; - if (isPull(w)) { - const srcKey = refTrunkKey(wRef(w)); - this.catchGuardedTools.add(srcKey); - } - if (isAndW(w)) { - this.catchGuardedTools.add(refTrunkKey(eRef(wAndOr(w).left))); - if (eRef(wAndOr(w).right)) - this.catchGuardedTools.add(refTrunkKey(eRef(wAndOr(w).right))); - } - if (isOrW(w)) { - this.catchGuardedTools.add(refTrunkKey(eRef(wAndOr(w).left))); - if (eRef(wAndOr(w).right)) - this.catchGuardedTools.add(refTrunkKey(eRef(wAndOr(w).right))); - } - } - // Also mark tools catch-guarded if referenced by catch-guarded or safe define wires - for (const [, dwires] of defineWires) { - for (const w of dwires) { - const needsCatch = - hasCatchFallback(w) || hasCatchControl(w) || wSafe(w); - if (!needsCatch) continue; - if (isPull(w)) { - const srcKey = refTrunkKey(wRef(w)); - this.catchGuardedTools.add(srcKey); - } - if (isTern(w)) { - this.catchGuardedTools.add(refTrunkKey(eRef(wTern(w).cond))); - if ((wTern(w).then as RefExpr).ref) - this.catchGuardedTools.add( - refTrunkKey((wTern(w).then as RefExpr).ref), - ); - if ((wTern(w).else as RefExpr).ref) - this.catchGuardedTools.add( - refTrunkKey((wTern(w).else as RefExpr).ref), - ); - } - } - } - // Mark tools catch-guarded when pipe wires carry safe/catch modifiers - // (e.g. `api?.score > 5` — the pipe from api to the `>` operator has safe) - for (const [, twires] of toolWires) { - for (const w of twires) { - const isSafe = - wSafe(w) || - (isAndW(w) && (wAndOr(w).leftSafe || wAndOr(w).rightSafe)) || - (isOrW(w) && (wAndOr(w).leftSafe || wAndOr(w).rightSafe)); - if (!isSafe) continue; - if (isPull(w)) { - this.catchGuardedTools.add(refTrunkKey(wRef(w))); - } - if (isAndW(w)) { - this.catchGuardedTools.add(refTrunkKey(eRef(wAndOr(w).left))); - if (eRef(wAndOr(w).right)) - this.catchGuardedTools.add(refTrunkKey(eRef(wAndOr(w).right))); - } - if (isOrW(w)) { - this.catchGuardedTools.add(refTrunkKey(eRef(wAndOr(w).left))); - if (eRef(wAndOr(w).right)) - this.catchGuardedTools.add(refTrunkKey(eRef(wAndOr(w).right))); - } - } - } + // ── Emit helpers ────────────────────────────────────────────────────── - // Detect element-scoped tools/containers: any node that directly receives - // element input, or depends on another element-scoped node, must be emitted - // inside the array callback rather than at the top level. - const elementScopeEntries = [ - ...toolWires.entries(), - ...defineWires.entries(), - ]; - let changed = true; - while (changed) { - changed = false; - for (const [tk, wires] of elementScopeEntries) { - if (this.elementScopedTools.has(tk)) continue; - for (const w of wires) { - if (isPull(w) && wRef(w).element) { - this.elementScopedTools.add(tk); - changed = true; - break; - } - if ( - this.getSourceTrunks(w).some((srcKey) => - this.elementScopedTools.has(srcKey), - ) - ) { - this.elementScopedTools.add(tk); - changed = true; - break; - } - } - } - } + private emit(line: string) { + const pad = " ".repeat(this.indent); + this.lines.push(pad + line); + } - // Merge define container entries into toolWires for topological sorting. - // Define containers are scheduled like tools (they have dependencies and - // dependants) but they emit simple object assignments instead of tool calls. - for (const [tk, wires] of defineWires) { - toolWires.set(tk, wires); - } + private pushIndent() { + this.indent++; + } + private popIndent() { + this.indent--; + } - // Topological sort of tool calls (including define containers) - const toolOrder = this.topologicalSort(toolWires); - // Layer-based grouping for parallel emission - const toolLayers = this.topologicalLayers(toolWires); - - // ── Overdefinition bypass analysis ──────────────────────────────────── - // When multiple wires target the same output path ("overdefinition"), - // the runtime's pull-based model skips later tools if earlier sources - // resolve non-null. The compiler replicates this: if a tool's output - // contributions are ALL in secondary (non-first) position, the tool - // call is wrapped in a null-check on the prior sources. - const conditionalTools = this.analyzeOverdefinitionBypass( - outputWires, - toolOrder, - forceMap, - ); + // ── Preamble ────────────────────────────────────────────────────────── - // ── Lazy ternary analysis ──────────────────────────────────────────── - // Identify tools that are ONLY referenced in ternary branches (thenRef/elseRef) - // and never in regular pull wires. These can be lazily evaluated inline. - this.analyzeTernaryOnlyTools(outputWires, toolWires, defineWires, forceMap); - - // Build code lines - const lines: string[] = []; - lines.push(`// AOT-compiled bridge: ${bridge.type}.${bridge.field}`); - lines.push(`// Generated by @stackables/bridge-compiler`); - lines.push(""); - lines.push( - `export default async function ${fnName}(input, tools, context, __opts) {`, - ); - lines.push( - ` const __BridgePanicError = __opts?.__BridgePanicError ?? class extends Error { constructor(m) { super(m); this.name = "BridgePanicError"; } };`, - ); - lines.push( - ` const __BridgeAbortError = __opts?.__BridgeAbortError ?? class extends Error { constructor(m) { super(m ?? "Execution aborted by external signal"); this.name = "BridgeAbortError"; } };`, - ); - lines.push( - ` const __BridgeTimeoutError = __opts?.__BridgeTimeoutError ?? class extends Error { constructor(n, ms) { super('Tool "' + n + '" timed out after ' + ms + 'ms'); this.name = "BridgeTimeoutError"; } };`, - ); - lines.push( - ` const __BridgeRuntimeError = __opts?.__BridgeRuntimeError ?? class extends Error { constructor(message, options) { super(message, options && "cause" in options ? { cause: options.cause } : undefined); this.name = "BridgeRuntimeError"; this.bridgeLoc = options?.bridgeLoc; } };`, - ); - lines.push(` const __signal = __opts?.signal;`); - lines.push(` const __timeoutMs = __opts?.toolTimeoutMs ?? 0;`); - lines.push( - ` const __ctx = { logger: __opts?.logger ?? {}, signal: __signal };`, + private emitMemoHelper() { + this.emit("function __memoize(fn, name) {"); + this.pushIndent(); + this.emit("let cached; let active = false;"); + this.emit( + "return () => { if (cached) return cached; if (active) throw new __PanicError('Circular dependency detected: \"' + (name || '?') + '\" depends on itself'); active = true; return (cached = fn()); };", ); - lines.push( - ` const __queueMicrotask = globalThis.queueMicrotask ?? ((fn) => Promise.resolve().then(fn));`, - ); - lines.push(` const __batchQueues = new Map();`); - lines.push(` const __trace = __opts?.__trace;`); - lines.push(` function __toolExecutionLogLevel(fn) {`); - lines.push(` const log = fn?.bridge?.log;`); - lines.push(` if (log === false || log == null) return false;`); - lines.push(` if (log === true) return "info";`); - lines.push( - ` return log.execution === "info" ? "info" : log.execution ? "debug" : false;`, - ); - lines.push(` }`); - lines.push(` function __toolErrorLogLevel(fn) {`); - lines.push(` const log = fn?.bridge?.log;`); - lines.push(` if (log === false) return false;`); - lines.push(` if (log == null || log === true) return "error";`); - lines.push( - ` return log.errors === false ? false : log.errors === "warn" ? "warn" : "error";`, - ); - lines.push(` }`); - lines.push(` function __rethrowBridgeError(err, loc) {`); - lines.push( - ` if (err?.name === "BridgePanicError") throw __attachBridgeMeta(err, loc);`, - ); - lines.push(` if (err?.name === "BridgeAbortError") throw err;`); - lines.push( - ` if (err?.name === "BridgeRuntimeError" && err.bridgeLoc != null) throw err;`, - ); - lines.push( - ` throw new __BridgeRuntimeError(err instanceof Error ? err.message : String(err), { cause: err, bridgeLoc: loc });`, - ); - lines.push(` }`); - lines.push(` function __wrapBridgeError(fn, loc) {`); - lines.push(` try {`); - lines.push(` return fn();`); - lines.push(` } catch (err) {`); - lines.push(` __rethrowBridgeError(err, loc);`); - lines.push(` }`); - lines.push(` }`); - lines.push(` async function __wrapBridgeErrorAsync(fn, loc) {`); - lines.push(` try {`); - lines.push(` return await fn();`); - lines.push(` } catch (err) {`); - lines.push(` __rethrowBridgeError(err, loc);`); - lines.push(` }`); - lines.push(` }`); - lines.push(` function __attachBridgeMeta(err, loc) {`); - lines.push( - ` if (err && (typeof err === "object" || typeof err === "function")) {`, + this.popIndent(); + this.emit("}"); + this.emit(""); + } + + private emitPipeHelper() { + this.emit("async function __pipe(__fn, __name, __fnName, __input) {"); + this.pushIndent(); + this.emit( + "if (typeof __fn !== \"function\") throw new Error('No tool found for \"' + __fnName + '\"');", ); - lines.push(` if (err.bridgeLoc === undefined) err.bridgeLoc = loc;`); - lines.push(` }`); - lines.push(` return err;`); - lines.push(` }`); - lines.push( - ` // Single-segment access is split out to preserve the compiled-path recovery documented in packages/bridge-compiler/performance.md (#2).`, + this.emit( + "const __doTrace = __trace && (!__fn?.bridge || __fn.bridge.trace !== false);", ); - lines.push( - ` function __get(base, segment, accessSafe, allowMissingBase) {`, + this.emit("const __start = __doTrace ? performance.now() : 0;"); + this.emit("try {"); + this.pushIndent(); + this.emit("let __raw = __fn(__input, __toolCtx);"); + this.emit( + "if (__timeoutMs > 0 && __raw && typeof __raw.then === 'function') {", ); - lines.push(` if (base == null) {`); - lines.push(` if (allowMissingBase || accessSafe) return undefined;`); - lines.push( - ` throw new TypeError("Cannot read properties of " + base + " (reading '" + segment + "')");`, + this.pushIndent(); + this.emit("let __timer;"); + this.emit( + "const __tout = new Promise((_, rej) => { __timer = setTimeout(() => rej(new (__TimeoutError || Error)(__fnName, __timeoutMs)), __timeoutMs); });", ); - lines.push(` }`); - lines.push(` const next = base[segment];`); - lines.push( - ` const isPrimitiveBase = base !== null && typeof base !== "object" && typeof base !== "function";`, + this.emit( + "__raw = Promise.race([__raw, __tout]).finally(() => clearTimeout(__timer));", ); - lines.push(` if (isPrimitiveBase && next === undefined) {`); - lines.push( - ` throw new TypeError("Cannot read properties of " + base + " (reading '" + segment + "')");`, + this.popIndent(); + this.emit("}"); + this.emit("const __result = await __raw;"); + this.emit( + "if (__doTrace) __trace(__name, __fnName, __start, performance.now(), __input, __result, null);", ); - lines.push(` }`); - lines.push(` return next;`); - lines.push(` }`); - lines.push(` function __path(base, path, safe, allowMissingBase) {`); - lines.push(` let result = base;`); - lines.push(` for (let i = 0; i < path.length; i++) {`); - lines.push(` const segment = path[i];`); - lines.push(` const accessSafe = safe?.[i] ?? false;`); - lines.push(` if (result == null) {`); - lines.push(` if ((i === 0 && allowMissingBase) || accessSafe) {`); - lines.push(` result = undefined;`); - lines.push(` continue;`); - lines.push(` }`); - lines.push( - ` throw new TypeError("Cannot read properties of " + result + " (reading '" + segment + "')");`, + this.emit("return __result;"); + this.popIndent(); + this.emit("} catch (__err) {"); + this.pushIndent(); + this.emit( + "if (__doTrace) __trace(__name, __fnName, __start, performance.now(), __input, null, __err);", ); - lines.push(` }`); - lines.push(` const next = result[segment];`); - lines.push( - ` const isPrimitiveBase = result !== null && typeof result !== "object" && typeof result !== "function";`, - ); - lines.push(` if (isPrimitiveBase && next === undefined) {`); - lines.push( - ` throw new TypeError("Cannot read properties of " + result + " (reading '" + segment + "')");`, - ); - lines.push(` }`); - lines.push(` result = next;`); - lines.push(` }`); - lines.push(` return result;`); - lines.push(` }`); - lines.push(` function __callBatch(fn, input, toolDefName, fnName) {`); - lines.push( - ` if (__signal?.aborted) return Promise.reject(new __BridgeAbortError());`, - ); - lines.push( - ` if (typeof fn !== "function") return Promise.reject(new __BridgeRuntimeError('No tool found for "' + fnName + '"'));`, - ); - lines.push(` let queue = __batchQueues.get(fn);`); - lines.push(` if (!queue) {`); - lines.push( - ` queue = { items: [], scheduled: false, toolDefName, fnName, maxBatchSize: typeof fn?.bridge?.batch === "object" && fn?.bridge?.batch?.maxBatchSize > 0 ? Math.floor(fn.bridge.batch.maxBatchSize) : undefined };`, - ); - lines.push(` __batchQueues.set(fn, queue);`); - lines.push(` }`); - lines.push(` return new Promise((resolve, reject) => {`); - lines.push(` queue.items.push({ input, resolve, reject });`); - lines.push(` if (queue.scheduled) return;`); - lines.push(` queue.scheduled = true;`); - lines.push( - ` __queueMicrotask(() => { void __flushBatch(fn, queue); });`, - ); - lines.push(` });`); - lines.push(` }`); - lines.push(` async function __flushBatch(fn, queue) {`); - lines.push( - ` const pending = queue.items.splice(0, queue.items.length);`, - ); - lines.push(` queue.scheduled = false;`); - lines.push(` if (pending.length === 0) return;`); - lines.push(` if (__signal?.aborted) {`); - lines.push(` const err = new __BridgeAbortError();`); - lines.push(` for (const item of pending) item.reject(err);`); - lines.push(` return;`); - lines.push(` }`); - lines.push( - ` const chunkSize = queue.maxBatchSize && queue.maxBatchSize > 0 ? queue.maxBatchSize : pending.length;`, - ); - lines.push( - ` for (let start = 0; start < pending.length; start += chunkSize) {`, - ); - lines.push(` const chunk = pending.slice(start, start + chunkSize);`); - lines.push(` const inputs = chunk.map((item) => item.input);`); - lines.push( - ` const startTime = (__trace || __ctx.logger) ? performance.now() : 0;`, - ); - lines.push(` try {`); - lines.push(` const batchPromise = fn(inputs, __ctx);`); - lines.push(` let result;`); - lines.push( - ` if (__timeoutMs > 0 && batchPromise && typeof batchPromise.then === "function") {`, - ); - lines.push( - ` let t; const timeout = new Promise((_, rej) => { t = setTimeout(() => rej(new __BridgeTimeoutError(queue.toolDefName, __timeoutMs)), __timeoutMs); });`, - ); - lines.push( - ` try { result = await Promise.race([batchPromise, timeout]); } finally { clearTimeout(t); }`, - ); - lines.push(` } else {`); - lines.push(` result = await batchPromise;`); - lines.push(` }`); - lines.push( - ` if (__trace && fn?.bridge?.trace !== false) __trace(queue.toolDefName, queue.fnName, startTime, performance.now(), inputs, result, null);`, - ); - lines.push(` const __execLevel = __toolExecutionLogLevel(fn);`); - lines.push( - ` if (__execLevel) __ctx.logger?.[__execLevel]?.({ tool: queue.toolDefName, fn: queue.fnName, durationMs: Math.round((performance.now() - startTime) * 1000) / 1000 }, "[bridge] tool completed");`, - ); - lines.push( - ` if (!Array.isArray(result)) throw new Error('Batch tool "' + queue.toolDefName + '" must return an array of results');`, - ); - lines.push( - ` if (result.length !== chunk.length) throw new Error('Batch tool "' + queue.toolDefName + '" returned ' + result.length + ' results for ' + chunk.length + ' queued calls');`, - ); - lines.push( - ` for (let i = 0; i < chunk.length; i++) { const value = result[i]; if (value instanceof Error) chunk[i].reject(value); else chunk[i].resolve(value); }`, - ); - lines.push(` } catch (err) {`); - lines.push( - ` try { __rethrowBridgeError(err, undefined); } catch (_wrapped) { err = _wrapped; }`, - ); - lines.push( - ` if (__trace && fn?.bridge?.trace !== false) __trace(queue.toolDefName, queue.fnName, startTime, performance.now(), inputs, null, err);`, - ); - lines.push(` const __errorLevel = __toolErrorLogLevel(fn);`); - lines.push( - ` if (__errorLevel) __ctx.logger?.[__errorLevel]?.({ tool: queue.toolDefName, fn: queue.fnName, err: err instanceof Error ? err.message : String(err) }, "[bridge] tool failed");`, - ); - lines.push(` for (const item of chunk) item.reject(err);`); - lines.push(` }`); - lines.push(` }`); - lines.push(` }`); - // Sync tool caller — no await, no timeout, enforces no-promise return. - lines.push(` function __callSync(fn, input, toolDefName, fnName) {`); - lines.push(` if (__signal?.aborted) throw new __BridgeAbortError();`); - lines.push( - ` if (typeof fn !== "function") throw new __BridgeRuntimeError('No tool found for "' + fnName + '"');`, - ); - lines.push(` const start = __trace ? performance.now() : 0;`); - lines.push(` try {`); - lines.push(` const result = fn(input, __ctx);`); - lines.push( - ` if (result && typeof result.then === "function") throw new Error("Tool \\"" + toolDefName + "\\" declared {sync:true} but returned a Promise");`, - ); - lines.push( - ` if (__trace && fn?.bridge?.trace !== false) __trace(toolDefName, fnName, start, performance.now(), input, result, null);`, - ); - lines.push(` const __execLevel = __toolExecutionLogLevel(fn);`); - lines.push( - ` if (__execLevel) __ctx.logger?.[__execLevel]?.({ tool: toolDefName, fn: fnName, durationMs: Math.round((performance.now() - start) * 1000) / 1000 }, "[bridge] tool completed");`, - ); - lines.push(` return result;`); - lines.push(` } catch (err) {`); - lines.push( - ` if (__trace && fn?.bridge?.trace !== false) __trace(toolDefName, fnName, start, performance.now(), input, null, err);`, - ); - lines.push(` const __errorLevel = __toolErrorLogLevel(fn);`); - lines.push( - ` if (__errorLevel) __ctx.logger?.[__errorLevel]?.({ tool: toolDefName, fn: fnName, err: err instanceof Error ? err.message : String(err) }, "[bridge] tool failed");`, - ); - lines.push(` __rethrowBridgeError(err, undefined);`); - lines.push(` }`); - lines.push(` }`); - lines.push( - ` const __isLoopCtrl = (v) => (v?.__bridgeControl === "break" || v?.__bridgeControl === "continue") && Number.isInteger(v?.levels) && v.levels > 0;`, - ); - lines.push( - ` const __nextLoopCtrl = (v) => ({ __bridgeControl: v.__bridgeControl, levels: v.levels - 1 });`, - ); - // Async tool caller — full promise handling with optional timeout. - lines.push(` async function __call(fn, input, toolDefName, fnName) {`); - lines.push(` if (__signal?.aborted) throw new __BridgeAbortError();`); - lines.push( - ` if (typeof fn !== "function") throw new __BridgeRuntimeError('No tool found for "' + fnName + '"');`, - ); - lines.push(` const start = __trace ? performance.now() : 0;`); - lines.push(` try {`); - lines.push(` const p = fn(input, __ctx);`); - lines.push(` let result;`); - lines.push(` if (__timeoutMs > 0) {`); - lines.push( - ` let t; const timeout = new Promise((_, rej) => { t = setTimeout(() => rej(new __BridgeTimeoutError(toolDefName, __timeoutMs)), __timeoutMs); });`, - ); - lines.push( - ` try { result = await Promise.race([p, timeout]); } finally { clearTimeout(t); }`, - ); - lines.push(` } else {`); - lines.push(` result = await p;`); - lines.push(` }`); - lines.push( - ` if (__trace && fn?.bridge?.trace !== false) __trace(toolDefName, fnName, start, performance.now(), input, result, null);`, - ); - lines.push(` const __execLevel = __toolExecutionLogLevel(fn);`); - lines.push( - ` if (__execLevel) __ctx.logger?.[__execLevel]?.({ tool: toolDefName, fn: fnName, durationMs: Math.round((performance.now() - start) * 1000) / 1000 }, "[bridge] tool completed");`, - ); - lines.push(` return result;`); - lines.push(` } catch (err) {`); - lines.push( - ` if (__trace && fn?.bridge?.trace !== false) __trace(toolDefName, fnName, start, performance.now(), input, null, err);`, - ); - lines.push(` const __errorLevel = __toolErrorLogLevel(fn);`); - lines.push( - ` if (__errorLevel) __ctx.logger?.[__errorLevel]?.({ tool: toolDefName, fn: fnName, err: err instanceof Error ? err.message : String(err) }, "[bridge] tool failed");`, - ); - lines.push(` __rethrowBridgeError(err, undefined);`); - lines.push(` }`); - lines.push(` }`); - if (this.memoizedToolKeys.size > 0) { - lines.push(` const __toolMemoCache = new Map();`); - lines.push(` function __stableMemoizeKey(value) {`); - lines.push(` if (value === undefined) return "undefined";`); - lines.push(' if (typeof value === "bigint") return `${value}n`;'); - lines.push( - ` if (value === null || typeof value !== "object") { const serialized = JSON.stringify(value); return serialized ?? String(value); }`, - ); - lines.push(` if (Array.isArray(value)) {`); - lines.push( - ' return `[${value.map((item) => __stableMemoizeKey(item)).join(",")}]`;', - ); - lines.push(` }`); - lines.push( - ` const entries = Object.entries(value).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));`, - ); - lines.push( - ' return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${__stableMemoizeKey(entryValue)}`).join(",")}}`;', - ); - lines.push(` }`); - lines.push( - ` function __callMemoized(fn, input, toolDefName, fnName, memoizeKey) {`, - ); - lines.push(` let toolCache = __toolMemoCache.get(memoizeKey);`); - lines.push(` if (!toolCache) {`); - lines.push(` toolCache = new Map();`); - lines.push(` __toolMemoCache.set(memoizeKey, toolCache);`); - lines.push(` }`); - lines.push(` const cacheKey = __stableMemoizeKey(input);`); - lines.push(` const cached = toolCache.get(cacheKey);`); - lines.push(` if (cached !== undefined) return cached;`); - lines.push(` try {`); - lines.push( - ` const result = fn?.bridge?.batch ? __callBatch(fn, input, toolDefName, fnName) : fn?.bridge?.sync ? __callSync(fn, input, toolDefName, fnName) : __call(fn, input, toolDefName, fnName);`, - ); - lines.push(` if (result && typeof result.then === "function") {`); - lines.push( - ` const pending = Promise.resolve(result).catch((error) => {`, - ); - lines.push(` toolCache.delete(cacheKey);`); - lines.push(` throw error;`); - lines.push(` });`); - lines.push(` toolCache.set(cacheKey, pending);`); - lines.push(` return pending;`); - lines.push(` }`); - lines.push(` toolCache.set(cacheKey, result);`); - lines.push(` return result;`); - lines.push(` } catch (error) {`); - lines.push(` toolCache.delete(cacheKey);`); - lines.push(` throw error;`); - lines.push(` }`); - lines.push(` }`); - } + this.emit("throw __err;"); + this.popIndent(); + this.emit("}"); + this.popIndent(); + this.emit("}"); + this.emit(""); + } - // Placeholder for upfront tool lookups — replaced after code emission - lines.push(" // __TOOL_LOOKUPS__"); - - // ── Dead tool detection ──────────────────────────────────────────── - // Detect which tools are reachable from the (possibly filtered) output - // wires. Uses a backward reachability analysis: start from tools - // referenced in output wires, then transitively follow tool-input - // wires to discover all upstream dependencies. Tools not in the - // reachable set are dead code and can be skipped. - - /** - * Extract all tool trunk keys referenced as **sources** in a set of - * wires. A "source key" is the trunk key of a node that feeds data - * into a wire (the right-hand side of `target <- source`). This - * includes pull refs, ternary branches, condAnd/condOr operands, - * and all fallback refs. Used by the backward reachability analysis - * to discover which tools are transitively needed by the output. - */ - const collectSourceKeys = (wires: Wire[]): Set => { - const keys = new Set(); - for (const w of wires) { - if (isPull(w)) keys.add(refTrunkKey(wRef(w))); - if (isTern(w)) { - keys.add(refTrunkKey(eRef(wTern(w).cond))); - if ((wTern(w).then as RefExpr).ref) - keys.add(refTrunkKey((wTern(w).then as RefExpr).ref)); - if ((wTern(w).else as RefExpr).ref) - keys.add(refTrunkKey((wTern(w).else as RefExpr).ref)); - } - if (isAndW(w)) { - keys.add(refTrunkKey(eRef(wAndOr(w).left))); - if (eRef(wAndOr(w).right)) - keys.add(refTrunkKey(eRef(wAndOr(w).right))); - } - if (isOrW(w)) { - keys.add(refTrunkKey(eRef(wAndOr(w).left))); - if (eRef(wAndOr(w).right)) - keys.add(refTrunkKey(eRef(wAndOr(w).right))); - } - if (hasFallbacks(w)) { - for (const fb of fallbacks(w)) { - if (eRef(fb.expr)) keys.add(refTrunkKey(eRef(fb.expr))); - } - } - if (hasCatchRef(w)) { - keys.add(refTrunkKey(catchRef(w)!)); - } - } - return keys; + /** + * Emit __getPath helper for Bridge's segment-local safe navigation. + * Matches runtime getPath semantics: ?. only makes the immediately + * following segment safe; subsequent non-safe segments throw normally. + */ + private emitGetPathHelper() { + this.emit("function __getPath(__obj, __segs, __safe) {"); + this.pushIndent(); + this.emit("let __c = __obj;"); + this.emit("for (let __i = 0; __i < __segs.length; __i++) {"); + this.pushIndent(); + this.emit("if (__c == null) {"); + this.pushIndent(); + this.emit("if (__safe[__i]) { __c = undefined; continue; }"); + this.emit("return __c[__segs[__i]];"); + this.popIndent(); + this.emit("}"); + // Match runtime: primitives where property access yields undefined must throw + this.emit( + 'const __isPrim = typeof __c !== "object" && typeof __c !== "function";', + ); + this.emit("const __next = __c[__segs[__i]];"); + this.emit("if (__isPrim && __next === undefined) {"); + this.pushIndent(); + this.emit("if (__safe[__i]) { __c = undefined; continue; }"); + this.emit( + "throw new TypeError(`Cannot read properties of ${String(__c)} (reading '${__segs[__i]}')`);", + ); + this.popIndent(); + this.emit("}"); + this.emit("__c = __next;"); + this.popIndent(); + this.emit("}"); + this.emit("return __c;"); + this.popIndent(); + this.emit("}"); + this.emit(""); + } + + private emitStableKeyHelper() { + this.emit("function __stableKey(v) {"); + this.pushIndent(); + this.emit("if (v == null) return v === null ? 'n' : 'u';"); + this.emit("if (typeof v === 'boolean') return v ? 'T' : 'F';"); + this.emit( + "if (typeof v === 'number' || typeof v === 'bigint') return typeof v === 'number' ? 'd:' + v : 'B:' + v;", + ); + this.emit("if (typeof v === 'string') return 's:' + v;"); + this.emit( + "if (Array.isArray(v)) return '[' + v.map(__stableKey).join(',') + ']';", + ); + this.emit( + "if (typeof v === 'object') { const ks = Object.keys(v).sort(); return '{' + ks.map(k => k + ':' + __stableKey(v[k])).join(',') + '}'; }", + ); + this.emit("return String(v);"); + this.popIndent(); + this.emit("}"); + this.emit(""); + } + + private emitConsts() { + if (this.constDefs.size === 0) return; + this.emit("const __consts = {"); + this.pushIndent(); + for (const [name, value] of this.constDefs) { + this.emit(`${safeId(name)}: ${value},`); + } + this.popIndent(); + this.emit("};"); + this.emit(""); + } + + private emitToolLookups(_scope: ScopeChain) { + // Tool lookups are emitted by registerWithBinding during compileBody. + // This method is kept as a no-op for structural clarity. + this.emit(""); + } + + // ── Body compilation ────────────────────────────────────────────────── + + private compileBody( + body: Statement[], + scope: ScopeChain, + outputVar: string, + pathPrefix: string[] = [], + absolutePrefix: string[] = [], + ) { + // First pass: register any `with` bindings in this scope + for (const stmt of body) { + if (stmt.kind === "with") { + this.registerWithBinding(stmt, scope); + } + } + + // Collect tool handles declared in this body's `with` statements + const localToolHandles = new Set(); + for (const stmt of body) { + if (stmt.kind === "with" && stmt.binding.kind === "tool") { + localToolHandles.add(stmt.binding.handle); + } + } + + // Build a map of tool handles → input wires for memoized tool getters + const toolInputs = this.collectToolInputs(body, scope); + + // Ensure ALL local tool handles get memoized getters for tracing & memoization, + // even when they have no bridge input wires or ToolDef body. + for (const handle of localToolHandles) { + if (!toolInputs.has(handle)) { + toolInputs.set(handle, []); + } + } + + // Collect define input wires + const defineInputs = this.collectDefineInputs(body, scope); + + // Emit memoized tool getters for this scope + this.emitToolGetters(toolInputs, scope); + + // Emit memoized define getters for this scope + this.emitDefineGetters(defineInputs, scope); + + // Group output-targeting wires by target path for overdefinition handling + const outputWireGroups = this.groupOutputWiresByPath( + body, + scope, + pathPrefix, + ); + const emittedPaths = new Set(); + + // Second pass: compile wires, scopes, force statements. + // Batch consecutive output wires for parallel execution via Promise.all. + // Force statements are deferred until after output wires (matching runtime + // semantics where force runs concurrently with output resolution). + let pendingWires: { + valueExpr: string; + targetExpr: string; + isRoot: boolean; + locExpr?: string; + }[] = []; + const deferredForces: ForceStatement[] = []; + const flushPending = () => { + if (pendingWires.length === 0) return; + this.emitParallelAssignments( + pendingWires.map((w) => ({ + expr: w.valueExpr, + locExpr: w.locExpr, + assign: (v: string) => + w.isRoot + ? `Object.assign(${outputVar}, ${v});` + : `${w.targetExpr} = ${v};`, + })), + ); + pendingWires = []; }; - // Seed: tools directly referenced by output wires + forced tools - const referencedToolKeys = collectSourceKeys(outputWires); - for (const tk of forceMap.keys()) referencedToolKeys.add(tk); - - // Transitive closure: walk backward through tool input wires - const visited = new Set(); - const queue = [...referencedToolKeys]; - while (queue.length > 0) { - const tk = queue.pop()!; - if (visited.has(tk)) continue; - visited.add(tk); - const deps = toolWires.get(tk); - if (!deps) continue; - for (const key of collectSourceKeys(deps)) { - if (!visited.has(key)) { - referencedToolKeys.add(key); - queue.push(key); - } - } - } - - // Emit tool calls and define container assignments - // Tools in the same topological layer have no mutual dependencies and - // can execute in parallel — we emit them as a single Promise.all(). - for (const layer of toolLayers) { - // Classify tools in this layer - const parallelBatch: { - tk: string; - tool: ToolInfo; - wires: Wire[]; - }[] = []; - const sequentialKeys: string[] = []; - - for (const tk of layer) { - if (this.elementScopedTools.has(tk)) continue; - if (this.ternaryOnlyTools.has(tk)) continue; - if ( - !referencedToolKeys.has(tk) && - !forceMap.has(tk) && - !this.defineContainers.has(tk) - ) - continue; - - if (this.isParallelizableTool(tk, conditionalTools, forceMap)) { - const tool = this.tools.get(tk)!; - const wires = toolWires.get(tk) ?? []; - parallelBatch.push({ tk, tool, wires }); - } else { - sequentialKeys.push(tk); - } - } + for (const stmt of body) { + switch (stmt.kind) { + case "wire": { + // Skip tool input wires (handled by tool getters) + const handleName = this.findTargetHandle(stmt.target, scope); + if (handleName) { + const binding = scope.get(handleName); + if (binding?.kind === "tool") break; + } - // Emit parallelizable tools first so their variables are in scope when - // sequential tools (which may have bypass conditions referencing them) run. - if (parallelBatch.length === 1) { - const { tool, wires } = parallelBatch[0]!; - this.emitToolCall(lines, tool, wires, "normal"); - } else if (parallelBatch.length > 1) { - const varNames = parallelBatch - .map(({ tool }) => tool.varName) - .join(", "); - lines.push(` const [${varNames}] = await Promise.all([`); - for (const { tool, wires } of parallelBatch) { - const callExpr = this.buildNormalCallExpr(tool, wires); - lines.push(` ${callExpr},`); - } - lines.push(` ]);`); - } + // Skip define input wires (handled by define getters) + if (stmt.target.module.startsWith("__define_")) break; + + // requestedFields filtering: skip output wires for unrequested fields + if (this.requestedFields && this.requestedFields.length > 0) { + const pathKey = this.wireOutputPathKey(stmt.target, pathPrefix); + if (pathKey !== undefined) { + const absolutePath = [...absolutePrefix, ...pathKey.split(".")] + .filter(Boolean) + .join("."); + if ( + absolutePath && + !matchesRequestedFields(absolutePath, this.requestedFields) + ) + break; + } + } - // Emit sequential (complex) tools one by one — same logic as before - for (const tk of sequentialKeys) { - if (this.defineContainers.has(tk)) { - const wires = defineWires.get(tk) ?? []; - const varName = this.varMap.get(tk)!; - if (wires.length === 0) { - lines.push(` const ${varName} = undefined;`); - } else if (wires.length === 1 && wires[0]!.to.path.length === 0) { - const w = wires[0]!; - let expr = this.wireToExpr(w); - if (wSafe(w)) { - const errFlags: string[] = []; - if (isPull(w)) { - const ef = this.getSourceErrorFlag(w); - if (ef) errFlags.push(ef); - } - if (isTern(w)) { - const tern = wTern(w); - const condEf = this.getErrorFlagForRef(eRef(tern.cond)); - if (condEf) errFlags.push(condEf); - if (tern.then.type === "ref") { - const ef = this.getErrorFlagForRef( - (tern.then as RefExpr).ref, - ); - if (ef) errFlags.push(ef); - } - if (tern.else.type === "ref") { - const ef = this.getErrorFlagForRef( - (tern.else as RefExpr).ref, + // Overdefinition: emit grouped wires on first encounter + { + const pathKey = this.wireOutputPathKey(stmt.target, pathPrefix); + if (pathKey !== undefined) { + const group = outputWireGroups.get(pathKey); + if (group && group.length > 1) { + if (!emittedPaths.has(pathKey)) { + emittedPaths.add(pathKey); + flushPending(); + this.compileOverdefinedWires( + group, + scope, + outputVar, + pathPrefix, ); - if (ef) errFlags.push(ef); } - } - if (errFlags.length > 0) { - const errCheck = errFlags - .map((f) => `${f} !== undefined`) - .join(" || "); - expr = `(${errCheck} ? undefined : ${expr})`; + break; } } - lines.push(` const ${varName} = ${expr};`); - } else { - const inputObj = this.buildObjectLiteral( - wires, - (w) => w.to.path, - 4, - ); - lines.push(` const ${varName} = ${inputObj};`); - } - continue; - } - const tool = this.tools.get(tk)!; - const wires = toolWires.get(tk) ?? []; - const forceInfo = forceMap.get(tk); - const bypass = conditionalTools.get(tk); - if (bypass && !forceInfo && !this.catchGuardedTools.has(tk)) { - const condition = bypass.checkExprs - .map((expr) => `(${expr}) == null`) - .join(" || "); - lines.push(` let ${tool.varName};`); - lines.push(` if (${condition}) {`); - const buf: string[] = []; - this.emitToolCall(buf, tool, wires, "normal"); - for (const line of buf) { - lines.push( - " " + - line.replace(`const ${tool.varName} = `, `${tool.varName} = `), - ); } - lines.push(` }`); - } else if (forceInfo?.catchError) { - this.emitToolCall(lines, tool, wires, "fire-and-forget"); - } else if (this.catchGuardedTools.has(tk)) { - this.emitToolCall(lines, tool, wires, "catch-guarded"); - } else { - this.emitToolCall(lines, tool, wires, "normal"); - } - } - } - // Emit output - this.emitOutput(lines, outputWires); - - lines.push("}"); - lines.push(""); - - // Insert upfront tool function lookups right after the preamble. - // The toolFnVars map is fully populated at this point from tool emission. - if (this.toolFnVars.size > 0) { - const placeholderIdx = lines.indexOf(" // __TOOL_LOOKUPS__"); - if (placeholderIdx !== -1) { - const lookupLines: string[] = []; - for (const [fnName, varName] of this.toolFnVars) { - lookupLines.push( - ` const ${varName} = ${this.toolLookupExpr(fnName)};`, - ); - } - lines.splice(placeholderIdx, 1, ...lookupLines); - } - } - - // Extract function body (lines after the signature, before the closing brace) - const signatureIdx = lines.findIndex((l) => - l.startsWith("export default async function"), - ); - const closingIdx = lines.lastIndexOf("}"); - const bodyLines = lines.slice(signatureIdx + 1, closingIdx); - const functionBody = bodyLines.join("\n"); - - return { code: lines.join("\n"), functionName: fnName, functionBody }; - } - - // ── Tool call emission ───────────────────────────────────────────────────── - - /** - * Generate a tool call expression that uses __callSync for sync tools at runtime, - * falling back to `await __call` for async tools. Used at individual call sites. - */ - private syncAwareCall( - fnName: string, - inputObj: string, - memoizeTrunkKey?: string, - toolDefName?: string, - ): string { - const fn = this.toolFnVar(fnName); - const defName = JSON.stringify(toolDefName ?? fnName); - const name = JSON.stringify(fnName); - if (memoizeTrunkKey && this.memoizedToolKeys.has(memoizeTrunkKey)) { - return `await __callMemoized(${fn}, ${inputObj}, ${defName}, ${name}, ${JSON.stringify(memoizeTrunkKey)})`; - } - return `(${fn}?.bridge?.batch ? await __callBatch(${fn}, ${inputObj}, ${defName}, ${name}) : ${fn}?.bridge?.sync ? __callSync(${fn}, ${inputObj}, ${defName}, ${name}) : await __call(${fn}, ${inputObj}, ${defName}, ${name}))`; - } - - /** - * Same as syncAwareCall but without await — for use inside Promise.all() and - * in sync array map bodies. Returns a value for sync tools, a Promise for async. - */ - private syncAwareCallNoAwait( - fnName: string, - inputObj: string, - memoizeTrunkKey?: string, - toolDefName?: string, - ): string { - const fn = this.toolFnVar(fnName); - const defName = JSON.stringify(toolDefName ?? fnName); - const name = JSON.stringify(fnName); - if (memoizeTrunkKey && this.memoizedToolKeys.has(memoizeTrunkKey)) { - return `__callMemoized(${fn}, ${inputObj}, ${defName}, ${name}, ${JSON.stringify(memoizeTrunkKey)})`; - } - return `(${fn}?.bridge?.batch ? __callBatch(${fn}, ${inputObj}, ${defName}, ${name}) : ${fn}?.bridge?.sync ? __callSync(${fn}, ${inputObj}, ${defName}, ${name}) : __call(${fn}, ${inputObj}, ${defName}, ${name}))`; - } - - /** - * Emit a tool call with ToolDef wire merging and onError support. - * - * If a ToolDef exists for the tool: - * 1. Apply ToolDef constant wires as base input - * 2. Apply ToolDef pull wires (resolved at runtime from tool deps) - * 3. Apply bridge wires on top (override) - * 4. Call the ToolDef's fn function (not the tool name) - * 5. Wrap in try/catch if onError wire exists - */ - private emitToolCall( - lines: string[], - tool: ToolInfo, - bridgeWires: Wire[], - mode: "normal" | "fire-and-forget" | "catch-guarded" = "normal", - ): void { - const toolDef = this.resolveToolDef(tool.toolName); - - if (!toolDef) { - // Check if this is an internal pipe tool (expressions, interpolation) - if (this.internalToolKeys.has(tool.trunkKey)) { - this.emitInternalToolCall(lines, tool, bridgeWires); - return; - } - // Simple tool call — no ToolDef - const inputObj = this.buildObjectLiteral( - bridgeWires, - (w) => w.to.path, - 4, - ); - if (mode === "fire-and-forget") { - lines.push( - ` try { ${this.syncAwareCall(tool.toolName, inputObj, tool.trunkKey)}; } catch (_e) {}`, - ); - lines.push(` const ${tool.varName} = undefined;`); - } else if (mode === "catch-guarded") { - // Catch-guarded: store result AND the actual error so unguarded wires can re-throw. - lines.push(` let ${tool.varName}, ${tool.varName}_err;`); - lines.push( - ` try { ${tool.varName} = ${this.syncAwareCall(tool.toolName, inputObj, tool.trunkKey)}; } catch (_e) { if (_e?.name === "BridgePanicError" || _e?.name === "BridgeAbortError") throw _e; ${tool.varName}_err = _e; }`, - ); - } else { - const callExpr = this.syncAwareCall( - tool.toolName, - inputObj, - tool.trunkKey, - ); - const pullingLoc = this.findPullingWireLoc(tool.trunkKey); - if (pullingLoc) { - lines.push( - ` const ${tool.varName} = ${this.wrapExprWithLoc(callExpr, pullingLoc)};`, - ); - } else { - lines.push( - ` const ${tool.varName} = await __wrapBridgeErrorAsync(async () => (${callExpr}), null);`, - ); - } - } - return; - } - - // ToolDef-backed tool call - const fnName = toolDef.fn ?? tool.toolName; - - // Build input: ToolDef wires first, then bridge wires override - // Track entries by key for precise override matching - const inputEntries = new Map(); - - // Emit ToolDef-level tool dependency calls (e.g. `with authService as auth`) - // These must be emitted before building the input so their vars are in scope. - this.emitToolDeps(lines, toolDef); - - // ── ToolDef pipe forks (expressions, interpolation) ───────────── - // When a ToolDef has pipeHandles, some wires target internal fork tools - // (e.g., add:100000). Compute their results as inline expressions before - // processing the main tool's input wires. - const forkKeys = new Set(); - const forkExprs = new Map(); - if (toolDef.pipeHandles && toolDef.pipeHandles.length > 0) { - for (const ph of toolDef.pipeHandles) { - forkKeys.add(ph.key); - } - // Process forks in instance order (expressions may chain) - const sortedPH = [...toolDef.pipeHandles].sort((a, b) => { - const ai = a.baseTrunk.instance ?? 0; - const bi = b.baseTrunk.instance ?? 0; - return ai - bi; - }); - for (const ph of sortedPH) { - const forkKey = ph.key; - const forkField = ph.baseTrunk.field; - // Collect fork input wires - const forkInputs = new Map(); - for (const tw of toolDef.wires) { - if (refTrunkKey(tw.to) !== forkKey) continue; - const path = tw.to.path.join("."); - if (isLit(tw) && !isTern(tw)) { - forkInputs.set(path, emitCoerced(wVal(tw))); - } else if (isPull(tw)) { - const fromKey = refTrunkKey(wRef(tw)); - if (forkExprs.has(fromKey)) { - let expr = forkExprs.get(fromKey)!; - for (const p of wRef(tw).path) { - expr += `[${JSON.stringify(p)}]`; - } - forkInputs.set(path, expr); + // Single wire — collect for parallel execution + { + const isArrayWire = + stmt.sources.length === 1 && + stmt.sources[0]!.expr.type === "array"; + if (isArrayWire) { + flushPending(); + this.compileWire( + stmt, + scope, + outputVar, + pathPrefix, + absolutePrefix, + ); } else { - forkInputs.set(path, this.resolveToolWireSource(tw, toolDef)); + const target = stmt.target; + const targetExpr = this.compileTargetRef( + target, + scope, + outputVar, + pathPrefix, + ); + // Use raw compileSourceChain (no IIFE wrapping) and capture loc + // separately. emitParallelAssignments will annotate errors with + // bridgeLoc at the batch level — avoiding per-expression async + // IIFE closures in the hot path. + const hasLoc = stmt.sources.some((s) => s.expr.loc); + const locExpr = + hasLoc && !stmt.catch + ? stmt.sources.length === 1 && stmt.sources[0]!.expr.loc + ? jsLoc(stmt.sources[0]!.expr.loc) + : undefined + : undefined; + const valueExpr = locExpr + ? this.compileSourceChain(stmt.sources, stmt.catch, scope) + : this.compileSourceChainWithLoc( + stmt.sources, + stmt.catch, + scope, + ); + const isRoot = + target.module === SELF_MODULE && + target.type === this.bridge.type && + target.field === this.bridge.field && + target.path.length === 0 && + pathPrefix.length === 0; + pendingWires.push({ valueExpr, targetExpr, isRoot, locExpr }); } } - } - // Inline the internal tool operation - forkExprs.set(forkKey, this.inlineForkExpr(forkField, forkInputs)); - } - } - - // Accumulate nested ToolDef wire targets (path.length > 1) - // Maps top-level key -> [[remainingPath, expression]] - const nestedInputEntries = new Map(); - const addNestedEntry = (path: string[], expr: string) => { - const topKey = path[0]!; - if (!nestedInputEntries.has(topKey)) nestedInputEntries.set(topKey, []); - nestedInputEntries.get(topKey)!.push([path.slice(1), expr]); - }; - - // ToolDef constant wires (skip fork-targeted wires) - for (const tw of toolDef.wires) { - if (isLit(tw) && !isTern(tw)) { - if (forkKeys.has(refTrunkKey(tw.to))) continue; - const path = tw.to.path; - const expr = emitCoerced(wVal(tw)); - if (path.length > 1) { - addNestedEntry(path, expr); - } else { - inputEntries.set(path[0]!, ` ${JSON.stringify(path[0])}: ${expr}`); - } - } - } - - // ToolDef pull wires — resolved from tool handles (skip fork-targeted wires) - for (const tw of toolDef.wires) { - if (!isPull(tw)) continue; - if (forkKeys.has(refTrunkKey(tw.to))) continue; - // Skip wires with fallbacks — handled below - if (hasFallbacks(tw)) continue; - const path = tw.to.path; - const fromKey = refTrunkKey(wRef(tw)); - let expr: string; - if (forkExprs.has(fromKey)) { - // Source is a fork result - expr = forkExprs.get(fromKey)!; - for (const p of wRef(tw).path) { - expr = `(${expr})[${JSON.stringify(p)}]`; - } - } else { - expr = this.resolveToolWireSource(tw, toolDef); - } - if (path.length > 1) { - addNestedEntry(path, expr); - } else { - inputEntries.set(path[0]!, ` ${JSON.stringify(path[0])}: ${expr}`); - } - } - - // ToolDef ternary wires - for (const tw of toolDef.wires) { - if (!isTern(tw)) continue; - if (forkKeys.has(refTrunkKey(tw.to))) continue; - const path = tw.to.path; - const tern = wTern(tw); - const condExpr = this.resolveToolDefRef( - eRef(tern.cond), - toolDef, - forkExprs, - ); - const thenExpr = - tern.then.type === "ref" - ? this.resolveToolDefRef( - (tern.then as RefExpr).ref, - toolDef, - forkExprs, - ) - : tern.then.type === "literal" - ? emitCoerced((tern.then as LitExpr).value) - : "undefined"; - const elseExpr = - tern.else.type === "ref" - ? this.resolveToolDefRef( - (tern.else as RefExpr).ref, - toolDef, - forkExprs, - ) - : tern.else.type === "literal" - ? emitCoerced((tern.else as LitExpr).value) - : "undefined"; - const expr = `(${condExpr} ? ${thenExpr} : ${elseExpr})`; - if (path.length > 1) { - addNestedEntry(path, expr); - } else { - inputEntries.set(path[0]!, ` ${JSON.stringify(path[0])}: ${expr}`); - } - } - - // ToolDef fallback/coalesce wires (pull wires with fallbacks array) - for (const tw of toolDef.wires) { - if (!isPull(tw)) continue; - if (!hasFallbacks(tw) || !fallbacks(tw).length) continue; - if (forkKeys.has(refTrunkKey(tw.to))) continue; - const path = tw.to.path; - const pullWire = tw; - let expr = this.resolveToolDefRef(wRef(pullWire), toolDef, forkExprs); - for (const fb of fallbacks(pullWire)) { - const op = fb.gate === "nullish" ? "??" : "||"; - if (eVal(fb.expr) !== undefined) { - expr = `(${expr} ${op} ${emitCoerced(eVal(fb.expr))})`; - } else if (eRef(fb.expr)) { - const refExpr = this.resolveToolDefRef( - eRef(fb.expr), - toolDef, - forkExprs, - ); - expr = `(${expr} ${op} ${refExpr})`; - } - } - if (path.length > 1) { - addNestedEntry(path, expr); - } else { - inputEntries.set(path[0]!, ` ${JSON.stringify(path[0])}: ${expr}`); - } - } - - // Emit nested ToolDef inputs as nested object literals - for (const [topKey, entries] of nestedInputEntries) { - if (!inputEntries.has(topKey)) { - inputEntries.set( - topKey, - ` ${JSON.stringify(topKey)}: ${emitNestedObjectLiteral(entries)}`, - ); - } - } - - // Bridge wires override ToolDef wires - let spreadExprForToolDef: string | undefined; - for (const bw of bridgeWires) { - const path = bw.to.path; - if (path.length === 0) { - // Spread wire: ...sourceExpr — captures all fields from source - spreadExprForToolDef = this.wireToExpr(bw); - } else if (path.length >= 1) { - const key = path[0]!; - inputEntries.set( - key, - ` ${JSON.stringify(key)}: ${this.wireToExpr(bw)}`, - ); - } - } - - const inputParts = [...inputEntries.values()]; - - let inputObj: string; - if (spreadExprForToolDef !== undefined) { - // Spread wire present: { ...spreadExpr, field1: ..., field2: ... } - const spreadEntry = ` ...${spreadExprForToolDef}`; - const allParts = [spreadEntry, ...inputParts]; - inputObj = `{\n${allParts.join(",\n")},\n }`; - } else { - inputObj = - inputParts.length > 0 ? `{\n${inputParts.join(",\n")},\n }` : "{}"; - } - - if (toolDef.onError) { - // Wrap in try/catch for onError - lines.push(` let ${tool.varName};`); - lines.push(` try {`); - lines.push( - ` ${tool.varName} = ${this.syncAwareCall(fnName, inputObj, tool.trunkKey, tool.toolName)};`, - ); - lines.push(` } catch (_e) {`); - if ("value" in toolDef.onError) { - lines.push( - ` ${tool.varName} = JSON.parse(${JSON.stringify(toolDef.onError.value)});`, - ); - } else { - const fallbackExpr = this.resolveToolDepSource( - toolDef.onError.source, - toolDef, - ); - lines.push(` ${tool.varName} = ${fallbackExpr};`); - } - lines.push(` }`); - } else if (mode === "fire-and-forget") { - lines.push( - ` try { ${this.syncAwareCall(fnName, inputObj, tool.trunkKey, tool.toolName)}; } catch (_e) {}`, - ); - lines.push(` const ${tool.varName} = undefined;`); - } else if (mode === "catch-guarded") { - // Catch-guarded: store result AND the actual error so unguarded wires can re-throw. - lines.push(` let ${tool.varName}, ${tool.varName}_err;`); - lines.push( - ` try { ${tool.varName} = ${this.syncAwareCall(fnName, inputObj, tool.trunkKey, tool.toolName)}; } catch (_e) { if (_e?.name === "BridgePanicError" || _e?.name === "BridgeAbortError") throw _e; ${tool.varName}_err = _e; }`, - ); - } else { - const callExpr = this.syncAwareCall( - fnName, - inputObj, - tool.trunkKey, - tool.toolName, - ); - const pullingLoc = this.findPullingWireLoc(tool.trunkKey); - if (pullingLoc) { - lines.push( - ` const ${tool.varName} = ${this.wrapExprWithLoc(callExpr, pullingLoc)};`, - ); - } else { - lines.push( - ` const ${tool.varName} = await __wrapBridgeErrorAsync(async () => (${callExpr}), null);`, - ); - } - } - } - - /** - * Emit an inlined internal tool call (expressions, string interpolation). - * - * Instead of calling through the tools map, these are inlined as direct - * JavaScript operations — e.g., multiply becomes `Number(a) * Number(b)`. - */ - private emitInternalToolCall( - lines: string[], - tool: ToolInfo, - bridgeWires: Wire[], - ): void { - const fieldName = tool.toolName; - - // Collect input wires by their target path - const inputs = new Map(); - for (const w of bridgeWires) { - const path = w.to.path; - const key = path.join("."); - inputs.set(key, this.wireToExpr(w)); - } - - let expr: string; - - // condAnd/condOr wires target the root path and already contain the full - // inlined expression (e.g. `(Boolean(left) && Boolean(right))`). - const rootExpr = inputs.get(""); - if (rootExpr !== undefined && (fieldName === "and" || fieldName === "or")) { - expr = rootExpr; - } else { - const a = inputs.get("a") ?? "undefined"; - const b = inputs.get("b") ?? "undefined"; - - switch (fieldName) { - case "add": - expr = `(Number(${a}) + Number(${b}))`; - break; - case "subtract": - expr = `(Number(${a}) - Number(${b}))`; - break; - case "multiply": - expr = `(Number(${a}) * Number(${b}))`; - break; - case "divide": - expr = `(Number(${a}) / Number(${b}))`; - break; - case "eq": - expr = `(${a} === ${b})`; - break; - case "neq": - expr = `(${a} !== ${b})`; - break; - case "gt": - expr = `(Number(${a}) > Number(${b}))`; break; - case "gte": - expr = `(Number(${a}) >= Number(${b}))`; - break; - case "lt": - expr = `(Number(${a}) < Number(${b}))`; - break; - case "lte": - expr = `(Number(${a}) <= Number(${b}))`; - break; - case "not": - expr = `(!${a})`; - break; - case "and": - expr = `(Boolean(${a}) && Boolean(${b}))`; - break; - case "or": - expr = `(Boolean(${a}) || Boolean(${b}))`; + } + case "alias": + flushPending(); + this.compileAlias(stmt, scope); break; - case "concat": { - const parts: string[] = []; - for (let i = 0; ; i++) { - const partExpr = inputs.get(`parts.${i}`); - if (partExpr === undefined) break; - parts.push(partExpr); + case "scope": { + // Skip scope blocks targeting tools (handled by tool getters) + const scopeHandle = this.findTargetHandle(stmt.target, scope); + if (scopeHandle) { + const scopeBinding = scope.get(scopeHandle); + if (scopeBinding?.kind === "tool") break; } - // concat returns { value: string } — same as the runtime internal tool - const concatParts = parts - .map((p) => `(${p} == null ? "" : String(${p}))`) - .join(" + "); - expr = `{ value: ${concatParts || '""'} }`; + flushPending(); + this.compileScope(stmt, scope, outputVar, pathPrefix, absolutePrefix); break; } - default: { - // Unknown internal tool — fall back to tools map call - const inputObj = this.buildObjectLiteral( - bridgeWires, - (w) => w.to.path, - 4, - ); - lines.push( - ` const ${tool.varName} = ${this.syncAwareCall(tool.toolName, inputObj, tool.trunkKey)};`, - ); - return; - } - } - } - - lines.push(` const ${tool.varName} = ${expr};`); - } - - /** - * Emit ToolDef-level dependency tool calls. - * - * When a ToolDef declares `with authService as auth`, the auth handle - * references a separate tool that must be called before the main tool. - * This method recursively resolves the dependency chain, emitting calls - * in dependency order. Independent deps are parallelized with Promise.all. - * - * Results are cached in `toolDepVars` so each dep is called at most once. - */ - private emitToolDeps(lines: string[], toolDef: ToolDef): void { - // Collect tool-kind handles that haven't been emitted yet - const pendingDeps: { handle: string; toolName: string }[] = []; - for (const h of toolDef.handles) { - if (h.kind === "tool" && !this.toolDepVars.has(h.name)) { - pendingDeps.push({ handle: h.handle, toolName: h.name }); - } - } - if (pendingDeps.length === 0) return; - - // Recursively emit transitive deps first - for (const pd of pendingDeps) { - const depToolDef = this.resolveToolDef(pd.toolName); - if (depToolDef) { - // Check for patterns the compiler can't handle in tool deps - if (depToolDef.onError) { - throw new BridgeCompilerIncompatibleError( - `${this.bridge.type}.${this.bridge.field}`, - "ToolDef on-error fallback in tool dependencies is not yet supported by the compiler.", - ); - } - for (const tw of depToolDef.wires) { - if ((isLit(tw) || isPull(tw)) && !isTern(tw)) { - if (tw.to.path.length > 1) { - throw new BridgeCompilerIncompatibleError( - `${this.bridge.type}.${this.bridge.field}`, - "Nested wire paths in tool dependencies are not yet supported by the compiler.", - ); - } - } - } - - this.emitToolDeps(lines, depToolDef); - } - } - - // Now emit the current level deps — only the ones still not emitted - const toEmit = pendingDeps.filter( - (pd) => !this.toolDepVars.has(pd.toolName), - ); - if (toEmit.length === 0) return; - - // Build call expressions for each dep - const depCalls: { toolName: string; varName: string; callExpr: string }[] = - []; - for (const pd of toEmit) { - const depToolDef = this.resolveToolDef(pd.toolName); - if (!depToolDef) continue; - - const fnName = depToolDef.fn ?? pd.toolName; - const varName = `_td${++this.toolCounter}`; - - // Build input from the dep's ToolDef wires - const inputParts: string[] = []; - - // Constant wires - for (const tw of depToolDef.wires) { - if (isLit(tw) && !isTern(tw)) { - inputParts.push( - ` ${JSON.stringify(tw.to.path.join("."))}: ${emitCoerced(wVal(tw))}`, - ); - } - } - - // Pull wires — resolved from the dep's own handles - for (const tw of depToolDef.wires) { - if (isPull(tw)) { - const source = this.resolveToolWireSource(tw, depToolDef); - inputParts.push( - ` ${JSON.stringify(tw.to.path.join("."))}: ${source}`, - ); - } - } - - const inputObj = - inputParts.length > 0 ? `{\n${inputParts.join(",\n")},\n }` : "{}"; - - // Build call expression (without `const X = await`) - const callExpr = this.syncAwareCallNoAwait( - fnName, - inputObj, - undefined, - pd.toolName, - ); - - depCalls.push({ toolName: pd.toolName, varName, callExpr }); - this.toolDepVars.set(pd.toolName, varName); - } - - if (depCalls.length === 0) return; - - if (depCalls.length === 1) { - const dc = depCalls[0]!; - lines.push(` const ${dc.varName} = await ${dc.callExpr};`); - } else { - // Parallel: independent deps resolve concurrently - const varNames = depCalls.map((dc) => dc.varName).join(", "); - lines.push(` const [${varNames}] = await Promise.all([`); - for (const dc of depCalls) { - lines.push(` ${dc.callExpr},`); - } - lines.push(` ]);`); - } - } - - /** - * Resolve a Wire's source NodeRef to a JS expression in the context of a ToolDef. - * Handles context, const, and tool handle types. - */ - private resolveToolWireSource(wire: Wire, toolDef: ToolDef): string { - const ref = wRef(wire); - // Match the ref against tool handles - const h = toolDef.handles.find((handle) => { - if (handle.kind === "context") { - return ( - ref.module === SELF_MODULE && - ref.type === "Context" && - ref.field === "context" - ); - } - if (handle.kind === "const") { - return ( - ref.module === SELF_MODULE && - ref.type === "Const" && - ref.field === "const" - ); - } - if (handle.kind === "tool") { - return ( - ref.module === SELF_MODULE && - ref.type === "Tools" && - ref.field === handle.name - ); - } - return false; - }); - - if (!h) return "undefined"; - - // Reconstruct the string-based source for resolveToolDepSource - const pathParts = ref.path.length > 0 ? "." + ref.path.join(".") : ""; - return this.resolveToolDepSource(h.handle + pathParts, toolDef); - } - - /** - * Resolve a NodeRef within a ToolDef context to a JS expression. - * Like resolveToolWireSource but also checks fork expression results. - */ - private resolveToolDefRef( - ref: NodeRef, - toolDef: ToolDef, - forkExprs: Map, - ): string { - const key = refTrunkKey(ref); - if (forkExprs.has(key)) { - let expr = forkExprs.get(key)!; - for (const p of ref.path) { - expr = `(${expr})[${JSON.stringify(p)}]`; - } - return expr; - } - // Delegate to resolveToolWireSource via a synthetic wire - return this.resolveToolWireSource( - { to: ref, sources: [{ expr: { type: "ref" as const, ref: ref } }] }, - toolDef, - ); - } - - /** - * Inline an internal fork tool operation as a JS expression. - * Used for ToolDef pipe forks — mirrors emitInternalToolCall logic. - */ - private inlineForkExpr( - forkField: string, - inputs: Map, - ): string { - const a = inputs.get("a") ?? "undefined"; - const b = inputs.get("b") ?? "undefined"; - switch (forkField) { - case "add": - return `(Number(${a}) + Number(${b}))`; - case "subtract": - return `(Number(${a}) - Number(${b}))`; - case "multiply": - return `(Number(${a}) * Number(${b}))`; - case "divide": - return `(Number(${a}) / Number(${b}))`; - case "eq": - return `(${a} === ${b})`; - case "neq": - return `(${a} !== ${b})`; - case "gt": - return `(Number(${a}) > Number(${b}))`; - case "gte": - return `(Number(${a}) >= Number(${b}))`; - case "lt": - return `(Number(${a}) < Number(${b}))`; - case "lte": - return `(Number(${a}) <= Number(${b}))`; - case "not": - return `(!${a})`; - case "and": - return `(Boolean(${a}) && Boolean(${b}))`; - case "or": - return `(Boolean(${a}) || Boolean(${b}))`; - case "concat": { - const parts: string[] = []; - for (let i = 0; ; i++) { - const partExpr = inputs.get(`parts.${i}`); - if (partExpr === undefined) break; - parts.push(partExpr); - } - const concatParts = parts - .map((p) => `(${p} == null ? "" : String(${p}))`) - .join(" + "); - return `{ value: ${concatParts || '""'} }`; - } - default: - return "undefined"; - } - } - - /** - * Resolve a ToolDef source reference (e.g. "ctx.apiKey") to a JS expression. - * Handles context, const, and tool dependencies. - */ - private resolveToolDepSource(source: string, toolDef: ToolDef): string { - const dotIdx = source.indexOf("."); - const handle = dotIdx === -1 ? source : source.substring(0, dotIdx); - const restPath = - dotIdx === -1 ? [] : source.substring(dotIdx + 1).split("."); - - const h = toolDef.handles.find((d) => d.handle === handle); - if (!h) return "undefined"; - - let baseExpr: string; - if (h.kind === "context") { - baseExpr = "context"; - } else if (h.kind === "const") { - // Resolve from the const definitions — inline parsed value - if (restPath.length > 0) { - const constName = restPath[0]!; - const val = this.constDefs.get(constName); - if (val != null) { - const base = emitParsedConst(val); - if (restPath.length === 1) return base; - const tail = restPath - .slice(1) - .map((p) => `[${JSON.stringify(p)}]`) - .join(""); - return `(${base})${tail}`; - } - } - return "undefined"; - } else if (h.kind === "tool") { - // Tool dependency — first check ToolDef-level dep vars (emitted by emitToolDeps), - // then fall back to bridge-level tool handles - const depVar = this.toolDepVars.get(h.name); - if (depVar) { - baseExpr = depVar; - } else { - const depToolInfo = this.findToolByName(h.name); - if (depToolInfo) { - baseExpr = depToolInfo.varName; - } else { - return "undefined"; - } - } - } else { - return "undefined"; - } - - if (restPath.length === 0) return baseExpr; - let expr = - baseExpr + restPath.map((p) => `[${JSON.stringify(p)}]`).join(""); - - // If reading from a tool dep, check if the dep has a constant wire for - // this path — if so, add a ?? fallback so the constant is visible even - // though the tool function may not have returned it. - if (h.kind === "tool" && restPath.length > 0) { - const depToolDef = this.resolveToolDef(h.name); - if (depToolDef) { - const pathKey = restPath.join("."); - for (const tw of depToolDef.wires) { - if (isLit(tw) && !isTern(tw) && tw.to.path.join(".") === pathKey) { - expr = `(${expr} ?? ${emitCoerced(wVal(tw))})`; - break; - } - } - } - } - - return expr; - } - - /** Find a tool info by tool name. */ - private findToolByName(name: string): ToolInfo | undefined { - for (const [, info] of this.tools) { - if (info.toolName === name) return info; - } - return undefined; - } - - /** - * Resolve a ToolDef by name, merging the extends chain. - * Mirrors the runtime's resolveToolDefByName logic. - */ - private resolveToolDef(name: string): ToolDef | undefined { - const base = this.toolDefs.find((t) => t.name === name); - if (!base) return undefined; - - // Build extends chain: root → ... → leaf - const chain: ToolDef[] = [base]; - let current = base; - while (current.extends) { - const parent = this.toolDefs.find((t) => t.name === current.extends); - if (!parent) break; - chain.unshift(parent); - current = parent; - } - - // Merge: root provides base, each child overrides - const merged: ToolDef = { - kind: "tool", - name, - fn: chain[0]!.fn, - handles: [], - wires: [], - }; - - for (const def of chain) { - for (const h of def.handles) { - if (!merged.handles.some((mh) => mh.handle === h.handle)) { - merged.handles.push(h); - } - } - for (const wire of def.wires) { - const wireKey = wire.to.path.join("."); - const idx = merged.wires.findIndex( - (w) => w.to.path.join(".") === wireKey, - ); - if (idx >= 0) merged.wires[idx] = wire; - else merged.wires.push(wire); - } - if (def.onError) merged.onError = def.onError; - // Merge pipeHandles — child overrides parent by key - if (def.pipeHandles) { - if (!merged.pipeHandles) merged.pipeHandles = []; - for (const ph of def.pipeHandles) { - if (!merged.pipeHandles.some((mph) => mph.key === ph.key)) { - merged.pipeHandles.push(ph); - } - } - } - } - - return merged; - } - - // ── Output generation ──────────────────────────────────────────────────── - - private emitOutput(lines: string[], outputWires: Wire[]): void { - if (outputWires.length === 0) { - // Match the runtime's error when no wires target the output - const { type, field } = this.bridge; - const hasForce = this.bridge.forces && this.bridge.forces.length > 0; - if (!hasForce) { - lines.push( - ` throw new Error(${JSON.stringify(`Bridge "${type}.${field}" has no output wires. Ensure at least one wire targets the output (e.g. \`o.field <- ...\`).`)});`, - ); - } else { - lines.push(" return {};"); - } - return; - } - - // Detect array iterators - const arrayIterators = this.bridge.arrayIterators ?? {}; - const isRootArray = "" in arrayIterators; - - // Separate root wires into passthrough vs spread - const rootWires = outputWires.filter((w) => w.to.path.length === 0); - const spreadRootWires = rootWires.filter( - (w) => isPull(w) && w.spread && w.spread, - ); - const passthroughRootWire = rootWires.find( - (w) => !(isPull(w) && w.spread && w.spread), - ); - - // Passthrough (non-spread root wire) — return directly - if (passthroughRootWire && !isRootArray) { - lines.push(` return ${this.wireToExpr(passthroughRootWire)};`); - return; - } - - // Check for root passthrough (wire with empty path) — but not if it's a root array source - const rootWire = rootWires[0]; // for backwards compat with array handling below - - // Handle root array output (o <- src.items[] as item { ... }) - if (isRootArray && rootWire) { - const elemWires = outputWires.filter( - (w) => w !== rootWire && w.to.path.length > 0, - ); - let arrayExpr = this.wireToExpr(rootWire); - // Check for catch control on root wire (e.g., `catch continue` returns []) - const rootCatchCtrl = hasCatchControl(rootWire) - ? catchControl(rootWire) - : undefined; - if ( - rootCatchCtrl && - (rootCatchCtrl.kind === "continue" || rootCatchCtrl.kind === "break") - ) { - arrayExpr = `await (async () => { try { return ${arrayExpr}; } catch (_e) { if (_e?.name === "BridgePanicError" || _e?.name === "BridgeAbortError") throw _e; return null; } })()`; - } - // Only check control flow on direct element wires, not sub-array element wires - const directElemWires = elemWires.filter((w) => w.to.path.length === 1); - const currentScopeElemWires = this.filterCurrentElementWires( - elemWires, - arrayIterators, - ); - const cf = detectControlFlow(directElemWires); - const anyCf = detectControlFlow(elemWires); - const requiresLabeledLoop = !cf && !!anyCf && anyCf.levels > 1; - // Check if any element wire generates `await` (element-scoped tools or catch fallbacks) - const needsAsync = elemWires.some((w) => this.wireNeedsAwait(w)); - - if (needsAsync) { - // Check if async is only from element-scoped tools (no catch fallbacks). - // If so, generate a dual sync/async path with a runtime check. - const canDualPath = - !cf && !requiresLabeledLoop && this.asyncOnlyFromTools(elemWires); - const toolRefs = canDualPath - ? this.collectElementToolRefs(currentScopeElemWires) - : []; - const hasDualPath = canDualPath && toolRefs.length > 0; - - if (hasDualPath) { - // ── Dual path: sync .map() when all element tools are sync ── - const syncCheck = toolRefs - .map((r) => `${r}.bridge?.sync`) - .join(" && "); - - // Sync branch — .map() with __callSync - const syncPreamble: string[] = []; - this.elementLocalVars.clear(); - this.collectElementPreamble( - currentScopeElemWires, - "_el0", - syncPreamble, - true, - ); - const syncBody = this.buildElementBody( - elemWires, - arrayIterators, - 0, - 6, - ); - lines.push(` if (${syncCheck}) {`); - if (syncPreamble.length > 0) { - lines.push( - ` return (${arrayExpr} ?? []).map((_el0) => { ${syncPreamble.join(" ")} return ${syncBody}; });`, - ); - } else { - lines.push( - ` return (${arrayExpr} ?? []).map((_el0) => (${syncBody}));`, - ); - } - lines.push(` }`); - this.elementLocalVars.clear(); - } - - // Async branch — Promise.all over async element callbacks so batched - // tool calls can coalesce before the first microtask flush. Control - // flow still requires an explicit loop. - const preambleLines: string[] = []; - this.elementLocalVars.clear(); - this.collectElementPreamble( - currentScopeElemWires, - "_el0", - preambleLines, - ); - - if ( - cf?.kind === "break" || - cf?.kind === "continue" || - requiresLabeledLoop - ) { - const body = cf - ? this.buildElementBodyWithControlFlow( - elemWires, - arrayIterators, - 0, - 4, - cf.kind === "continue" ? "for-continue" : "break", - ) - : ` _result.push(${this.buildElementBody(elemWires, arrayIterators, 0, 4)});`; - - lines.push(` const _result = [];`); - lines.push(` __loop0: for (const _el0 of (${arrayExpr} ?? [])) {`); - lines.push(` try {`); - for (const pl of preambleLines) { - lines.push(` ${pl}`); - } - lines.push(` ${body.trimStart()}`); - lines.push(` } catch (_ctrl) {`); - lines.push( - ` if (__isLoopCtrl(_ctrl)) { if (_ctrl.levels > 1) throw __nextLoopCtrl(_ctrl); if (_ctrl.__bridgeControl === "break") break; continue; }`, - ); - lines.push(` throw _ctrl;`); - lines.push(` }`); - lines.push(` }`); - lines.push(` return _result;`); - } else { - lines.push( - ` return await Promise.all((${arrayExpr} ?? []).map(async (_el0) => {`, - ); - for (const pl of preambleLines) { - lines.push(` ${pl}`); - } - lines.push( - ` return ${this.buildElementBody(elemWires, arrayIterators, 0, 4)};`, - ); - lines.push(` }));`); - } - this.elementLocalVars.clear(); - } else if (cf?.kind === "continue" && cf.levels === 1) { - // Use flatMap — skip elements that trigger continue (sync only) - const body = this.buildElementBodyWithControlFlow( - elemWires, - arrayIterators, - 0, - 4, - "continue", - ); - lines.push(` return (${arrayExpr} ?? []).flatMap((_el0) => {`); - lines.push(body); - lines.push(` });`); - } else if ( - cf?.kind === "break" || - cf?.kind === "continue" || - requiresLabeledLoop - ) { - // Use an explicit loop for: - // - direct break/continue control - // - nested multilevel control (e.g. break 2 / continue 2) that must - // escape from sub-array IIFEs through throw/catch propagation. - // Use a loop with early break (sync) - const body = cf - ? this.buildElementBodyWithControlFlow( - elemWires, - arrayIterators, - 0, - 4, - cf.kind === "continue" ? "for-continue" : "break", - ) - : ` _result.push(${this.buildElementBody(elemWires, arrayIterators, 0, 4)});`; - lines.push(` const _result = [];`); - lines.push(` __loop0: for (const _el0 of (${arrayExpr} ?? [])) {`); - lines.push(` try {`); - lines.push(` ${body.trimStart()}`); - lines.push(` } catch (_ctrl) {`); - lines.push( - ` if (__isLoopCtrl(_ctrl)) { if (_ctrl.levels > 1) throw __nextLoopCtrl(_ctrl); if (_ctrl.__bridgeControl === "break") break; continue; }`, - ); - lines.push(` throw _ctrl;`); - lines.push(` }`); - lines.push(` }`); - lines.push(` return _result;`); - } else { - const body = this.buildElementBody(elemWires, arrayIterators, 0, 4); - lines.push(` return (${arrayExpr} ?? []).map((_el0) => (${body}));`); + case "spread": + flushPending(); + this.compileSpread(stmt, scope, outputVar); + break; + case "force": + // Defer force statements until after output wires + deferredForces.push(stmt); + break; + case "with": + // Already handled in first pass + break; } - return; } - - const arrayFields = new Set(Object.keys(arrayIterators)); - - // Separate element wires from scalar wires - const elementWires = new Map(); - const scalarWires: Wire[] = []; - const arraySourceWires = new Map(); - - for (const w of outputWires) { - const topField = w.to.path[0]!; - const isElementWire = - (isPull(w) && - (wRef(w).element || - w.to.element || - this.elementScopedTools.has(refTrunkKey(wRef(w))) || - // Wires from bridge-level refs targeting inside an array mapping - (arrayFields.has(topField) && w.to.path.length > 1))) || - (w.to.element && (isLit(w) || isTern(w))) || - // Cond wires targeting a field inside an array mapping are element wires - (isTern(w) && arrayFields.has(topField) && w.to.path.length > 1) || - // Const wires targeting a field inside an array mapping are element wires - (isLit(w) && arrayFields.has(topField) && w.to.path.length > 1); - if (isElementWire) { - // Element wire — belongs to an array mapping - const arr = elementWires.get(topField) ?? []; - arr.push(w); - elementWires.set(topField, arr); - } else if (arrayFields.has(topField) && w.to.path.length === 1) { - // Root wire for an array field - arraySourceWires.set(topField, w); - } else if (isPull(w) && w.spread && w.spread && w.to.path.length === 0) { - // Spread root wire — handled separately via spreadRootWires - } else { - scalarWires.push(w); - } + flushPending(); + + // Emit deferred force statements after all output wires + for (const stmt of deferredForces) { + this.compileForce(stmt, scope); + } + } + + private registerWithBinding(stmt: WithStatement, scope: ScopeChain) { + const h = stmt.binding; + switch (h.kind) { + case "input": + scope.set(h.handle, { kind: "input", jsExpr: "input" }); + break; + case "output": + scope.set(h.handle, { kind: "output", jsExpr: "__output" }); + break; + case "context": + scope.set(h.handle, { kind: "context", jsExpr: "context" }); + break; + case "const": + scope.set(h.handle, { kind: "const", jsExpr: "__consts" }); + break; + case "tool": { + const toolId = safeId(h.handle) + "_" + this.toolGetterCount++; + const toolFnVar = `__toolFn_${toolId}`; + scope.set(h.handle, { + kind: "tool", + jsExpr: toolFnVar, + toolName: h.name, + toolFnExpr: toolFnVar, + memoize: h.memoize === true || undefined, + }); + // Emit tool function lookup — resolve fn through ToolDef extends chain + const toolDef = this.resolveToolDef(h.name); + const fnName = toolDef?.fn ?? h.name; + this.emit(`const ${toolFnVar} = tools[${jsStr(fnName)}];`); + break; + } + case "define": + scope.set(h.handle, { + kind: "define", + jsExpr: `__define_${safeId(h.handle)}`, + defineName: h.name, + }); + break; } + } - // Build a nested tree from scalar wires using their full output path - interface TreeNode { - expr?: string; - terminal?: boolean; - spreadExprs?: string[]; - children: Map; - } - const tree: TreeNode = { children: new Map() }; + // ── Tool input collection ───────────────────────────────────────────── - // First pass: handle nested spread wires (spread with path.length > 0) - const nestedSpreadWires = scalarWires.filter( - (w) => isPull(w) && w.spread && w.spread && w.to.path.length > 0, - ); - const normalScalarWires = scalarWires.filter( - (w) => !(isPull(w) && w.spread && w.spread), - ); + /** + * Collect all wire statements that target tool inputs in this scope level. + * Returns a map: toolHandle → [{ inputField, sourceExpr }] + */ + private collectToolInputs( + body: Statement[], + scope: ScopeChain, + ): Map { + const map = new Map(); + + const addEntry = ( + handleName: string, + field: string, + stmt: WireStatement, + ) => { + let entries = map.get(handleName); + if (!entries) { + entries = []; + map.set(handleName, entries); + } + entries.push({ field, stmt }); + }; - // Add nested spread expressions to tree nodes - for (const w of nestedSpreadWires) { - const path = w.to.path; - let current = tree; - // Navigate to parent of the target - for (let i = 0; i < path.length - 1; i++) { - const seg = path[i]!; - if (!current.children.has(seg)) { - current.children.set(seg, { children: new Map() }); + const collectFromScope = ( + stmts: Statement[], + handleName: string, + pathPrefix: string[], + ) => { + for (const inner of stmts) { + if (inner.kind === "wire") { + const field = [...pathPrefix, ...inner.target.path].join("."); + addEntry(handleName, field, inner); + } else if (inner.kind === "scope") { + collectFromScope(inner.body, handleName, [ + ...pathPrefix, + ...inner.target.path, + ]); } - current = current.children.get(seg)!; - } - const lastSeg = path[path.length - 1]!; - if (!current.children.has(lastSeg)) { - current.children.set(lastSeg, { children: new Map() }); } - const node = current.children.get(lastSeg)!; - // Add spread expression to this node - if (!node.spreadExprs) node.spreadExprs = []; - node.spreadExprs.push(this.wireToExpr(w)); - } + }; - for (const w of normalScalarWires) { - const path = w.to.path; - let current = tree; - for (let i = 0; i < path.length - 1; i++) { - const seg = path[i]!; - if (!current.children.has(seg)) { - current.children.set(seg, { children: new Map() }); - } - current = current.children.get(seg)!; - } - const lastSeg = path[path.length - 1]!; - if (!current.children.has(lastSeg)) { - current.children.set(lastSeg, { children: new Map() }); + for (const stmt of body) { + if (stmt.kind === "wire") { + const target = stmt.target; + const handleName = this.findTargetHandle(target, scope); + if (!handleName) continue; + const binding = scope.get(handleName); + if (!binding || binding.kind !== "tool") continue; + const field = target.path.join("."); + addEntry(handleName, field, stmt); + } else if (stmt.kind === "scope") { + // Check if the scope targets a tool (instance != null) + const handleName = this.findTargetHandle(stmt.target, scope); + if (!handleName) continue; + const binding = scope.get(handleName); + if (!binding || binding.kind !== "tool") continue; + collectFromScope(stmt.body, handleName, stmt.target.path); } - const node = current.children.get(lastSeg)!; - this.mergeOverdefinedExpr(node, w); } - // Emit array-mapped fields into the tree as well - for (const [arrayField] of Object.entries(arrayIterators)) { - if (arrayField === "") continue; // root array handled above - const sourceW = arraySourceWires.get(arrayField); - const elemWires = elementWires.get(arrayField) ?? []; - if (!sourceW || elemWires.length === 0) continue; - - // Strip the array field prefix from element wire paths - const shifted: Wire[] = elemWires.map((w) => ({ - ...w, - to: { ...w.to, path: w.to.path.slice(1) }, - })); + return map; + } - const arrayExpr = this.wireToExpr(sourceW); - // Only check control flow on direct element wires (not sub-array element wires) - const directShifted = shifted.filter((w) => w.to.path.length === 1); - const currentScopeShifted = this.filterCurrentElementWires( - shifted, - this.relativeArrayIterators(arrayIterators, arrayField), - ); - const cf = detectControlFlow(directShifted); - const anyCf = detectControlFlow(shifted); - const requiresLabeledLoop = !cf && !!anyCf && anyCf.levels > 1; - // Check if any element wire generates `await` (element-scoped tools or catch fallbacks) - const needsAsync = shifted.some((w) => this.wireNeedsAwait(w)); - let mapExpr: string; - if (needsAsync) { - // Check if we can generate a dual sync/async path - const canDualPath = - !cf && !requiresLabeledLoop && this.asyncOnlyFromTools(shifted); - const toolRefs = canDualPath - ? this.collectElementToolRefs(currentScopeShifted) - : []; - const hasDualPath = canDualPath && toolRefs.length > 0; - - if (hasDualPath) { - // Sync branch — .map() with __callSync - const syncCheck = toolRefs - .map((r) => `${r}.bridge?.sync`) - .join(" && "); - const syncPreamble: string[] = []; - this.elementLocalVars.clear(); - this.collectElementPreamble( - currentScopeShifted, - "_el0", - syncPreamble, - true, - ); - const shiftedIterators = this.relativeArrayIterators( - arrayIterators, - arrayField, - ); - const syncMapExpr = - syncPreamble.length > 0 - ? `(${arrayExpr})?.map((_el0) => { ${syncPreamble.join(" ")} return ${this.buildElementBody(shifted, shiftedIterators, 0, 6)}; }) ?? null` - : `(${arrayExpr})?.map((_el0) => (${this.buildElementBody(shifted, shiftedIterators, 0, 6)})) ?? null`; - this.elementLocalVars.clear(); - - // Async branch — for...of inside an async IIFE - const preambleLines: string[] = []; - this.elementLocalVars.clear(); - this.collectElementPreamble( - currentScopeShifted, - "_el0", - preambleLines, - ); - const preamble = preambleLines.map((l) => ` ${l}`).join("\n"); - const asyncExpr = `await ((async (__s) => Array.isArray(__s) ? Promise.all(__s.map(async (_el0) => {\n${preamble}${preamble ? "\n" : ""} return ${this.buildElementBody(shifted, shiftedIterators, 0, 8)};\n })) : null)(${arrayExpr}))`; - this.elementLocalVars.clear(); + /** + * Collect all wire statements that target define inputs. + * Define input wires have target.module starting with "__define_". + */ + private collectDefineInputs( + body: Statement[], + _scope: ScopeChain, + ): Map { + const map = new Map(); - mapExpr = `(${syncCheck}) ? ${syncMapExpr} : ${asyncExpr}`; - } else { - // Standard async path — Promise.all over async element callbacks so - // batched tools can queue together before the first flush. Control - // flow still requires an explicit loop. - const preambleLines: string[] = []; - this.elementLocalVars.clear(); - this.collectElementPreamble( - currentScopeShifted, - "_el0", - preambleLines, - ); - const shiftedIterators = this.relativeArrayIterators( - arrayIterators, - arrayField, - ); + for (const stmt of body) { + if (stmt.kind !== "wire") continue; + if (!stmt.target.module.startsWith("__define_")) continue; - const preamble = preambleLines.map((l) => ` ${l}`).join("\n"); - if ( - cf?.kind === "break" || - cf?.kind === "continue" || - requiresLabeledLoop - ) { - const asyncBody = cf - ? this.buildElementBodyWithControlFlow( - shifted, - shiftedIterators, - 0, - 8, - cf.kind === "continue" ? "for-continue" : "break", - ) - : ` _result.push(${this.buildElementBody(shifted, shiftedIterators, 0, 8)});`; - mapExpr = `await (async () => { const _src = ${arrayExpr}; if (_src == null) return null; const _result = []; __loop0: for (const _el0 of _src) {\n try {\n${preamble}\n${asyncBody}\n } catch (_ctrl) { if (__isLoopCtrl(_ctrl)) { if (_ctrl.levels > 1) throw __nextLoopCtrl(_ctrl); if (_ctrl.__bridgeControl === "break") break; continue; } throw _ctrl; }\n } return _result; })()`; - } else { - mapExpr = `await ((async (__s) => Array.isArray(__s) ? Promise.all(__s.map(async (_el0) => {\n${preamble}${preamble ? "\n" : ""} return ${this.buildElementBody(shifted, shiftedIterators, 0, 8)};\n })) : null)(${arrayExpr}))`; - } - this.elementLocalVars.clear(); - } - } else if (cf?.kind === "continue" && cf.levels === 1) { - const cfBody = this.buildElementBodyWithControlFlow( - shifted, - this.relativeArrayIterators(arrayIterators, arrayField), - 0, - 6, - "continue", - ); - mapExpr = `((__s) => Array.isArray(__s) ? __s.flatMap((_el0) => {\n${cfBody}\n }) ?? null : null)(${arrayExpr})`; - } else if ( - cf?.kind === "break" || - cf?.kind === "continue" || - requiresLabeledLoop - ) { - // Same rationale as root array handling above: nested multilevel - // control requires for-loop + throw/catch propagation instead of map. - const loopBody = cf - ? this.buildElementBodyWithControlFlow( - shifted, - this.relativeArrayIterators(arrayIterators, arrayField), - 0, - 8, - cf.kind === "continue" ? "for-continue" : "break", - ) - : ` _result.push(${this.buildElementBody(shifted, this.relativeArrayIterators(arrayIterators, arrayField), 0, 8)});`; - mapExpr = `(() => { const _src = ${arrayExpr}; if (!Array.isArray(_src)) return null; const _result = []; __loop0: for (const _el0 of _src) {\n try {\n${loopBody}\n } catch (_ctrl) { if (__isLoopCtrl(_ctrl)) { if (_ctrl.levels > 1) throw __nextLoopCtrl(_ctrl); if (_ctrl.__bridgeControl === "break") break; continue; } throw _ctrl; }\n } return _result; })()`; - } else { - const body = this.buildElementBody( - shifted, - this.relativeArrayIterators(arrayIterators, arrayField), - 0, - 6, - ); - mapExpr = `((__s) => Array.isArray(__s) ? __s.map((_el0) => (${body})) ?? null : null)(${arrayExpr})`; + // Extract handle name from module: "__define_sp" → "sp" + const handleName = stmt.target.module.substring("__define_".length); + let entries = map.get(handleName); + if (!entries) { + entries = []; + map.set(handleName, entries); } - if (!tree.children.has(arrayField)) { - tree.children.set(arrayField, { children: new Map() }); - } - tree.children.get(arrayField)!.expr = mapExpr; + const field = stmt.target.path.join("."); + entries.push({ field, stmt }); } - // Serialize the tree to a return statement - // Include spread expressions at the start if present - const spreadExprs = spreadRootWires.map((w) => this.wireToExpr(w)); - const objStr = this.serializeOutputTree(tree, 4, spreadExprs); - lines.push(` return ${objStr};`); + return map; } - /** Serialize an output tree node into a JS object literal. */ - private serializeOutputTree( - node: { - children: Map }>; - }, - indent: number, - spreadExprs?: string[], - ): string { - const pad = " ".repeat(indent); - const entries: string[] = []; - - // Add spread expressions first (they come before field overrides) - if (spreadExprs) { - for (const expr of spreadExprs) { - entries.push(`${pad}...${expr}`); + /** + * Emit memoized define getters for this scope. + * Each define getter compiles the define body inline, using bridge wires as input. + */ + private emitDefineGetters( + defineInputs: Map, + scope: ScopeChain, + ) { + // Also emit getters for defines that have 0 input wires + // (they might still be referenced as sources) + for (const h of this.bridge.handles) { + if (h.kind === "define" && !defineInputs.has(h.handle)) { + defineInputs.set(h.handle, []); } } - for (const [key, child] of node.children) { - // Check if child has spread expressions - const childSpreadExprs = (child as { spreadExprs?: string[] }) - .spreadExprs; - - if ( - child.expr != null && - child.children.size === 0 && - !childSpreadExprs - ) { - // Simple leaf with just an expression - entries.push(`${pad}${JSON.stringify(key)}: ${child.expr}`); - } else if (childSpreadExprs || child.children.size > 0) { - // Nested object: may have spreads, children, or both - const nested = this.serializeOutputTree( - child, - indent + 2, - childSpreadExprs, - ); - entries.push(`${pad}${JSON.stringify(key)}: ${nested}`); - } else { - // Has both expr and children — use expr (children override handled elsewhere) - entries.push( - `${pad}${JSON.stringify(key)}: ${child.expr ?? "undefined"}`, - ); - } - } + for (const [handleName, inputs] of defineInputs) { + const binding = scope.get(handleName); + if (!binding || binding.kind !== "define") continue; - const innerPad = " ".repeat(indent - 2); - return `{\n${entries.join(",\n")},\n${innerPad}}`; - } + const defineName = binding.defineName ?? handleName; + const defineDef = this.defineDefs.get(defineName); + if (!defineDef) continue; - private reorderOverdefinedOutputWires(outputWires: Wire[]): Wire[] { - if (outputWires.length < 2) return outputWires; + const getterId = safeId(handleName) + "_def_" + this.toolGetterCount++; + const getterName = `__get_${getterId}`; - const groups = new Map(); - for (const wire of outputWires) { - const pathKey = wire.to.path.join("."); - const group = groups.get(pathKey) ?? []; - group.push(wire); - groups.set(pathKey, group); - } + const defineKey = `_:Define:${defineName}`; + this.emit(`const ${getterName} = __memoize(async () => {`); + this.pushIndent(); - const emitted = new Set(); - const reordered: Wire[] = []; - let changed = false; + // Build define input from bridge wires + this.emit("const __defInput = {};"); + const singleFields: { field: string; expr: string }[] = []; + for (const { field, stmt } of inputs) { + const valueExpr = this.compileSourceChain( + stmt.sources, + stmt.catch, + scope, + ); + if (field === "") { + this.emit(`Object.assign(__defInput, ${valueExpr});`); + } else { + singleFields.push({ field, expr: valueExpr }); + } + } + this.emitParallelAssignments( + singleFields.map((f) => ({ + expr: f.expr, + assign: (v: string) => `__defInput[${jsStr(f.field)}] = ${v};`, + })), + ); - for (const wire of outputWires) { - const pathKey = wire.to.path.join("."); - if (emitted.has(pathKey)) continue; - emitted.add(pathKey); + // Compile define body in a child scope + this.emit("const __defOutput = {};"); + const defScope = scope.child(); - const group = groups.get(pathKey)!; - if (group.length < 2) { - reordered.push(...group); - continue; - } + // Register marker for Define-type refs so compileRefExpr can resolve them + defScope.set("__defineInput_" + defineName, { + kind: "input", + jsExpr: "__defInput", + }); - const ranked = group.map((candidate, index) => ({ - wire: candidate, - index, - cost: this.classifyOverdefinitionWire(candidate), - })); - ranked.sort((left, right) => { - if (left.cost !== right.cost) { - changed = true; - return left.cost - right.cost; + // Register define body handles + for (const stmt of defineDef.body) { + if (stmt.kind === "with") { + const h = stmt.binding; + switch (h.kind) { + case "input": + defScope.set(h.handle, { kind: "input", jsExpr: "__defInput" }); + break; + case "output": + defScope.set(h.handle, { + kind: "output", + jsExpr: "__defOutput", + }); + break; + case "context": + defScope.set(h.handle, { kind: "context", jsExpr: "context" }); + break; + case "const": + defScope.set(h.handle, { kind: "const", jsExpr: "__consts" }); + break; + case "tool": { + const toolId = safeId(h.handle) + "_" + this.toolGetterCount++; + const toolFnVar = `__toolFn_${toolId}`; + defScope.set(h.handle, { + kind: "tool", + jsExpr: toolFnVar, + toolName: h.name, + toolFnExpr: toolFnVar, + memoize: h.memoize === true || undefined, + }); + // Resolve fn through ToolDef extends chain + const innerToolDef = this.resolveToolDef(h.name); + const fnName = innerToolDef?.fn ?? h.name; + this.emit(`const ${toolFnVar} = tools[${jsStr(fnName)}];`); + break; + } + case "define": + defScope.set(h.handle, { + kind: "define", + jsExpr: `__define_${safeId(h.handle)}`, + defineName: h.name, + }); + break; + } } - return left.index - right.index; - }); - reordered.push(...ranked.map((entry) => entry.wire)); - } + } - return changed ? reordered : outputWires; - } + // The define body is compiled like a mini-bridge, but with + // __defInput as input and __defOutput as output. + // We temporarily override the bridge's type/field context for + // target resolution. + this.compileDefineBody(defineDef.body, defScope, "__defOutput"); + + this.emit("return __defOutput;"); + this.popIndent(); + this.emit(`}, ${jsStr(defineKey)});`); - private classifyOverdefinitionWire( - wire: Wire, - visited = new Set(), - ): number { - // Optimistic cost — cost of the first source only. - return this.computeExprCost(wire.sources[0]!.expr, visited); + // Update the scope binding to use this getter + binding.jsExpr = getterName; + } } /** - * Pessimistic wire cost — sum of all source expression costs plus catch. - * Represents worst-case cost when all fallback sources fire. + * Compile a define body — like compileBody but uses the define's + * input/output handles instead of the bridge's. */ - private computeWireCost(wire: Wire, visited: Set): number { - let cost = 0; - for (const source of wire.sources) { - cost += this.computeExprCost(source.expr, visited); - } - if (catchRef(wire)) { - cost += this.computeRefCost(catchRef(wire)!, visited); - } - return cost; - } + private compileDefineBody( + body: Statement[], + scope: ScopeChain, + outputVar: string, + ) { + // Collect tool inputs within the define body + const toolInputs = this.collectDefineToolInputs(body, scope); + + // Emit tool getters for the define scope + this.emitToolGetters(toolInputs, scope); + + // Compile wires targeting define output + for (const stmt of body) { + if (stmt.kind === "wire") { + // Skip tool input wires (handled by tool getters) + const handleName = this.findDefineTargetHandle( + stmt.target, + scope, + body, + ); + if (handleName) { + const binding = scope.get(handleName); + if (binding?.kind === "tool") continue; + } - private computeExprCost(expr: Expression, visited: Set): number { - switch (expr.type) { - case "literal": - case "control": - return 0; - case "ref": - return this.computeRefCost(expr.ref, visited); - case "ternary": - return Math.max( - this.computeExprCost(expr.cond, visited), - this.computeExprCost(expr.then, visited), - this.computeExprCost(expr.else, visited), + // Skip define input wires + if (stmt.target.module.startsWith("__define_")) continue; + + // This is an output wire in the define + const targetExpr = this.compileDefineTargetRef( + stmt.target, + scope, + outputVar, ); - case "and": - case "or": - return Math.max( - this.computeExprCost(expr.left, visited), - this.computeExprCost(expr.right, visited), + const valueExpr = this.compileSourceChainWithLoc( + stmt.sources, + stmt.catch, + scope, ); + this.emit(`${targetExpr} = ${valueExpr};`); + } } } - private computeRefCost(ref: NodeRef, visited: Set): number { - if (ref.element) return 0; - // Self-module input/context/const — free - if ( - ref.module === SELF_MODULE && - ((ref.type === this.bridge.type && ref.field === this.bridge.field) || - (ref.type === "Context" && ref.field === "context") || - (ref.type === "Const" && ref.field === "const")) - ) { - return 0; - } + /** + * Collect tool input wires within a define body. + */ + private collectDefineToolInputs( + body: Statement[], + scope: ScopeChain, + ): Map { + const map = new Map(); - const key = refTrunkKey(ref); - if (visited.has(key)) return Infinity; - visited.add(key); + for (const stmt of body) { + if (stmt.kind !== "wire") continue; + const handleName = this.findDefineTargetHandle(stmt.target, scope, body); + if (!handleName) continue; - // Define — recursive, cheapest incoming wire wins - if (ref.module.startsWith("__define_")) { - const incoming = this.bridge.wires.filter( - (wire) => refTrunkKey(wire.to) === key, - ); - let best = Infinity; - for (const wire of incoming) { - best = Math.min(best, this.computeWireCost(wire, visited)); - } - return best === Infinity ? 2 : best; - } + const binding = scope.get(handleName); + if (!binding || binding.kind !== "tool") continue; - // Local alias — recursive, cheapest incoming wire wins - if (ref.module === "__local") { - const incoming = this.bridge.wires.filter( - (wire) => refTrunkKey(wire.to) === key, - ); - let best = Infinity; - for (const wire of incoming) { - best = Math.min(best, this.computeWireCost(wire, visited)); + let entries = map.get(handleName); + if (!entries) { + entries = []; + map.set(handleName, entries); } - return best === Infinity ? 2 : best; + entries.push({ field: stmt.target.path.join("."), stmt }); } - // External tool — compiler has no metadata, default to async cost - return 2; + return map; } /** - * Build the body of a `.map()` callback from element wires. - * - * Handles nested array iterators: if an element wire targets a field that - * is itself an array iterator, a nested `.map()` is generated. + * Find which handle a target node ref matches in a define body. + * Similar to findTargetHandle but uses the define's handles from body. */ - private buildElementBody( - elemWires: Wire[], - arrayIterators: Record, - depth: number, - indent: number, - ): string { - const elVar = `_el${depth}`; - - // Separate into scalar element wires and sub-array source/element wires - interface TreeNode { - expr?: string; - children: Map; - } - const tree: TreeNode = { children: new Map() }; - - // Group wires by whether they target a sub-array field - const subArraySources = new Map(); // field → source wire - const subArrayElements = new Map(); // field → element wires - - for (const ew of elemWires) { - const topField = ew.to.path[0]!; - - if ( - topField in arrayIterators && - ew.to.path.length === 1 && - !subArraySources.has(topField) - ) { - // This is the source wire for a sub-array (e.g., .legs <- c.sections[]) - subArraySources.set(topField, ew); - } else if (topField in arrayIterators && ew.to.path.length > 1) { - // This is an element wire for a sub-array (e.g., .legs.trainName <- s.name) - const arr = subArrayElements.get(topField) ?? []; - arr.push(ew); - subArrayElements.set(topField, arr); - } else { - // Regular scalar element wire — add to tree using full path - const path = ew.to.path; - let current = tree; - for (let i = 0; i < path.length - 1; i++) { - const seg = path[i]!; - if (!current.children.has(seg)) { - current.children.set(seg, { children: new Map() }); + private findDefineTargetHandle( + target: NodeRef, + _scope: ScopeChain, + body: Statement[], + ): string | undefined { + let instanceCount = 0; + for (const stmt of body) { + if (stmt.kind !== "with") continue; + const h = stmt.binding; + if (h.kind === "tool") { + const refName = + target.module === SELF_MODULE + ? target.field + : `${target.module}.${target.field}`; + const matches = refName === h.name; + if (matches) { + instanceCount++; + if (!target.instance || instanceCount === target.instance) { + return h.handle; } - current = current.children.get(seg)!; } - const lastSeg = path[path.length - 1]!; - if (!current.children.has(lastSeg)) { - current.children.set(lastSeg, { children: new Map() }); - } - current.children.get(lastSeg)!.expr = this.elementWireToExpr(ew, elVar); - } - } - - // Handle sub-array fields - for (const [field, sourceW] of subArraySources) { - const innerElems = subArrayElements.get(field) ?? []; - if (innerElems.length === 0) continue; - - // Shift inner element paths: remove the first segment (the sub-array field name) - const shifted: Wire[] = innerElems.map((w) => ({ - ...w, - to: { ...w.to, path: w.to.path.slice(1) }, - })); - - const srcExpr = this.elementWireToExpr(sourceW, elVar); - const innerElVar = `_el${depth + 1}`; - const innerArrayIterators = this.relativeArrayIterators( - arrayIterators, - field, - ); - const innerCf = detectControlFlow(shifted); - // Check if inner loop needs async (element-scoped tools or catch fallbacks) - const innerNeedsAsync = shifted.some((w) => this.wireNeedsAwait(w)); - let mapExpr: string; - if (innerNeedsAsync) { - mapExpr = this.withElementLocalVarScope(() => { - const innerCurrentScope = this.filterCurrentElementWires( - shifted, - innerArrayIterators, - ); - const innerPreambleLines: string[] = []; - this.collectElementPreamble( - innerCurrentScope, - innerElVar, - innerPreambleLines, - ); - const innerBody = innerCf - ? this.buildElementBodyWithControlFlow( - shifted, - innerArrayIterators, - depth + 1, - indent + 4, - innerCf.kind === "continue" ? "for-continue" : "break", - ) - : `${" ".repeat(indent + 4)}_result.push(${this.buildElementBody(shifted, innerArrayIterators, depth + 1, indent + 4)});`; - const innerPreamble = innerPreambleLines - .map((line) => `${" ".repeat(indent + 4)}${line}`) - .join("\n"); - return `await (async () => { const _src = ${srcExpr}; if (!Array.isArray(_src)) return null; const _result = []; __loop${depth + 1}: for (const ${innerElVar} of _src) {\n${" ".repeat(indent + 4)}try {\n${innerPreamble}${innerPreamble ? "\n" : ""}${innerBody}\n${" ".repeat(indent + 4)}} catch (_ctrl) { if (__isLoopCtrl(_ctrl)) { if (_ctrl.levels > 1) throw __nextLoopCtrl(_ctrl); if (_ctrl.__bridgeControl === "break") break; continue; } throw _ctrl; }\n${" ".repeat(indent + 2)}} return _result; })()`; - }); - } else if (innerCf?.kind === "continue" && innerCf.levels === 1) { - const cfBody = this.buildElementBodyWithControlFlow( - shifted, - innerArrayIterators, - depth + 1, - indent + 2, - "continue", - ); - mapExpr = `((__s) => Array.isArray(__s) ? __s.flatMap((${innerElVar}) => {\n${cfBody}\n${" ".repeat(indent + 2)}}) ?? null : null)(${srcExpr})`; - } else if (innerCf?.kind === "break" || innerCf?.kind === "continue") { - const cfBody = this.buildElementBodyWithControlFlow( - shifted, - innerArrayIterators, - depth + 1, - indent + 4, - innerCf.kind === "continue" ? "for-continue" : "break", - ); - mapExpr = `(() => { const _src = ${srcExpr}; if (!Array.isArray(_src)) return null; const _result = []; __loop${depth + 1}: for (const ${innerElVar} of _src) {\n${" ".repeat(indent + 4)}try {\n${cfBody}\n${" ".repeat(indent + 4)}} catch (_ctrl) { if (__isLoopCtrl(_ctrl)) { if (_ctrl.levels > 1) throw __nextLoopCtrl(_ctrl); if (_ctrl.__bridgeControl === "break") break; continue; } throw _ctrl; }\n${" ".repeat(indent + 2)}} return _result; })()`; - } else { - const innerBody = this.buildElementBody( - shifted, - innerArrayIterators, - depth + 1, - indent + 2, - ); - mapExpr = `((__s) => Array.isArray(__s) ? __s.map((${innerElVar}) => (${innerBody})) ?? null : null)(${srcExpr})`; - } - - if (!tree.children.has(field)) { - tree.children.set(field, { children: new Map() }); } - tree.children.get(field)!.expr = mapExpr; } - - return this.serializeOutputTree(tree, indent); + return undefined; } /** - * Build the body of a loop/flatMap callback with break/continue support. - * - * For "continue": generates flatMap body that returns [] to skip elements - * For "break": generates loop body that pushes to _result and breaks + * Compile a target ref in a define body's output context. */ - private buildElementBodyWithControlFlow( - elemWires: Wire[], - arrayIterators: Record, - depth: number, - indent: number, - mode: "break" | "continue" | "for-continue", + private compileDefineTargetRef( + target: NodeRef, + _scope: ScopeChain, + outputVar: string, ): string { - const elVar = `_el${depth}`; - const pad = " ".repeat(indent); - - // Find the wire with control flow at the current depth level only - // (not sub-array element wires) - const controlWire = elemWires.find( - (w) => - w.to.path.length === 1 && - (fallbacks(w).some((fb) => fb.expr.type === "control") || - hasCatchControl(w)), - ); - - if (!controlWire || !isPull(controlWire)) { - // No control flow found — fall back to simple body - const body = this.buildElementBody( - elemWires, - arrayIterators, - depth, - indent, - ); - if (mode === "continue") { - return `${pad} return [${body}];`; - } - return `${pad} _result.push(${body});`; + // All output wires in a define body target the define's output + const path = target.path; + if (path.length > 1) { + this.emitEnsurePath(outputVar, path.slice(0, -1)); } + return `${outputVar}${path.map((p) => `[${jsStr(p)}]`).join("")}`; + } - // Build the check expression using elementWireToExpr to include fallbacks - const checkExpr = this.elementWireToExpr(controlWire, elVar); - - // Determine the check type - const isNullish = fallbacks(controlWire).some( - (fb) => fb.gate === "nullish" && fb.expr.type === "control", - ); - const ctrlFb = fallbacks(controlWire).find( - (fb) => fb.expr.type === "control", - ); - const ctrlFromFallback = ctrlFb - ? (ctrlFb.expr as ControlExpr).control - : undefined; - const ctrl = ctrlFromFallback ?? catchControl(controlWire); - const controlKind = ctrl?.kind === "continue" ? "continue" : "break"; - const controlLevels = - ctrl && (ctrl.kind === "continue" || ctrl.kind === "break") - ? Math.max(1, Number(ctrl.levels) || 1) - : 1; - const controlStatement = - controlLevels > 1 - ? `throw { __bridgeControl: ${JSON.stringify(controlKind)}, levels: ${controlLevels} };` - : controlKind === "continue" - ? "continue;" - : "break;"; - - if (mode === "continue") { - if (isNullish) { - return `${pad} if (${checkExpr} == null) return [];\n${pad} return [${this.buildElementBody(elemWires, arrayIterators, depth, indent)}];`; - } - // falsy fallback control - return `${pad} if (!${checkExpr}) return [];\n${pad} return [${this.buildElementBody(elemWires, arrayIterators, depth, indent)}];`; + /** + * Find which handle a NodeRef targets by matching module/type/field against scope bindings. + */ + private findTargetHandle( + target: NodeRef, + _scope: ScopeChain, + ): string | undefined { + // Tool input wires have target refs like: + // { module: "_", type: "Tools", field: "constants", path: ["greeting"], instance: 1 } + // or for module-scoped tools: + // { module: "test.async", type: "SyncAsync", field: "multitool", path: [], instance: 1 } + // + // Output wires targeting the bridge itself have type === bridgeType (e.g. "Query"). + // We must NOT match output wires as tool input wires. + // + // For self-module tools, target.type is "Tools" (from the parser). + // For module-scoped tools, target.module !== SELF_MODULE. + if (target.module === SELF_MODULE && target.type !== "Tools") { + return undefined; } - // mode === "for-continue" — same as break but uses native 'continue' keyword - if (mode === "for-continue") { - if (isNullish) { - return `${pad} if (${checkExpr} == null) ${controlStatement}\n${pad} _result.push(${this.buildElementBody(elemWires, arrayIterators, depth, indent)});`; + let instanceCount = 0; + for (const h of this.bridge.handles) { + if (h.kind === "tool") { + const refName = + target.module === SELF_MODULE + ? target.field + : `${target.module}.${target.field}`; + const matches = refName === h.name; + if (matches) { + instanceCount++; + if (!target.instance || instanceCount === target.instance) { + return h.handle; + } + } } - return `${pad} if (!${checkExpr}) ${controlStatement}\n${pad} _result.push(${this.buildElementBody(elemWires, arrayIterators, depth, indent)});`; } - // mode === "break" - if (isNullish) { - return `${pad} if (${checkExpr} == null) ${controlStatement}\n${pad} _result.push(${this.buildElementBody(elemWires, arrayIterators, depth, indent)});`; - } - return `${pad} if (!${checkExpr}) ${controlStatement}\n${pad} _result.push(${this.buildElementBody(elemWires, arrayIterators, depth, indent)});`; + return undefined; } - // ── Wire → expression ──────────────────────────────────────────────────── + // ── Tool getter emission ────────────────────────────────────────────── - /** Convert a wire to a JavaScript expression string. */ - wireToExpr(w: Wire): string { - // Constant wire - if (isLit(w)) return emitCoerced(wVal(w)); + private emitToolGetters( + toolInputs: Map, + scope: ScopeChain, + ) { + // Two-pass approach: first register all getter names so cross-references + // between tool getters resolve to memoized getters (not raw tool fns). + const entries: { + handleName: string; + inputs: { field: string; stmt: WireStatement }[]; + binding: ScopeBinding; + getterName: string; + memoKey: string; + }[] = []; + for (const [handleName, inputs] of toolInputs) { + const binding = scope.get(handleName); + if (!binding || binding.kind !== "tool") continue; + + const getterId = safeId(handleName) + "_" + this.toolGetterCount++; + const getterName = `__get_${getterId}`; + const memoKey = this.toolNodeKey(handleName, binding); + + // Update scope binding to getter BEFORE compiling any getter body so + // cross-tool references go through memoized getters (with tracing). + binding.jsExpr = getterName; + binding.instanceKey = getterId; + + // For memoized tools inside a loop, allocate a function-scoped Map. + // The Map is injected at function scope via post-processing so it + // persists across all loop iterations (matching runtime semantics). + if (binding.memoize && this.iteratorStack.length > 0) { + const mapVar = `__memoMap_${this.memoMapCounter++}`; + this.memoMapForGetter.set(getterName, mapVar); + this.memoMapDeclarations.push(mapVar); + } + + entries.push({ handleName, inputs, binding, getterName, memoKey }); + } + + // Second pass: emit getter bodies (all bindings already point to getters). + for (const { + handleName, + inputs, + binding, + getterName, + memoKey, + } of entries) { + const useMemoCache = this.getMemoMapVar(getterName); + if (useMemoCache) { + // Memoized-by-input: the getter is called every iteration and deduplicates by input key + this.emit(`const ${getterName} = async () => {`); + } else { + this.emit(`const ${getterName} = __memoize(async () => {`); + } + this.pushIndent(); - // Pull wire - if (isPull(w)) { - let expr = this.wrapExprWithLoc(this.refToExpr(wRef(w)), wRefLoc(w)); - expr = this.applyFallbacks(w, expr); - return this.wrapWireExpr(w, expr); - } + // Check for root wire (empty field) — passes entire value as tool input + const rootEntries = inputs.filter((e) => e.field === ""); + const fieldEntries = inputs.filter((e) => e.field !== ""); - // Conditional wire (ternary) - if (isTern(w)) { - const condExpr = this.wrapExprWithLoc( - this.refToExpr(eRef(wTern(w).cond)), - wTern(w).condLoc ?? w.loc, - ); - const thenExpr = - (wTern(w).then as RefExpr).ref !== undefined - ? this.wrapExprWithLoc( - this.lazyRefToExpr((wTern(w).then as RefExpr).ref), - wTern(w).thenLoc, - ) - : (wTern(w).then as LitExpr).value !== undefined - ? emitCoerced((wTern(w).then as LitExpr).value) - : "undefined"; - const elseExpr = - (wTern(w).else as RefExpr).ref !== undefined - ? this.wrapExprWithLoc( - this.lazyRefToExpr((wTern(w).else as RefExpr).ref), - wTern(w).elseLoc, - ) - : (wTern(w).else as LitExpr).value !== undefined - ? emitCoerced((wTern(w).else as LitExpr).value) - : "undefined"; - let expr = `(${condExpr} ? ${thenExpr} : ${elseExpr})`; - expr = this.applyFallbacks(w, expr); - return this.wrapWireExpr(w, expr); - } + // Group field entries by field name for overdefinition handling + const fieldGroupMap = new Map(); + for (const { field, stmt } of fieldEntries) { + let group = fieldGroupMap.get(field); + if (!group) { + group = []; + fieldGroupMap.set(field, group); + } + group.push(stmt); + } + const fieldGroups = Array.from(fieldGroupMap, ([field, stmts]) => ({ + field, + stmts, + })); - // Logical AND - if (isAndW(w)) { - const ao = wAndOr(w); - const leftRef = eRef(ao.left); - const rightRef = ao.right.type === "ref" ? eRef(ao.right) : undefined; - const rightValue = - ao.right.type === "literal" ? eVal(ao.right) : undefined; - const left = this.refToExpr(leftRef); - let expr: string; - if (rightRef) { - let rightExpr = this.lazyRefToExpr(rightRef); - if (ao.rightSafe && this.ternaryOnlyTools.has(refTrunkKey(rightRef))) { - rightExpr = `await (async () => { try { return ${rightExpr}; } catch (_e) { if (_e?.name === "BridgePanicError" || _e?.name === "BridgeAbortError") throw _e; return undefined; } })()`; + if (rootEntries.length > 0) { + // For overdefined root entries, use the cheapest + const rootStmts = rootEntries.map((e) => e.stmt); + let rootExpr: string; + if (rootStmts.length > 1) { + const ranked = rootStmts.map((s, i) => ({ + stmt: s, + index: i, + cost: this.computeExprCost(s.sources[0]!.expr, scope), + })); + ranked.sort((a, b) => + a.cost !== b.cost ? a.cost - b.cost : a.index - b.index, + ); + rootExpr = this.compileSourceChainWithLoc( + ranked[0]!.stmt.sources, + ranked[0]!.stmt.catch, + scope, + ); + } else { + rootExpr = this.compileSourceChainWithLoc( + rootStmts[0]!.sources, + rootStmts[0]!.catch, + scope, + ); } - expr = `(Boolean(${left}) && Boolean(${rightExpr}))`; - } else if (rightValue !== undefined) - expr = `(Boolean(${left}) && Boolean(${emitCoerced(rightValue)}))`; - else expr = `Boolean(${left})`; - expr = this.applyFallbacks(w, expr); - return this.wrapWireExpr(w, expr); - } + if (fieldGroups.length > 0) { + this.emit(`const __toolInput = { ...${rootExpr} };`); + } else { + // Match runtime setPath(input, [], value) behavior: + // non-null objects are spread, null/undefined/primitives become {} + this.emit(`const __rv = ${rootExpr};`); + this.emit( + `const __toolInput = __rv != null && typeof __rv === 'object' && !Array.isArray(__rv) ? __rv : {};`, + ); + } + } else { + this.emit("const __toolInput = {};"); + } - // Logical OR - if (isOrW(w)) { - const ao2 = wAndOr(w); - const leftRef2 = eRef(ao2.left); - const rightRef2 = ao2.right.type === "ref" ? eRef(ao2.right) : undefined; - const rightValue2 = - ao2.right.type === "literal" ? eVal(ao2.right) : undefined; - const left2 = this.refToExpr(leftRef2); - let expr: string; - if (rightRef2) { - let rightExpr = this.lazyRefToExpr(rightRef2); - if ( - ao2.rightSafe && - this.ternaryOnlyTools.has(refTrunkKey(rightRef2)) - ) { - rightExpr = `await (async () => { try { return ${rightExpr}; } catch (_e) { if (_e?.name === "BridgePanicError" || _e?.name === "BridgeAbortError") throw _e; return undefined; } })()`; + // Emit ToolDef self-wire defaults (before bridge wires override) + if (binding.toolName) { + this.emitToolDefDefaults(binding.toolName, scope); + } + + // Separate overdefined from single-source fields + const singleFields: { field: string; expr: string; locExpr?: string }[] = + []; + for (const { field, stmts } of fieldGroups) { + // Prototype pollution guard for tool input fields + if (UNSAFE_KEYS.has(field)) { + this.emit( + `throw new Error(${jsStr(`Unsafe assignment key: ${field}`)});`, + ); + continue; } - expr = `(Boolean(${left2}) || Boolean(${rightExpr}))`; - } else if (rightValue2 !== undefined) - expr = `(Boolean(${left2}) || Boolean(${emitCoerced(rightValue2)}))`; - else expr = `Boolean(${left2})`; - expr = this.applyFallbacks(w, expr); - return this.wrapWireExpr(w, expr); - } + if (stmts.length === 1) { + const stmt0 = stmts[0]!; + const hasLoc = stmt0.sources.some((s) => s.expr.loc); + const locExpr = + hasLoc && !stmt0.catch + ? stmt0.sources.length === 1 && stmt0.sources[0]!.expr.loc + ? jsLoc(stmt0.sources[0]!.expr.loc) + : undefined + : undefined; + const valueExpr = locExpr + ? this.compileSourceChain(stmt0.sources, stmt0.catch, scope) + : this.compileSourceChainWithLoc(stmt0.sources, stmt0.catch, scope); + singleFields.push({ field, expr: valueExpr, locExpr }); + } else { + // Overdefined — sort by cost and emit null-coalescing block + const ranked = stmts.map((s, i) => ({ + stmt: s, + index: i, + cost: this.computeExprCost(s.sources[0]!.expr, scope), + })); + ranked.sort((a, b) => + a.cost !== b.cost ? a.cost - b.cost : a.index - b.index, + ); - return "undefined"; - } + const errVar = `__ti_${safeId(field)}_err`; - /** Convert an element wire (inside array mapping) to an expression. */ - private elementWireToExpr(w: Wire, elVar = "_el0"): string { - const prevElVar = this.currentElVar; - this.elementVarStack.push(elVar); - this.currentElVar = elVar; - try { - return this.wrapWireExpr(w, this._elementWireToExprInner(w, elVar)); - } finally { - this.elementVarStack.pop(); - this.currentElVar = prevElVar; - } - } + const firstExpr = this.compileSourceChainWithLoc( + ranked[0]!.stmt.sources, + ranked[0]!.stmt.catch, + scope, + ); + if (ranked[0]!.cost === 0) { + this.emit(`__toolInput[${jsStr(field)}] = ${firstExpr};`); + this.emit(`let ${errVar};`); + } else { + this.emit(`let ${errVar};`); + this.emit( + `try { __toolInput[${jsStr(field)}] = ${firstExpr}; } catch (_e) { ${errVar} = _e; }`, + ); + } - private wrapWireExpr(w: Wire, expr: string): string { - const loc = this.serializeLoc(w.loc); - if (expr.includes("await ")) { - return `await __wrapBridgeErrorAsync(async () => (${expr}), ${loc})`; - } - return `__wrapBridgeError(() => (${expr}), ${loc})`; - } + for (let i = 1; i < ranked.length; i++) { + const nextExpr = this.compileSourceChainWithLoc( + ranked[i]!.stmt.sources, + ranked[i]!.stmt.catch, + scope, + ); + this.emit(`if (__toolInput[${jsStr(field)}] == null) {`); + this.pushIndent(); + this.emit( + `try { __toolInput[${jsStr(field)}] = ${nextExpr}; if (__toolInput[${jsStr(field)}] != null) ${errVar} = undefined; } catch (_e) { ${errVar} = _e; }`, + ); + this.popIndent(); + this.emit("}"); + } - /** - * Find the source location of the closest wire that pulls FROM a tool. - * Used to attach `bridgeLoc` to tool execution errors. - */ - private findPullingWireLoc(trunkKey: string): SourceLocation | undefined { - for (const w of this.bridge.wires) { - if (isPull(w)) { - const srcKey = refTrunkKey(wRef(w)); - if (srcKey === trunkKey) return wRefLoc(w) ?? w.loc; + this.emit( + `if (__toolInput[${jsStr(field)}] == null && ${errVar}) throw ${errVar};`, + ); + } } - if (isTern(w)) { - if (refTrunkKey(eRef(wTern(w).cond)) === trunkKey) - return wTern(w).condLoc ?? w.loc; - if ( - (wTern(w).then as RefExpr).ref && - refTrunkKey((wTern(w).then as RefExpr).ref) === trunkKey - ) - return wTern(w).thenLoc ?? w.loc; - if ( - (wTern(w).else as RefExpr).ref && - refTrunkKey((wTern(w).else as RefExpr).ref) === trunkKey - ) - return wTern(w).elseLoc ?? w.loc; + + // Emit single-source fields — parallelize async ones via Promise.all + // For dotted field paths (from scope blocks), ensure parent objects exist + for (const f of singleFields) { + if (f.field.includes(".")) { + const parts = f.field.split("."); + for (let i = 0; i < parts.length - 1; i++) { + const parentPath = parts + .slice(0, i + 1) + .map((p) => `[${jsStr(p)}]`) + .join(""); + this.emit(`__toolInput${parentPath} ??= {};`); + } + } } - } - return undefined; - } + this.emitParallelAssignments( + singleFields.map((f) => ({ + expr: f.expr, + locExpr: f.locExpr, + assign: (v: string) => { + if (f.field.includes(".")) { + const parts = f.field.split("."); + const pathExpr = parts.map((p) => `[${jsStr(p)}]`).join(""); + return `__toolInput${pathExpr} = ${v};`; + } + return `__toolInput[${jsStr(f.field)}] = ${v};`; + }, + })), + ); - private serializeLoc(loc?: SourceLocation): string { - return JSON.stringify(loc ?? null); - } + const toolFnExpr = this.resolveToolFnExpr(handleName, scope); + const toolName = binding.toolName ?? handleName; + const toolDef = binding.toolName + ? this.resolveToolDef(binding.toolName) + : undefined; + const fnName = toolDef?.fn ?? toolName; + + // Call tool with tracing support (respecting trace:false on tool metadata) + if (useMemoCache) { + // Input-keyed memoization: check scoped cache before calling tool + const mapVar = useMemoCache; + this.emit(`const __ck = __stableKey(__toolInput);`); + this.emit(`if (${mapVar}.has(__ck)) return ${mapVar}.get(__ck);`); + this.emit(`const __p = (async () => {`); + this.pushIndent(); + } + this.emit("__checkAbort();"); + this.emit( + `if (typeof ${toolFnExpr} !== 'function') throw new Error('No tool found for "${toolName}"');`, + ); + this.emit( + `const __doTrace = __trace && (!${toolFnExpr}?.bridge || ${toolFnExpr}.bridge.trace !== false);`, + ); + this.emit("const __start = __doTrace ? performance.now() : 0;"); + this.emit("let __result;"); + this.emit("try {"); + this.pushIndent(); + + if (this.currentBatchQueue) { + // Inside a concurrent (Promise.all) loop — batch tools need __callBatched + // For batch tools, tracing is handled by the __callBatched microtask flush. + this.emit(`if (${toolFnExpr}?.bridge?.batch) {`); + this.pushIndent(); + this.emit( + `__result = await __callBatched(${toolFnExpr}, __toolInput, ${this.currentBatchQueue}, ${jsStr(toolName)}, ${jsStr(fnName)}, __doTrace);`, + ); + this.popIndent(); + this.emit("} else {"); + this.pushIndent(); + this.emit(`let __raw = ${toolFnExpr}(__toolInput, __toolCtx);`); + this.emit( + `if (${toolFnExpr}?.bridge?.sync && __raw && typeof __raw.then === 'function') throw new Error('Tool "${fnName}" declared {sync:true} but returned a Promise');`, + ); + this.emit( + `if (__timeoutMs > 0 && __raw && typeof __raw.then === 'function') { let __timer; const __tout = new Promise((_, rej) => { __timer = setTimeout(() => rej(new (__TimeoutError || Error)(${jsStr(fnName)}, __timeoutMs)), __timeoutMs); }); __raw = Promise.race([__raw, __tout]).finally(() => clearTimeout(__timer)); }`, + ); + this.emit( + "__result = (__raw && typeof __raw.then === 'function') ? await __raw : __raw;", + ); + this.emit( + `if (__doTrace) __trace(${jsStr(toolName)}, ${jsStr(fnName)}, __start, performance.now(), __toolInput, __result, null);`, + ); + this.popIndent(); + this.emit("}"); + } else { + // Sync tool validation: check if tool declared {sync:true} but returned a Promise + this.emit(`let __raw = ${toolFnExpr}(__toolInput, __toolCtx);`); + this.emit( + `if (${toolFnExpr}?.bridge?.sync && __raw && typeof __raw.then === 'function') throw new Error('Tool "${fnName}" declared {sync:true} but returned a Promise');`, + ); + this.emit( + `if (__timeoutMs > 0 && __raw && typeof __raw.then === 'function') { let __timer; const __tout = new Promise((_, rej) => { __timer = setTimeout(() => rej(new (__TimeoutError || Error)(${jsStr(fnName)}, __timeoutMs)), __timeoutMs); }); __raw = Promise.race([__raw, __tout]).finally(() => clearTimeout(__timer)); }`, + ); + this.emit( + "__result = (__raw && typeof __raw.then === 'function') ? await __raw : __raw;", + ); + this.emit( + `if (__doTrace) __trace(${jsStr(toolName)}, ${jsStr(fnName)}, __start, performance.now(), __toolInput, __result, null);`, + ); + } - private wrapExprWithLoc(expr: string, loc?: SourceLocation): string { - if (!loc) return expr; - const serializedLoc = this.serializeLoc(loc); - if (expr.includes("await ")) { - return `await __wrapBridgeErrorAsync(async () => (${expr}), ${serializedLoc})`; + this.popIndent(); + this.emit("} catch (__err) {"); + this.pushIndent(); + if (this.currentBatchQueue) { + // Only trace errors for non-batch tools; batch tool error tracing is in __callBatched + this.emit( + `if (__doTrace && !${toolFnExpr}?.bridge?.batch) __trace(${jsStr(toolName)}, ${jsStr(fnName)}, __start, performance.now(), __toolInput, null, __err);`, + ); + } else { + this.emit( + `if (__doTrace) __trace(${jsStr(toolName)}, ${jsStr(fnName)}, __start, performance.now(), __toolInput, null, __err);`, + ); + } + // onError — return fallback instead of rethrowing + if (toolDef?.onError) { + if ("value" in toolDef.onError) { + this.emit(`__result = ${toolDef.onError.value};`); + } else if ("source" in toolDef.onError) { + const parts = toolDef.onError.source.split("."); + const src = parts[0]!; + const path = parts.slice(1); + const handle = toolDef.handles.find((h) => h.handle === src); + if (handle?.kind === "context") { + const pathExpr = + path.length > 0 + ? path.map((p) => `?.[${jsStr(p)}]`).join("") + : ""; + this.emit(`__result = context${pathExpr};`); + } else { + this.emit("throw __err;"); + } + } else { + this.emit("throw __err;"); + } + } else { + this.emit("throw __err;"); + } + this.popIndent(); + this.emit("}"); + if (useMemoCache) { + this.emit("return __result;"); + this.popIndent(); + this.emit("})();"); + this.emit(`${useMemoCache}.set(__ck, __p);`); + this.emit("return __p;"); + } else { + this.emit("return __result;"); + } + this.popIndent(); + if (useMemoCache) { + this.emit("};"); + } else { + this.emit(`}, ${jsStr(memoKey)});`); + } } - return `__wrapBridgeError(() => (${expr}), ${serializedLoc})`; } - private refToElementExpr(ref: NodeRef): string { - const depth = ref.elementDepth ?? 0; - const stackIndex = this.elementVarStack.length - 1 - depth; - const elVar = - stackIndex >= 0 ? this.elementVarStack[stackIndex] : this.currentElVar; - if (!elVar) { - throw new Error(`Missing element variable for ${JSON.stringify(ref)}`); + private resolveToolFnExpr(handleName: string, scope: ScopeChain): string { + const binding = scope.get(handleName); + if (!binding || binding.kind !== "tool") { + return `tools[${jsStr(handleName)}]`; } - if (ref.path.length === 0) return elVar; - return this.appendPathExpr(elVar, ref, true); + // Use the cached __toolFn_ variable (resolves extends chain once at declaration) + if (binding.toolFnExpr) return binding.toolFnExpr; + const toolDef = binding.toolName + ? this.resolveToolDef(binding.toolName) + : undefined; + const fnName = toolDef?.fn ?? binding.toolName ?? handleName; + return `tools[${jsStr(fnName)}]`; } - private _elementWireToExprInner(w: Wire, elVar: string): string { - if (isLit(w)) return emitCoerced(wVal(w)); - - // Handle ternary (conditional) wires inside array mapping - if (isTern(w)) { - const condRef = eRef(wTern(w).cond); - let condExpr: string; - if (condRef.element) { - condExpr = this.refToElementExpr(condRef); - } else { - const condKey = refTrunkKey(condRef); - if (this.elementScopedTools.has(condKey)) { - condExpr = this.buildInlineToolExpr(condKey, elVar); - if (condRef.path.length > 0) { - condExpr = this.appendPathExpr(`(${condExpr})`, condRef); - } - } else { - condExpr = this.refToExpr(condRef); - } - } - condExpr = this.wrapExprWithLoc(condExpr, wTern(w).condLoc ?? w.loc); - const resolveBranch = ( - ref: NodeRef | undefined, - val: string | undefined, - loc: SourceLocation | undefined, - ): string => { - if (ref !== undefined) { - if (ref.element) { - return this.wrapExprWithLoc(this.refToElementExpr(ref), loc); - } - const branchKey = refTrunkKey(ref); - if (this.elementScopedTools.has(branchKey)) { - let e = this.buildInlineToolExpr(branchKey, elVar); - if (ref.path.length > 0) e = this.appendPathExpr(`(${e})`, ref); - return this.wrapExprWithLoc(e, loc); + /** + * Emit ToolDef self-wire defaults into __toolInput. + * Compiles wires from the ToolDef body where instance==null (config wires) + * and scope blocks into properties on __toolInput. + * Also handles inner tool dependencies (instance!=null wires). + */ + private emitToolDefDefaults(toolName: string, parentScope: ScopeChain) { + const toolDef = this.resolveToolDef(toolName); + if (!toolDef || toolDef.body.length === 0) return; + + // Build a child scope for ToolDef body handles (e.g. with const, with context, with innerTool) + const defScope = parentScope.child(); + for (const stmt of toolDef.body) { + if (stmt.kind === "with") { + if (stmt.binding.kind === "tool") { + // Inner tool dependency — emit a memoized getter for it + const innerName = stmt.binding.name; + const innerHandle = stmt.binding.handle; + const innerDef = this.resolveToolDef(innerName); + const innerFn = innerDef?.fn ?? innerName; + const innerId = + safeId(innerHandle) + "_inner_" + this.toolGetterCount++; + const innerGetterName = `__get_${innerId}`; + + // Emit inner tool getter + const innerNodeKey = `_:Tools:${innerName}`; + this.emit(`const ${innerGetterName} = __memoize(async () => {`); + this.pushIndent(); + this.emit("const __innerInput = {};"); + + // Compile inner tool's ToolDef defaults + if (innerDef && innerDef.body.length > 0) { + const innerDefScope = defScope.child(); + for (const is of innerDef.body) { + if (is.kind === "with") { + this.registerWithBinding(is, innerDefScope); + } + } + for (const is of innerDef.body) { + if (is.kind === "wire" && is.target.instance == null) { + const value = this.compileSourceChain( + is.sources, + is.catch, + innerDefScope, + ); + if (is.target.path.length === 0) { + this.emit(`Object.assign(__innerInput, ${value});`); + } else { + this.emitSetPath("__innerInput", is.target.path, value); + } + } else if (is.kind === "scope") { + this.emitToolDefScopeInner( + is, + innerDefScope, + [], + "__innerInput", + ); + } + } } - return this.wrapExprWithLoc(this.refToExpr(ref), loc); - } - return val !== undefined ? emitCoerced(val) : "undefined"; - }; - const thenExpr = resolveBranch( - (wTern(w).then as RefExpr).ref, - (wTern(w).then as LitExpr).value, - wTern(w).thenLoc, - ); - const elseExpr = resolveBranch( - (wTern(w).else as RefExpr).ref, - (wTern(w).else as LitExpr).value, - wTern(w).elseLoc, - ); - let expr = `(${condExpr} ? ${thenExpr} : ${elseExpr})`; - expr = this.applyFallbacks(w, expr); - return expr; - } - if (isPull(w)) { - // Check if the source is an element-scoped tool (needs inline computation) - if (!wRef(w).element) { - const srcKey = refTrunkKey(wRef(w)); - if (this.elementScopedTools.has(srcKey)) { - let expr = this.buildInlineToolExpr(srcKey, elVar); - if (wRef(w).path.length > 0) { - expr = this.appendPathExpr(`(${expr})`, wRef(w)); + // Collect inner tool input wires from ToolDef body (instance!=null targeting this inner tool) + for (const stmt2 of toolDef.body) { + if (stmt2.kind === "wire" && stmt2.target.instance != null) { + // Check if this wire targets the inner tool + const targetName = + stmt2.target.module === SELF_MODULE + ? stmt2.target.field + : `${stmt2.target.module}.${stmt2.target.field}`; + if (targetName === innerName) { + const value = this.compileSourceChain( + stmt2.sources, + stmt2.catch, + defScope, + ); + if (stmt2.target.path.length === 0) { + this.emit(`Object.assign(__innerInput, ${value});`); + } else { + this.emitSetPath("__innerInput", stmt2.target.path, value); + } + } + } } - expr = this.wrapExprWithLoc(expr, wRefLoc(w)); - expr = this.applyFallbacks(w, expr); - return expr; - } - // Non-element ref inside array mapping — use normal refToExpr - let expr = this.wrapExprWithLoc(this.refToExpr(wRef(w)), wRefLoc(w)); - expr = this.applyFallbacks(w, expr); - return expr; - } - // Element refs: from.element === true, path = ["srcField"] - // Resolve elementDepth to find the correct enclosing element variable - const elemDepth = wRef(w).elementDepth ?? 0; - let targetVar = elVar; - if (elemDepth > 0) { - const currentDepth = parseInt(elVar.slice(3), 10); - targetVar = `_el${currentDepth - elemDepth}`; - } - let expr = this.appendPathExpr(targetVar, wRef(w), true); - expr = this.wrapExprWithLoc(expr, wRefLoc(w)); - expr = this.applyFallbacks(w, expr); - return expr; - } - return this.wireToExpr(w); - } - /** - * Build an inline expression for an element-scoped tool. - * Used when internal tools or define containers depend on element wires. - */ - private buildInlineToolExpr(trunkKey: string, elVar: string): string { - // If we have a loop-local variable for this tool, just reference it - const localVar = this.elementLocalVars.get(trunkKey); - if (localVar) return localVar; - - // Check if it's a define container (alias) - if (this.defineContainers.has(trunkKey)) { - // Find the wires that target this define container - const wires = this.bridge.wires.filter( - (w) => refTrunkKey(w.to) === trunkKey, - ); - if (wires.length === 0) return "undefined"; - // A single root wire can be inlined directly. Field wires must preserve - // the define container object shape for later path access. - if (wires.length === 1 && wires[0]!.to.path.length === 0) { - const w = wires[0]!; - // Check if the wire itself is element-scoped - if (isPull(w) && wRef(w).element) { - return this.elementWireToExpr(w, elVar); - } - if (isPull(w) && !wRef(w).element) { - // Check if the source is another element-scoped tool - const srcKey = refTrunkKey(wRef(w)); - if (this.elementScopedTools.has(srcKey)) { - return this.elementWireToExpr(w, elVar); + const innerFnExpr = `tools[${jsStr(innerFn)}]`; + // onError for inner tool + if (innerDef?.onError && "value" in innerDef.onError) { + this.emit(`try {`); + this.pushIndent(); + this.emit( + `return await __pipe(${innerFnExpr}, ${jsStr(innerName)}, ${jsStr(innerFn)}, __innerInput);`, + ); + this.popIndent(); + this.emit(`} catch (__err) {`); + this.pushIndent(); + this.emit(`return ${innerDef.onError.value};`); + this.popIndent(); + this.emit("}"); + } else { + this.emit( + `return await __pipe(${innerFnExpr}, ${jsStr(innerName)}, ${jsStr(innerFn)}, __innerInput);`, + ); } + this.popIndent(); + this.emit(`}, ${jsStr(innerNodeKey)});`); + + // Register inner tool in scope + defScope.set(innerHandle, { + kind: "tool", + jsExpr: innerGetterName, + toolName: innerName, + }); + } else { + this.registerWithBinding(stmt, defScope); } - // Check if this is a pipe tool call (alias name <- tool:source) - if (isPull(w) && w.pipe) { - return this.elementWireToExpr(w, elVar); - } - return this.wireToExpr(w); } - return this.buildElementContainerExpr(wires, elVar); } - // Internal tool — rebuild inline - const tool = this.tools.get(trunkKey); - if (!tool) return "undefined"; - - const fieldName = tool.toolName; - const toolWires = this.bridge.wires.filter( - (w) => refTrunkKey(w.to) === trunkKey, - ); - - // Check if it's an internal tool we can inline - if (this.internalToolKeys.has(trunkKey)) { - const inputs = new Map(); - for (const tw of toolWires) { - const path = tw.to.path; - const key = path.join("."); - inputs.set(key, this.elementWireToExpr(tw, elVar)); - } - - const a = inputs.get("a") ?? "undefined"; - const b = inputs.get("b") ?? "undefined"; - - switch (fieldName) { - case "concat": { - const parts: string[] = []; - for (let i = 0; ; i++) { - const partExpr = inputs.get(`parts.${i}`); - if (partExpr === undefined) break; - parts.push(partExpr); + // Compile self-wires (instance==null, non-scope) and scope blocks. + // Collect single-path wires for parallel execution, emit others directly. + const parallelWires: { expr: string; assign: (v: string) => string }[] = []; + for (const stmt of toolDef.body) { + if (stmt.kind === "wire" && stmt.target.instance == null) { + const value = this.compileSourceChain( + stmt.sources, + stmt.catch, + defScope, + ); + const path = stmt.target.path; + if (path.length === 0) { + // Root wire — spread into __toolInput (not parallelizable) + this.emit(`Object.assign(__toolInput, ${value});`); + } else { + // Ensure parent objects exist for multi-segment paths + for (let i = 0; i < path.length - 1; i++) { + const parentPath = path + .slice(0, i + 1) + .map((p) => `[${jsStr(p)}]`) + .join(""); + this.emit(`__toolInput${parentPath} ??= {};`); } - const concatParts = parts - .map((p) => `(${p} == null ? "" : String(${p}))`) - .join(" + "); - return `{ value: ${concatParts || '""'} }`; + parallelWires.push({ + expr: value, + assign: (v: string) => { + const pathExpr = path.map((p) => `[${jsStr(p)}]`).join(""); + return `__toolInput${pathExpr} = ${v};`; + }, + }); } - case "add": - return `(Number(${a}) + Number(${b}))`; - case "subtract": - return `(Number(${a}) - Number(${b}))`; - case "multiply": - return `(Number(${a}) * Number(${b}))`; - case "divide": - return `(Number(${a}) / Number(${b}))`; - case "eq": - return `(${a} === ${b})`; - case "neq": - return `(${a} !== ${b})`; - case "gt": - return `(Number(${a}) > Number(${b}))`; - case "gte": - return `(Number(${a}) >= Number(${b}))`; - case "lt": - return `(Number(${a}) < Number(${b}))`; - case "lte": - return `(Number(${a}) <= Number(${b}))`; - case "not": - return `(!${a})`; - case "and": - return `(Boolean(${a}) && Boolean(${b}))`; - case "or": - return `(Boolean(${a}) || Boolean(${b}))`; + } else if (stmt.kind === "scope") { + // Flush any pending parallel wires before scope block + this.emitParallelAssignments(parallelWires); + parallelWires.length = 0; + this.emitToolDefScope(stmt, defScope, []); } } - - // Non-internal tool in element scope — inline as an await __call - const inputObj = this.buildElementToolInput(toolWires, elVar); - const fnName = this.resolveToolDef(tool.toolName)?.fn ?? tool.toolName; - const fn = this.toolFnVar(fnName); - return this.memoizedToolKeys.has(trunkKey) - ? `await __callMemoized(${fn}, ${inputObj}, ${JSON.stringify(tool.toolName)}, ${JSON.stringify(fnName)}, ${JSON.stringify(trunkKey)})` - : `await __call(${fn}, ${inputObj}, ${JSON.stringify(tool.toolName)}, ${JSON.stringify(fnName)})`; + // Flush remaining parallel wires + this.emitParallelAssignments(parallelWires); } /** - * Check if a wire's generated expression would contain `await`. - * Used to determine whether array loops must be async (for...of) instead of .map()/.flatMap(). + * Emit a ToolDef scope block, setting nested properties on __toolInput. */ - private wireNeedsAwait(w: Wire): boolean { - // Element-scoped non-internal tool reference generates await __call() - if (isPull(w) && !wRef(w).element) { - const srcKey = refTrunkKey(wRef(w)); - if ( - this.elementScopedTools.has(srcKey) && - !this.internalToolKeys.has(srcKey) - ) - return true; - if ( - this.elementScopedTools.has(srcKey) && - this.defineContainers.has(srcKey) - ) { - return this.hasAsyncElementDeps(srcKey); + private emitToolDefScope( + stmt: ScopeStatement, + scope: ScopeChain, + prefix: string[], + ) { + const path = [...prefix, ...stmt.target.path]; + for (const inner of stmt.body) { + if (inner.kind === "wire" && inner.target.instance == null) { + const value = this.compileSourceChain( + inner.sources, + inner.catch, + scope, + ); + const fullPath = [...path, ...inner.target.path]; + this.emitSetPath("__toolInput", fullPath, value); + } else if (inner.kind === "scope") { + this.emitToolDefScope(inner, scope, path); } } - // Catch fallback/control without errFlag → applyFallbacks generates await (async () => ...)() - if ( - (hasCatchFallback(w) || hasCatchControl(w)) && - !this.getSourceErrorFlag(w) - ) - return true; - return false; } /** - * Returns true when all async needs in the given wires come ONLY from - * element-scoped tool calls (no catch fallback/control). - * When this is true, the array map can be made sync if all tools declare - * `{ sync: true }` — we generate a dual sync/async path at runtime. + * Emit scope blocks for inner tool input (used inside inner tool getters). */ - private asyncOnlyFromTools(wires: Wire[]): boolean { - for (const w of wires) { - if ( - (hasCatchFallback(w) || hasCatchControl(w)) && - !this.getSourceErrorFlag(w) - ) - return false; - } - return true; - } - - /** Check if an element-scoped tool has transitive async dependencies. */ - private hasAsyncElementDeps(trunkKey: string): boolean { - const wires = this.bridge.wires.filter( - (w) => refTrunkKey(w.to) === trunkKey, - ); - for (const w of wires) { - if (isPull(w) && !wRef(w).element) { - const srcKey = refTrunkKey(wRef(w)); - if ( - this.elementScopedTools.has(srcKey) && - !this.internalToolKeys.has(srcKey) && - !this.defineContainers.has(srcKey) - ) - return true; - if ( - this.elementScopedTools.has(srcKey) && - this.defineContainers.has(srcKey) - ) { - return this.hasAsyncElementDeps(srcKey); - } - } - if (isPull(w) && w.pipe) { - const srcKey = refTrunkKey(wRef(w)); - if ( - this.elementScopedTools.has(srcKey) && - !this.internalToolKeys.has(srcKey) - ) - return true; + private emitToolDefScopeInner( + stmt: ScopeStatement, + scope: ScopeChain, + prefix: string[], + targetVar: string, + ) { + const path = [...prefix, ...stmt.target.path]; + for (const inner of stmt.body) { + if (inner.kind === "wire" && inner.target.instance == null) { + const value = this.compileSourceChain( + inner.sources, + inner.catch, + scope, + ); + const fullPath = [...path, ...inner.target.path]; + this.emitSetPath(targetVar, fullPath, value); + } else if (inner.kind === "scope") { + this.emitToolDefScopeInner(inner, scope, path, targetVar); } } - return false; } /** - * Collect preamble lines for element-scoped tool calls that should be - * computed once per element and stored in loop-local variables. - * - * @param syncOnly When true, emits `__callSync()` calls (no await) — used - * inside the sync `.map()` branch of the dual-path array map optimisation. + * Emit code to set a nested path on an object, ensuring parents exist. */ - private collectElementPreamble( - elemWires: Wire[], - elVar: string, - lines: string[], - syncOnly = false, - ): void { - // Find all element-scoped non-internal tools referenced by element wires - const needed = new Set(); - const collectDeps = (tk: string) => { - if (needed.has(tk)) return; - needed.add(tk); - // Check if this container depends on other element-scoped tools - const depWires = this.bridge.wires.filter( - (w) => refTrunkKey(w.to) === tk, - ); - for (const w of depWires) { - if (isPull(w) && !wRef(w).element) { - const srcKey = refTrunkKey(wRef(w)); - if ( - this.elementScopedTools.has(srcKey) && - !this.internalToolKeys.has(srcKey) - ) { - collectDeps(srcKey); - } - } - if (isPull(w) && w.pipe) { - const srcKey = refTrunkKey(wRef(w)); - if ( - this.elementScopedTools.has(srcKey) && - !this.internalToolKeys.has(srcKey) - ) { - collectDeps(srcKey); - } - } - } - }; - - for (const w of elemWires) { - if (isPull(w) && !wRef(w).element) { - const srcKey = refTrunkKey(wRef(w)); - if ( - this.elementScopedTools.has(srcKey) && - !this.internalToolKeys.has(srcKey) - ) { - collectDeps(srcKey); - } - } - } - - for (const tk of this.topologicalSortSubset(needed)) { - const vn = `_el_${this.elementLocalVars.size}`; - this.elementLocalVars.set(tk, vn); - - if (this.defineContainers.has(tk)) { - // Define container — build inline object/value - const wires = this.bridge.wires.filter((w) => refTrunkKey(w.to) === tk); - if (wires.length === 1 && wires[0]!.to.path.length === 0) { - const w = wires[0]!; - const hasCatch = hasCatchFallback(w) || hasCatchControl(w); - const hasSafe = isPull(w) && wSafe(w); - const expr = this.elementWireToExpr(w, elVar); - if (hasCatch || hasSafe) { - lines.push( - `let ${vn}; try { ${vn} = ${expr}; } catch (_e) { if (_e?.name === "BridgePanicError" || _e?.name === "BridgeAbortError") throw _e; ${vn} = undefined; }`, - ); - } else { - lines.push(`const ${vn} = ${expr};`); - } - } else { - lines.push( - `const ${vn} = ${this.buildElementContainerExpr(wires, elVar)};`, - ); - } - } else { - // Real tool — emit tool call - const tool = this.tools.get(tk); - if (!tool) continue; - const toolWires = this.bridge.wires.filter( - (w) => refTrunkKey(w.to) === tk, + private emitSetPath(objVar: string, path: string[], valueExpr: string) { + // Prototype pollution guard — static check at compile time + for (const key of path) { + if (UNSAFE_KEYS.has(key)) { + this.emit( + `throw new Error(${jsStr(`Unsafe assignment key: ${key}`)});`, ); - const inputObj = this.buildElementToolInput(toolWires, elVar); - const fnName = this.resolveToolDef(tool.toolName)?.fn ?? tool.toolName; - const isCatchGuarded = this.catchGuardedTools.has(tk); - if (syncOnly) { - const fn = this.toolFnVar(fnName); - const syncExpr = this.memoizedToolKeys.has(tk) - ? `__callMemoized(${fn}, ${inputObj}, ${JSON.stringify(tool.toolName)}, ${JSON.stringify(fnName)}, ${JSON.stringify(tk)})` - : `__callSync(${fn}, ${inputObj}, ${JSON.stringify(tool.toolName)}, ${JSON.stringify(fnName)})`; - if (isCatchGuarded) { - lines.push(`let ${vn}, ${vn}_err;`); - lines.push( - `try { ${vn} = ${syncExpr}; } catch (_e) { if (_e?.name === "BridgePanicError" || _e?.name === "BridgeAbortError") throw _e; ${vn}_err = _e; }`, - ); - } else { - lines.push(`const ${vn} = ${syncExpr};`); - } - } else { - const asyncExpr = this.syncAwareCall( - fnName, - inputObj, - tk, - tool.toolName, - ); - if (isCatchGuarded) { - lines.push(`let ${vn}, ${vn}_err;`); - lines.push( - `try { ${vn} = ${asyncExpr}; } catch (_e) { if (_e?.name === "BridgePanicError" || _e?.name === "BridgeAbortError") throw _e; ${vn}_err = _e; }`, - ); - } else { - lines.push(`const ${vn} = ${asyncExpr};`); - } - } + return; } } + // Ensure parent objects exist + for (let i = 0; i < path.length - 1; i++) { + const parentPath = path + .slice(0, i + 1) + .map((p) => `[${jsStr(p)}]`) + .join(""); + this.emit(`${objVar}${parentPath} ??= {};`); + } + const fullPath = path.map((p) => `[${jsStr(p)}]`).join(""); + this.emit(`${objVar}${fullPath} = ${valueExpr};`); } - private topologicalSortSubset(keys: Iterable): string[] { - const needed = new Set(keys); - const orderedKeys = [...this.tools.keys(), ...this.defineContainers].filter( - (key) => needed.has(key), - ); - const orderIndex = new Map(orderedKeys.map((key, index) => [key, index])); - const adj = new Map>(); - const inDegree = new Map(); - - for (const key of orderedKeys) { - adj.set(key, new Set()); - inDegree.set(key, 0); - } + // ── Wire compilation ────────────────────────────────────────────────── - for (const key of orderedKeys) { - const wires = this.bridge.wires.filter((w) => refTrunkKey(w.to) === key); - for (const w of wires) { - for (const src of this.getSourceTrunks(w)) { - if (src === key) { - const err = new BridgePanicError( - `Circular dependency detected: "${key}" depends on itself`, - ); - (err as any).bridgeLoc = isPull(w) ? wRefLoc(w) : w.loc; - throw err; - } - if (!needed.has(src)) continue; - const neighbors = adj.get(src); - if (!neighbors || neighbors.has(key)) continue; - neighbors.add(key); - inDegree.set(key, (inDegree.get(key) ?? 0) + 1); - } + private compileWire( + wire: WireStatement, + scope: ScopeChain, + outputVar: string, + pathPrefix: string[], + absolutePrefix: string[] = [], + ) { + const target = wire.target; + + // Check if this wire targets a tool input (already handled by tool getters) + const handleName = this.findTargetHandle(target, scope); + if (handleName) { + const binding = scope.get(handleName); + if (binding?.kind === "tool") { + // Tool input wire — already collected for the getter + return; } } - const ready = orderedKeys.filter((key) => (inDegree.get(key) ?? 0) === 0); - const sorted: string[] = []; + // This wire targets output or something else + const targetExpr = this.compileTargetRef( + target, + scope, + outputVar, + pathPrefix, + ); - while (ready.length > 0) { - ready.sort( - (left, right) => - (orderIndex.get(left) ?? 0) - (orderIndex.get(right) ?? 0), + // Special handling for array source expressions (e.g. i.list[] as item { ... }) + if (wire.sources.length === 1 && wire.sources[0]!.expr.type === "array") { + // Compute absolute prefix for array element body: + // inner fields are at absolutePrefix + target.path (e.g. ["legs"]) + const arrayAbsPrefix = [...absolutePrefix, ...pathPrefix, ...target.path]; + this.compileArrayAssignment( + wire.sources[0]!.expr as Extract, + targetExpr, + scope, + arrayAbsPrefix, ); - const key = ready.shift()!; - sorted.push(key); - for (const neighbor of adj.get(key) ?? []) { - const nextDegree = (inDegree.get(neighbor) ?? 1) - 1; - inDegree.set(neighbor, nextDegree); - if (nextDegree === 0) { - ready.push(neighbor); - } - } + return; } - return sorted.length === orderedKeys.length ? sorted : orderedKeys; - } - - private filterCurrentElementWires( - elemWires: Wire[], - arrayIterators: Record, - ): Wire[] { - return elemWires.filter( - (w) => !(w.to.path.length > 1 && w.to.path[0]! in arrayIterators), + const valueExpr = this.compileSourceChainWithLoc( + wire.sources, + wire.catch, + scope, ); - } - private relativeArrayIterators( - arrayIterators: Record, - prefix: string, - ): Record { - const relative: Record = {}; - const prefixWithDot = `${prefix}.`; - - for (const [path, alias] of Object.entries(arrayIterators)) { - if (path === prefix) { - relative[""] = alias; - } else if (path.startsWith(prefixWithDot)) { - relative[path.slice(prefixWithDot.length)] = alias; - } + // Root output wire — spread into output object instead of reassigning + if ( + target.module === SELF_MODULE && + target.type === this.bridge.type && + target.field === this.bridge.field && + target.path.length === 0 && + pathPrefix.length === 0 + ) { + this.emit(`Object.assign(${outputVar}, ${valueExpr});`); + } else { + this.emit(`${targetExpr} = ${valueExpr};`); } - - return relative; } - private withElementLocalVarScope(fn: () => T): T { - const previous = this.elementLocalVars; - this.elementLocalVars = new Map(previous); - try { - return fn(); - } finally { - this.elementLocalVars = previous; - } + private compileAlias(alias: WireAliasStatement, scope: ScopeChain) { + const valueExpr = this.compileSourceChain( + alias.sources, + alias.catch, + scope, + ); + const varName = `__alias_${safeId(alias.name)}`; + this.emit(`const ${varName} = ${valueExpr};`); + + // Register alias in scope + scope.set(alias.name, { kind: "alias", jsExpr: varName }); } - /** - * Collect the tool function references (as JS expressions) for all - * element-scoped non-internal tools used by the given element wires. - * Used to build runtime sync-check expressions for array map optimisation. - */ - private collectElementToolRefs(elemWires: Wire[]): string[] { - const needed = new Set(); - const collectDeps = (tk: string) => { - if (needed.has(tk)) return; - needed.add(tk); - const depWires = this.bridge.wires.filter( - (w) => refTrunkKey(w.to) === tk, - ); - for (const w of depWires) { - if (isPull(w) && !wRef(w).element) { - const srcKey = refTrunkKey(wRef(w)); - if ( - this.elementScopedTools.has(srcKey) && - !this.internalToolKeys.has(srcKey) - ) { - collectDeps(srcKey); - } - } - if (isPull(w) && w.pipe) { - const srcKey = refTrunkKey(wRef(w)); - if ( - this.elementScopedTools.has(srcKey) && - !this.internalToolKeys.has(srcKey) - ) { - collectDeps(srcKey); - } - } - } - }; - for (const w of elemWires) { - if (isPull(w) && !wRef(w).element) { - const srcKey = refTrunkKey(wRef(w)); - if ( - this.elementScopedTools.has(srcKey) && - !this.internalToolKeys.has(srcKey) - ) { - collectDeps(srcKey); - } - } + private compileScope( + stmt: ScopeStatement, + parentScope: ScopeChain, + outputVar: string, + pathPrefix: string[], + absolutePrefix: string[] = [], + ) { + const target = stmt.target; + // Scope targets are relative to the output + // target.path is the nested path within the output + const scopePath = [...pathPrefix, ...target.path]; + const absoluteScopePath = [...absolutePrefix, ...scopePath]; + + // requestedFields filtering: skip scopes for unrequested fields + if (this.requestedFields && this.requestedFields.length > 0) { + const fieldPath = absoluteScopePath.join("."); + if (fieldPath && !matchesRequestedFields(fieldPath, this.requestedFields)) + return; } - const refs: string[] = []; - for (const tk of needed) { - if (this.defineContainers.has(tk)) continue; - const tool = this.tools.get(tk); - if (!tool) continue; - const fnName = this.resolveToolDef(tool.toolName)?.fn ?? tool.toolName; - refs.push(this.toolFnVar(fnName)); - } - return refs; - } + const scopeVar = `__scope_${scopePath.join("_")}`; + const childScope = parentScope.child(); - /** Build an input object for a tool call inside an array map callback. */ - private buildElementToolInput(wires: Wire[], elVar: string): string { - if (wires.length === 0) return "{}"; - const entries: string[] = []; - for (const w of wires) { - const path = w.to.path; - const key = path[path.length - 1]!; - entries.push( - `${JSON.stringify(key)}: ${this.elementWireToExpr(w, elVar)}`, - ); - } - return `{ ${entries.join(", ")} }`; + // Ensure parent objects exist + this.emitEnsurePath(outputVar, scopePath); + this.emit( + `const ${scopeVar} = ${outputVar}${scopePath.map((p) => `[${jsStr(p)}]`).join("")};`, + ); + + this.compileBody(stmt.body, childScope, scopeVar, [], absoluteScopePath); } - private buildElementContainerExpr(wires: Wire[], elVar: string): string { - if (wires.length === 0) return "undefined"; + private compileSpread( + _stmt: SpreadStatement, + _scope: ScopeChain, + _outputVar: string, + ): never { + // TODO: implement spread compilation + throw new BridgeCompilerIncompatibleError( + `${this.bridge.type}.${this.bridge.field}`, + "Spread statements are not yet supported by the compiler.", + ); + } - let rootExpr: string | undefined; - const fieldWires: Wire[] = []; + private compileForce(stmt: ForceStatement, scope: ScopeChain) { + const binding = scope.get(stmt.handle); + if (!binding) return; - for (const w of wires) { - if (w.to.path.length === 0) { - rootExpr = this.elementWireToExpr(w, elVar); + if (binding.kind === "tool") { + // Force the tool getter to execute + if (stmt.catchError) { + this.emit(`try { await ${binding.jsExpr}(); } catch (_) {}`); } else { - fieldWires.push(w); + this.emit(`await ${binding.jsExpr}();`); } } + } - if (rootExpr !== undefined && fieldWires.length === 0) { - return rootExpr; - } + // ── Overdefinition ──────────────────────────────────────────────────── - interface TreeNode { - expr?: string; - children: Map; + /** + * Compute the output path key for a wire targeting the current bridge's output. + * Returns undefined if the wire doesn't target output (e.g. targets another tool). + */ + private wireOutputPathKey( + target: NodeRef, + pathPrefix: string[], + ): string | undefined { + if ( + target.module === SELF_MODULE && + target.type === this.bridge.type && + target.field === this.bridge.field + ) { + return [...pathPrefix, ...target.path].join("."); } + return undefined; + } - const root: TreeNode = { children: new Map() }; + /** + * Group output-targeting wire statements by their target path key. + * Only groups wires that target the bridge's own output (skips tool input wires). + */ + private groupOutputWiresByPath( + body: Statement[], + scope: ScopeChain, + pathPrefix: string[], + ): Map { + const groups = new Map(); + for (const stmt of body) { + if (stmt.kind !== "wire") continue; - for (const w of fieldWires) { - let current = root; - for (let index = 0; index < w.to.path.length - 1; index++) { - const segment = w.to.path[index]!; - if (!current.children.has(segment)) { - current.children.set(segment, { children: new Map() }); - } - current = current.children.get(segment)!; + // Skip tool input wires + const handleName = this.findTargetHandle(stmt.target, scope); + if (handleName) { + const binding = scope.get(handleName); + if (binding?.kind === "tool") continue; } - const lastSegment = w.to.path[w.to.path.length - 1]!; - if (!current.children.has(lastSegment)) { - current.children.set(lastSegment, { children: new Map() }); - } - current.children.get(lastSegment)!.expr = this.elementWireToExpr( - w, - elVar, - ); - } - return this.serializeTreeNode(root, 4, rootExpr); - } + const pathKey = this.wireOutputPathKey(stmt.target, pathPrefix); + if (pathKey === undefined) continue; - /** Apply falsy (||), nullish (??) and catch fallback chains to an expression. */ - private applyFallbacks(w: Wire, expr: string): string { - // Top-level safe flag indicates the wire wants error → undefined conversion. - // condAnd/condOr wires carry safe INSIDE (condAnd.safe) — those refs already - // have rootSafe/pathSafe so __get handles null bases; no extra wrapping needed. - const wireSafe = wSafe(w); - // When safe (?.) has fallbacks (?? / ||), convert tool error → undefined - // BEFORE the fallback chain so that `a?.name ?? panic "msg"` triggers - // the panic when the tool errors (safe makes it undefined, then ?? fires). - const wireHasFallbacks = hasFallbacks(w); - if ( - wireHasFallbacks && - wireSafe && - !hasCatchFallback(w) && - !hasCatchControl(w) - ) { - const earlyErrFlag = this.getSourceErrorFlag(w); - if (earlyErrFlag) { - expr = `(${earlyErrFlag} !== undefined ? undefined : ${expr})`; // lgtm [js/code-injection] + let group = groups.get(pathKey); + if (!group) { + group = []; + groups.set(pathKey, group); } + group.push(stmt); } + return groups; + } - if (hasFallbacks(w)) { - for (const fb of fallbacks(w)) { - if (fb.gate === "falsy") { - if (eRef(fb.expr)) { - expr = `(${expr} || ${this.wrapExprWithLoc(this.lazyRefToExpr(eRef(fb.expr)), fb.loc)})`; // lgtm [js/code-injection] - } else if (eVal(fb.expr) != null) { - expr = `(${expr} || ${emitCoerced(eVal(fb.expr))})`; // lgtm [js/code-injection] - } else if ((fb.expr as ControlExpr).control) { - const ctrl = (fb.expr as ControlExpr).control; - const fbLoc = this.serializeLoc(fb.loc); - if (ctrl.kind === "throw") { - expr = `(${expr} || (() => { throw new __BridgeRuntimeError(${JSON.stringify(ctrl.message)}, { bridgeLoc: ${fbLoc} }); })())`; // lgtm [js/code-injection] - } else if (ctrl.kind === "panic") { - expr = `(${expr} || (() => { const _e = new __BridgePanicError(${JSON.stringify(ctrl.message)}); _e.bridgeLoc = ${fbLoc}; throw _e; })())`; // lgtm [js/code-injection] - } - } - } else { - // nullish - if (eRef(fb.expr)) { - expr = `((__v) => (__v == null ? undefined : __v))((${expr} ?? ${this.wrapExprWithLoc(this.lazyRefToExpr(eRef(fb.expr)), fb.loc)}))`; // lgtm [js/code-injection] - } else if (eVal(fb.expr) != null) { - expr = `((__v) => (__v == null ? undefined : __v))((${expr} ?? ${emitCoerced(eVal(fb.expr))}))`; // lgtm [js/code-injection] - } else if ((fb.expr as ControlExpr).control) { - const ctrl = (fb.expr as ControlExpr).control; - const fbLoc = this.serializeLoc(fb.loc); - if (ctrl.kind === "throw") { - expr = `(${expr} ?? (() => { throw new __BridgeRuntimeError(${JSON.stringify(ctrl.message)}, { bridgeLoc: ${fbLoc} }); })())`; // lgtm [js/code-injection] - } else if (ctrl.kind === "panic") { - expr = `(${expr} ?? (() => { const _e = new __BridgePanicError(${JSON.stringify(ctrl.message)}); _e.bridgeLoc = ${fbLoc}; throw _e; })())`; // lgtm [js/code-injection] - } - } - } - } + /** + * Compile a group of overdefined wires targeting the same output path. + * Sorts by expression cost (cheapest first) and emits a null-coalescing + * block that short-circuits on the first non-null result. + * + * When all wires have equal static cost > 0 (e.g. all tool refs), emits + * a runtime-sorted block using tool metadata for cost disambiguation. + */ + private compileOverdefinedWires( + wires: WireStatement[], + scope: ScopeChain, + outputVar: string, + pathPrefix: string[], + ) { + // Compute static costs + const ranked = wires.map((wire, index) => ({ + wire, + index, + cost: this.computeExprCost(wire.sources[0]!.expr, scope), + })); + + // Check if all costs are equal and > 0 (needs runtime sorting) + const allSameCost = + ranked.length > 1 && + ranked[0]!.cost > 0 && + ranked.every((r) => r.cost === ranked[0]!.cost); + + if (allSameCost) { + this.compileRuntimeSortedOverdef(ranked, scope, outputVar, pathPrefix); + return; } - // Catch fallback — use error flag from catch-guarded tool call - const errFlag = this.getSourceErrorFlag(w); - - if (hasCatchFallback(w)) { - let catchExpr: string; - if (hasCatchRef(w)) { - catchExpr = this.wrapExprWithLoc( - this.refToExpr(catchRef(w)!), - catchLoc(w), - ); - } else if (hasCatchValue(w)) { - catchExpr = emitCoerced(catchValue(w)!); - } else { - catchExpr = "undefined"; - } - - if (errFlag) { - expr = `(${errFlag} !== undefined ? ${catchExpr} : ${expr})`; // lgtm [js/code-injection] - } else { - // Fallback: wrap in IIFE with try/catch (re-throw fatal errors) - expr = `await (async () => { try { return ${expr}; } catch (_e) { if (_e?.name === "BridgePanicError" || _e?.name === "BridgeAbortError") throw _e; return ${catchExpr}; } })()`; // lgtm [js/code-injection] - } - } else if (wireSafe && !hasCatchControl(w)) { - // Safe navigation (?.) without catch — return undefined on error. - // When fallbacks are present, the early conversion already happened above. - if (!wireHasFallbacks) { - if (errFlag) { - expr = `(${errFlag} !== undefined ? undefined : ${expr})`; // lgtm [js/code-injection] - } else { - expr = `await (async () => { try { return ${expr}; } catch (_e) { if (_e?.name === "BridgePanicError" || _e?.name === "BridgeAbortError") throw _e; return undefined; } })()`; // lgtm [js/code-injection] - } - } - } else if (errFlag) { - // condAnd/condOr with nested safe flag — the inner refs have rootSafe/pathSafe - // so __get handles null bases gracefully. Don't re-throw; the natural Boolean() - // evaluation produces the correct result (e.g. Boolean(undefined) → false). - const isCondSafe = - (isAndW(w) && (wAndOr(w).leftSafe || wAndOr(w).rightSafe)) || - (isOrW(w) && (wAndOr(w).leftSafe || wAndOr(w).rightSafe)); - if (!isCondSafe) { - // This wire has NO catch fallback but its source tool is catch-guarded by another - // wire. If the tool failed, re-throw the stored error rather than silently - // returning undefined — swallowing the error here would be a silent data bug. - expr = `(${errFlag} !== undefined ? (() => { throw ${errFlag}; })() : ${expr})`; // lgtm [js/code-injection] - } - } + // Static sorting: cheapest first, authored order for ties + ranked.sort((a, b) => + a.cost !== b.cost ? a.cost - b.cost : a.index - b.index, + ); + const sorted = ranked.map((e) => e.wire); - // Catch control flow (throw/panic on catch gate) - if (hasCatchControl(w)) { - const ctrl = catchControl(w)!; - const cLoc = this.serializeLoc(catchLoc(w)); - if (ctrl.kind === "throw") { - // Wrap in catch IIFE — on error, throw the custom message - if (errFlag) { - expr = `(${errFlag} !== undefined ? (() => { throw new __BridgeRuntimeError(${JSON.stringify(ctrl.message)}, { bridgeLoc: ${cLoc} }); })() : ${expr})`; // lgtm [js/code-injection] - } else { - expr = `await (async () => { try { return ${expr}; } catch (_e) { if (_e?.name === "BridgePanicError" || _e?.name === "BridgeAbortError") throw _e; throw new __BridgeRuntimeError(${JSON.stringify(ctrl.message)}, { bridgeLoc: ${cLoc} }); } })()`; // lgtm [js/code-injection] - } - } else if (ctrl.kind === "panic") { - if (errFlag) { - expr = `(${errFlag} !== undefined ? (() => { const _e = new __BridgePanicError(${JSON.stringify(ctrl.message)}); _e.bridgeLoc = ${cLoc}; throw _e; })() : ${expr})`; // lgtm [js/code-injection] - } else { - expr = `await (async () => { try { return ${expr}; } catch (_e) { if (_e?.name === "BridgePanicError" || _e?.name === "BridgeAbortError") throw _e; const _pe = new __BridgePanicError(${JSON.stringify(ctrl.message)}); _pe.bridgeLoc = ${cLoc}; throw _pe; } })()`; // lgtm [js/code-injection] - } - } - } + const target = sorted[0]!.target; + const targetExpr = this.compileTargetRef( + target, + scope, + outputVar, + pathPrefix, + ); - return expr; - } + // Root output wire — special handling + const isRoot = + target.module === SELF_MODULE && + target.type === this.bridge.type && + target.field === this.bridge.field && + target.path.length === 0 && + pathPrefix.length === 0; - /** Get the error flag variable name for a wire's source tool, but ONLY if - * that tool was compiled in catch-guarded mode (i.e. the `_err` variable exists). */ - private getSourceErrorFlag(w: Wire): string | undefined { - if (isPull(w)) { - return this.getErrorFlagForRef(wRef(w)); - } - // For ternary wires, check all referenced tools - if (isTern(w)) { - const flags: string[] = []; - const cf = this.getErrorFlagForRef(eRef(wTern(w).cond)); - if (cf) flags.push(cf); - if ((wTern(w).then as RefExpr).ref) { - const f = this.getErrorFlagForRef((wTern(w).then as RefExpr).ref); - if (f && !flags.includes(f)) flags.push(f); - } - if ((wTern(w).else as RefExpr).ref) { - const f = this.getErrorFlagForRef((wTern(w).else as RefExpr).ref); - if (f && !flags.includes(f)) flags.push(f); - } - if (flags.length > 0) return flags.join(" ?? "); // Combine error flags - } - // For condAnd/condOr wires, check leftRef and rightRef - if (isAndW(w)) { - const flags: string[] = []; - const lf = this.getErrorFlagForRef(eRef(wAndOr(w).left)); - if (lf) flags.push(lf); - if (eRef(wAndOr(w).right)) { - const rf = this.getErrorFlagForRef(eRef(wAndOr(w).right)); - if (rf && !flags.includes(rf)) flags.push(rf); - } - if (flags.length > 0) return flags.join(" ?? "); - } - if (isOrW(w)) { - const flags: string[] = []; - const lf = this.getErrorFlagForRef(eRef(wAndOr(w).left)); - if (lf) flags.push(lf); - if (eRef(wAndOr(w).right)) { - const rf = this.getErrorFlagForRef(eRef(wAndOr(w).right)); - if (rf && !flags.includes(rf)) flags.push(rf); - } - if (flags.length > 0) return flags.join(" ?? "); - } - return undefined; - } + const odVar = `__od_${this.overdefCount++}`; + const errVar = `${odVar}_err`; - /** Get error flag for a specific NodeRef (used by define container emission). */ - private getErrorFlagForRef(ref: NodeRef): string | undefined { - const srcKey = refTrunkKey(ref); - if (!this.catchGuardedTools.has(srcKey)) return undefined; - if (this.internalToolKeys.has(srcKey) || this.defineContainers.has(srcKey)) - return undefined; - const localVar = this.elementLocalVars.get(srcKey); - if (localVar) return `${localVar}_err`; - const tool = this.tools.get(srcKey); - if (!tool) return undefined; - return `${tool.varName}_err`; - } + // Emit the first (cheapest) wire's value + const firstVal = this.compileSourceChainWithLoc( + sorted[0]!.sources, + sorted[0]!.catch, + scope, + ); - // ── NodeRef → expression ────────────────────────────────────────────────── - - /** Convert a NodeRef to a JavaScript expression. */ - private refToExpr(ref: NodeRef): string { - // Const access: parse the JSON value at runtime, then access path - if (ref.type === "Const" && ref.field === "const" && ref.path.length > 0) { - const constName = ref.path[0]!; - const val = this.constDefs.get(constName); - if (val != null) { - const base = emitParsedConst(val); - if (ref.path.length === 1) return base; - // Delegate sub-path to appendPathExpr so pathSafe flags are respected. - const subRef: NodeRef = { - ...ref, - path: ref.path.slice(1), - rootSafe: ref.pathSafe?.[1] ?? false, - pathSafe: ref.pathSafe?.slice(1), - }; - return this.appendPathExpr(`(${base})`, subRef); - } + // If the first wire has cost 0, it can't throw a tool error — emit directly + if (ranked[0]!.cost === 0) { + this.emit(`let ${odVar} = ${firstVal};`); + this.emit(`let ${errVar};`); + } else { + this.emit(`let ${odVar};`); + this.emit(`let ${errVar};`); + this.emit( + `try { ${odVar} = ${firstVal}; } catch (_e) { ${errVar} = _e; }`, + ); } - // Self-module input reference - if ( - ref.module === SELF_MODULE && - ref.type === this.bridge.type && - ref.field === this.bridge.field && - !ref.element - ) { - if (ref.path.length === 0) return "input"; - return this.appendPathExpr("input", ref); + for (let i = 1; i < sorted.length; i++) { + const nextVal = this.compileSourceChainWithLoc( + sorted[i]!.sources, + sorted[i]!.catch, + scope, + ); + this.emit(`if (${odVar} == null) {`); + this.pushIndent(); + this.emit( + `try { ${odVar} = ${nextVal}; if (${odVar} != null) ${errVar} = undefined; } catch (_e) { ${errVar} = _e; }`, + ); + this.popIndent(); + this.emit("}"); } - // Tool result reference - const key = refTrunkKey(ref); - - // Handle element-scoped tools when in array context - if (this.elementScopedTools.has(key) && this.currentElVar) { - let expr = this.buildInlineToolExpr(key, this.currentElVar); - if (ref.path.length > 0) { - expr = this.appendPathExpr(`(${expr})`, ref); - } - return expr; - } + this.emit(`if (${odVar} == null && ${errVar}) throw ${errVar};`); - // Handle element refs (from.element = true) - if (ref.element) { - return this.refToElementExpr(ref); + if (isRoot) { + this.emit(`Object.assign(${outputVar}, ${odVar});`); + } else { + this.emit(`${targetExpr} = ${odVar};`); } + } - const varName = this.varMap.get(key); - if (!varName) - throw new BridgeCompilerIncompatibleError( - `${this.bridge.type}.${this.bridge.field}`, - `Unsupported reference: ${key}.`, + /** + * Emit a runtime-sorted overdefinition block for wires where all static + * costs are equal. Uses tool metadata (`bridge.cost`, `bridge.sync`) to + * determine cost at runtime and sort the evaluation order. + */ + private compileRuntimeSortedOverdef( + ranked: { wire: WireStatement; index: number; cost: number }[], + scope: ScopeChain, + outputVar: string, + pathPrefix: string[], + ) { + this.needsToolCostHelper = true; + const target = ranked[0]!.wire.target; + const targetExpr = this.compileTargetRef( + target, + scope, + outputVar, + pathPrefix, + ); + const isRoot = + target.module === SELF_MODULE && + target.type === this.bridge.type && + target.field === this.bridge.field && + target.path.length === 0 && + pathPrefix.length === 0; + + const odVar = `__od_${this.overdefCount++}`; + + // Build an array of { cost, fn } entries sorted at runtime by cost + // Each fn is a lazy async function that evaluates the wire's source chain + const entries = ranked.map((r, _i) => { + const runtimeCost = this.computeRuntimeCostExpr( + r.wire.sources[0]!.expr, + scope, ); - if (ref.path.length === 0) return varName; - return this.appendPathExpr(varName, ref); - } + return { wire: r.wire, costExpr: runtimeCost, index: r.index }; + }); - private appendPathExpr( - baseExpr: string, - ref: NodeRef, - allowMissingBase = false, - ): string { - if (ref.path.length === 0) return baseExpr; + // Emit: sort entries by runtime cost, then evaluate in order + this.emit(`const ${odVar}_entries = [`); + this.pushIndent(); + for (const entry of entries) { + const valueExpr = this.compileSourceChainWithLoc( + entry.wire.sources, + entry.wire.catch, + scope, + ); + this.emit( + `{ cost: ${entry.costExpr}, idx: ${entry.index}, fn: async () => ${valueExpr} },`, + ); + } + this.popIndent(); + this.emit( + `].sort((a, b) => a.cost !== b.cost ? a.cost - b.cost : a.idx - b.idx);`, + ); - const safeFlags = ref.path.map( - (_, i) => - ref.pathSafe?.[i] ?? (i === 0 ? (ref.rootSafe ?? false) : false), + this.emit(`let ${odVar};`); + this.emit(`let ${odVar}_err;`); + this.emit(`for (const __e of ${odVar}_entries) {`); + this.pushIndent(); + this.emit( + `try { ${odVar} = await __e.fn(); if (${odVar} != null) ${odVar}_err = undefined; } catch (_e) { ${odVar}_err = _e; continue; }`, ); - // Prefer the dedicated single-segment helper on the dominant case. - // See packages/bridge-compiler/performance.md (#2). - if (ref.path.length === 1) { - return `__get(${baseExpr}, ${JSON.stringify(ref.path[0])}, ${safeFlags[0] ? "true" : "false"}, ${allowMissingBase ? "true" : "false"})`; + this.emit(`if (${odVar} != null) break;`); + this.popIndent(); + this.emit("}"); + this.emit(`if (${odVar} == null && ${odVar}_err) throw ${odVar}_err;`); + + if (isRoot) { + this.emit(`Object.assign(${outputVar}, ${odVar});`); + } else { + this.emit(`${targetExpr} = ${odVar};`); } - return `__path(${baseExpr}, ${JSON.stringify(ref.path)}, ${JSON.stringify(safeFlags)}, ${allowMissingBase ? "true" : "false"})`; } /** - * Like refToExpr, but for ternary-only tools, inlines the tool call. - * This ensures lazy evaluation — only the chosen branch's tool is called. + * Compute a JS expression that evaluates to the runtime cost of an expression. + * For tool refs, checks `tools[name].bridge?.cost ?? (tools[name].bridge?.sync ? 1 : 2)`. + * For non-tool expressions, returns a literal number. */ - private lazyRefToExpr(ref: NodeRef): string { - const key = refTrunkKey(ref); - if (this.ternaryOnlyTools.has(key)) { - const tool = this.tools.get(key); - if (tool) { - const toolWires = this.bridge.wires.filter( - (w) => refTrunkKey(w.to) === key, - ); - const toolDef = this.resolveToolDef(tool.toolName); - const fnName = toolDef?.fn ?? tool.toolName; - - // Build input object - let inputObj: string; - if (toolDef) { - const inputEntries = new Map(); - for (const tw of toolDef.wires) { - if (isLit(tw) && !isTern(tw)) { - const target = tw.to.path.join("."); - inputEntries.set( - target, - `${JSON.stringify(target)}: ${emitCoerced(wVal(tw))}`, - ); - } - } - for (const tw of toolDef.wires) { - if (isPull(tw)) { - const target = tw.to.path.join("."); - const expr = this.resolveToolWireSource(tw, toolDef); - inputEntries.set(target, `${JSON.stringify(target)}: ${expr}`); - } - } - for (const bw of toolWires) { - const path = bw.to.path; - if (path.length >= 1) { - const bKey = path[0]!; - inputEntries.set( - bKey, - `${JSON.stringify(bKey)}: ${this.wireToExpr(bw)}`, - ); - } - } - const parts = [...inputEntries.values()]; - inputObj = parts.length > 0 ? `{ ${parts.join(", ")} }` : "{}"; - } else { - inputObj = this.buildObjectLiteral(toolWires, (w) => w.to.path, 4); - } - - const fn = this.toolFnVar(fnName); - let expr = this.memoizedToolKeys.has(key) - ? `(await __callMemoized(${fn}, ${inputObj}, ${JSON.stringify(tool.toolName)}, ${JSON.stringify(fnName)}, ${JSON.stringify(key)}))` - : `(await __call(${fn}, ${inputObj}, ${JSON.stringify(tool.toolName)}, ${JSON.stringify(fnName)}))`; - if (ref.path.length > 0) { - expr = this.appendPathExpr(expr, ref); + private computeRuntimeCostExpr(expr: Expression, scope: ScopeChain): string { + if (expr.type === "ref") { + const ref = expr.ref; + if (ref.element) return "0"; + if (ref.type === "Context" || ref.type === "Const") return "0"; + if (ref.module === SELF_MODULE && ref.type === "__local") return "0"; + if (ref.module === SELF_MODULE && ref.instance == null) return "0"; + // Tool ref — generate runtime cost check + const handle = this.findSourceHandle(ref, scope); + if (handle) { + const binding = scope.get(handle); + if (binding?.kind === "tool" && binding.toolName) { + return `__toolCost(tools[${jsStr(binding.toolName)}])`; } - return expr; } + const toolKey = + ref.module === SELF_MODULE ? ref.field : `${ref.module}.${ref.field}`; + return `__toolCost(tools[${jsStr(toolKey)}])`; } - return this.refToExpr(ref); + return String(this.computeExprCost(expr, scope)); } /** - * Analyze which tools are only referenced in ternary branches (thenRef/elseRef) - * and can be lazily evaluated inline instead of eagerly called. + * Compute the static cost of an expression for overdefinition ordering. + * Mirrors the runtime's computeExprCost logic. */ - private analyzeTernaryOnlyTools( - outputWires: Wire[], - toolWires: Map, - defineWires: Map, - forceMap: Map, - ): void { - // Collect all tool trunk keys referenced in any wire position - const allRefs = new Set(); - const ternaryBranchRefs = new Set(); - - const processWire = (w: Wire) => { - if (isPull(w) && !wRef(w).element) { - allRefs.add(refTrunkKey(wRef(w))); - } - if (isTern(w)) { - allRefs.add(refTrunkKey(eRef(wTern(w).cond))); - if ((wTern(w).then as RefExpr).ref) - ternaryBranchRefs.add(refTrunkKey((wTern(w).then as RefExpr).ref)); - if ((wTern(w).else as RefExpr).ref) - ternaryBranchRefs.add(refTrunkKey((wTern(w).else as RefExpr).ref)); - } - if (isAndW(w)) { - allRefs.add(refTrunkKey(eRef(wAndOr(w).left))); - if (eRef(wAndOr(w).right)) - ternaryBranchRefs.add(refTrunkKey(eRef(wAndOr(w).right))); - } - if (isOrW(w)) { - allRefs.add(refTrunkKey(eRef(wAndOr(w).left))); - if (eRef(wAndOr(w).right)) - ternaryBranchRefs.add(refTrunkKey(eRef(wAndOr(w).right))); - } - // Fallback refs — on ternary wires, treat as lazy (ternary-branch-like) - if (hasFallbacks(w)) { - const refSet = isTern(w) ? ternaryBranchRefs : allRefs; - for (const fb of fallbacks(w)) { - if (eRef(fb.expr)) refSet.add(refTrunkKey(eRef(fb.expr))); - } + private computeExprCost(expr: Expression, scope: ScopeChain): number { + switch (expr.type) { + case "literal": + case "control": + return 0; + case "ref": { + const ref = expr.ref; + if (ref.element) return 0; + if (ref.type === "Context" || ref.type === "Const") return 0; + if (ref.module === SELF_MODULE && ref.type === "__local") return 0; + // Input ref (self-module, no instance → not a tool) + if (ref.module === SELF_MODULE && ref.instance == null) return 0; + // Tool ref → default async cost + return 2; } - if (hasCatchRef(w)) allRefs.add(refTrunkKey(catchRef(w)!)); - }; - - for (const w of outputWires) processWire(w); - for (const [, wires] of toolWires) { - for (const w of wires) processWire(w); - } - for (const [, wires] of defineWires) { - for (const w of wires) processWire(w); - } - - // A tool is ternary-only if: - // 1. It's a real tool (not define/internal) - // 2. It appears ONLY in ternaryBranchRefs, never in allRefs (from regular pull wires, cond refs, etc.) - // 3. It has no force statement - // 4. It has no input wires from other ternary-only tools (simple first pass) - for (const tk of ternaryBranchRefs) { - if (!this.tools.has(tk)) continue; - if (this.defineContainers.has(tk)) continue; - if (this.internalToolKeys.has(tk)) continue; - if (forceMap.has(tk)) continue; - if (allRefs.has(tk)) continue; // Referenced outside ternary branches - this.ternaryOnlyTools.add(tk); + case "ternary": + return Math.max( + this.computeExprCost(expr.cond, scope), + this.computeExprCost(expr.then, scope), + this.computeExprCost(expr.else, scope), + ); + case "and": + case "or": + return Math.max( + this.computeExprCost(expr.left, scope), + this.computeExprCost(expr.right, scope), + ); + case "array": + case "pipe": + return this.computeExprCost(expr.source, scope); + case "binary": + return Math.max( + this.computeExprCost(expr.left, scope), + this.computeExprCost(expr.right, scope), + ); + case "unary": + return this.computeExprCost(expr.operand, scope); + case "concat": + return Math.max( + ...expr.parts.map((p) => this.computeExprCost(p, scope)), + ); } } - // ── Nested object literal builder ───────────────────────────────────────── + // ── Target reference compilation ────────────────────────────────────── - private mergeOverdefinedExpr( - node: { expr?: string; terminal?: boolean }, - wire: Wire, - ): void { - const nextExpr = this.wireToExpr(wire); - const nextIsConstant = isLit(wire); - - if (node.expr == null) { - node.expr = nextExpr; - node.terminal = nextIsConstant; - return; + private compileTargetRef( + target: NodeRef, + _scope: ScopeChain, + outputVar: string, + pathPrefix: string[], + ): string { + // Output wires: target is in the SELF_MODULE with bridge's type/field + if ( + target.module === SELF_MODULE && + target.type === this.bridge.type && + target.field === this.bridge.field + ) { + const fullPath = [...pathPrefix, ...target.path]; + // Ensure parent objects exist for nested paths + if (fullPath.length > 1) { + this.emitEnsurePath(outputVar, fullPath.slice(0, -1)); + } + return `${outputVar}${fullPath.map((p) => `[${jsStr(p)}]`).join("")}`; } - if (node.terminal) return; + // Otherwise it's targeting a tool or something else + return `${outputVar}${target.path.map((p) => `[${jsStr(p)}]`).join("")}`; + } - if (nextIsConstant) { - node.expr = `((__v) => (__v != null ? __v : ${nextExpr}))(${node.expr})`; - node.terminal = true; - return; + private emitEnsurePath(baseVar: string, path: string[]) { + let current = baseVar; + for (const seg of path) { + const next = `${current}[${jsStr(seg)}]`; + this.emit(`${next} ??= {};`); + current = next; } - - node.expr = `(${node.expr} ?? ${nextExpr})`; } + // ── Parallel assignment emission ────────────────────────────────────── + /** - * Build a JavaScript object literal from a set of wires. - * Handles nested paths by creating nested object literals. + * Emit a batch of assignments, parallelizing async items via Promise.all. + * Each item provides the value `expr` and an `assign` function that + * returns the full assignment statement given the resolved value. */ - private buildObjectLiteral( - wires: Wire[], - getPath: (w: Wire) => string[], - indent: number, - ): string { - if (wires.length === 0) return "{}"; - - // Separate root wire (path=[]) from field-specific wires - let rootExpr: string | undefined; - const fieldWires: Wire[] = []; - - for (const w of wires) { - const path = getPath(w); - if (path.length === 0) { - rootExpr = this.wireToExpr(w); + private emitParallelAssignments( + items: { + expr: string; + assign: (valueExpr: string) => string; + locExpr?: string; + }[], + ) { + if (items.length === 0) return; + + const hasAsync = items.some((it) => it.expr.includes("await")); + const asyncItems: typeof items = []; + const syncItems: typeof items = []; + for (const it of items) { + if (it.expr.includes("await")) { + asyncItems.push(it); + } else if (hasAsync && it.expr.includes("throw ")) { + // Sync items that can throw must join the async batch so they + // don't prevent concurrent tool getters from starting. + asyncItems.push(it); } else { - fieldWires.push(w); + syncItems.push(it); } } - // Only a root wire — simple passthrough expression - if (rootExpr !== undefined && fieldWires.length === 0) { - return rootExpr; - } - - // Build tree from field-specific wires - interface TreeNode { - expr?: string; - terminal?: boolean; - children: Map; - } - const root: TreeNode = { children: new Map() }; - - for (const w of fieldWires) { - const path = getPath(w); - let current = root; - for (let i = 0; i < path.length - 1; i++) { - const seg = path[i]!; - if (!current.children.has(seg)) { - current.children.set(seg, { children: new Map() }); - } - current = current.children.get(seg)!; + // For sync items that have loc, wrap in try/catch at the statement level + for (const it of syncItems) { + if (it.locExpr) { + this.emit( + `try { ${it.assign(it.expr)} } catch (__e) { if (__isFatal(__e)) { if (__e && !__e.bridgeLoc) __e.bridgeLoc = ${it.locExpr}; throw __e; } throw __wrapErr(__e, {bridgeLoc:${it.locExpr}}); }`, + ); + } else { + this.emit(it.assign(it.expr)); + } + } + + if (asyncItems.length > 1) { + // Use Promise.all + .catch to wait for all wires to settle + // (including traces) before we propagate the first error — matching + // runtime semantics. Avoids Promise.allSettled wrapper-object allocation. + const batchId = this.parallelBatchCount++; + const settledVar = `__s${batchId}`; + this.emit(`const ${settledVar} = await Promise.all([`); + this.pushIndent(); + for (const it of asyncItems) { + // Strip "await " prefix to access the raw Promise directly, + // avoiding async IIFE closure allocation + const raw = it.expr.startsWith("await ") + ? it.expr.slice(6) + : `(async () => ${it.expr})()`; + this.emit(`${raw}.catch((__e) => __e),`); + } + this.popIndent(); + this.emit(`]);`); + // Re-throw the first rejection (fatal errors first, matching runtime). + // Annotate with bridgeLoc from the per-wire loc metadata when available. + const hasLocs = asyncItems.some((it) => it.locExpr); + if (hasLocs) { + const locsArray = `[${asyncItems.map((it) => it.locExpr || "undefined").join(",")}]`; + this.emit( + `{ const __locs = ${locsArray}; let __fatal, __first, __fi = 0; for (let __i = 0; __i < ${settledVar}.length; __i++) { const __r = ${settledVar}[__i]; if (__r instanceof Error) { if (__isFatal(__r)) { if (!__fatal) { __fatal = __r; __fi = __i; } } else { if (!__first) { __first = __r; __fi = __i; } } } } if (__fatal) { if (__locs[__fi] && !__fatal.bridgeLoc) __fatal.bridgeLoc = __locs[__fi]; throw __fatal; } if (__first) { if (__locs[__fi]) throw __wrapErr(__first, {bridgeLoc:__locs[__fi]}); throw __first; } }`, + ); + } else { + this.emit( + `{ let __fatal, __first; for (const __r of ${settledVar}) { if (__r instanceof Error) { if (__isFatal(__r)) { if (!__fatal) __fatal = __r; } else { if (!__first) __first = __r; } } } if (__fatal) throw __fatal; if (__first) throw __first; }`, + ); } - const lastSeg = path[path.length - 1]!; - if (!current.children.has(lastSeg)) { - current.children.set(lastSeg, { children: new Map() }); + for (let i = 0; i < asyncItems.length; i++) { + this.emit(asyncItems[i]!.assign(`${settledVar}[${i}]`)); + } + } else if (asyncItems.length === 1) { + const it = asyncItems[0]!; + if (it.locExpr) { + // Single async item with loc — wrap assignment in try/catch + this.emit(`try {`); + this.pushIndent(); + this.emit(it.assign(it.expr)); + this.popIndent(); + this.emit( + `} catch (__e) { if (__isFatal(__e)) { if (__e && !__e.bridgeLoc) __e.bridgeLoc = ${it.locExpr}; throw __e; } throw __wrapErr(__e, {bridgeLoc:${it.locExpr}}); }`, + ); + } else { + this.emit(it.assign(it.expr)); } - const node = current.children.get(lastSeg)!; - this.mergeOverdefinedExpr(node, w); } - - // Spread + field overrides: { ...rootExpr, field1: ..., field2: ... } - return this.serializeTreeNode(root, indent, rootExpr); } - private serializeTreeNode( - node: { - children: Map }>; - }, - indent: number, - spreadExpr?: string, + // ── Source chain compilation ────────────────────────────────────────── + + private compileSourceChain( + sources: WireSourceEntry[], + wireCatch: WireCatch | undefined, + scope: ScopeChain, ): string { - const pad = " ".repeat(indent); - const entries: string[] = []; + if (sources.length === 0) return "undefined"; - if (spreadExpr !== undefined) { - entries.push(`${pad}...${spreadExpr}`); + let expr = this.compileExpression(sources[0]!.expr, scope); + + // Fallback chain + for (let i = 1; i < sources.length; i++) { + const src = sources[i]!; + const fbExpr = this.compileExpression(src.expr, scope); + + if (src.gate === "nullish") { + expr = `(${expr} ?? ${fbExpr})`; + } else if (src.gate === "falsy") { + expr = `(${expr} || ${fbExpr})`; + } } - for (const [key, child] of node.children) { - if (child.children.size === 0) { - entries.push( - `${pad}${JSON.stringify(key)}: ${child.expr ?? "undefined"}`, - ); - } else if (child.expr != null) { - entries.push(`${pad}${JSON.stringify(key)}: ${child.expr}`); - } else { - const nested = this.serializeTreeNode(child as typeof node, indent + 2); - entries.push(`${pad}${JSON.stringify(key)}: ${nested}`); + // Catch handler + if (wireCatch) { + const catchExpr = this.compileCatch(wireCatch, scope); + if (expr.includes("await")) { + return `(await (async () => ${expr})().catch(() => ${catchExpr}))`; } + return `(() => { try { return ${expr}; } catch (_e) { return ${catchExpr}; } })()`; } - const innerPad = " ".repeat(indent - 2); - return `{\n${entries.join(",\n")},\n${innerPad}}`; + return expr; } - // ── Overdefinition bypass ─────────────────────────────────────────────── - /** - * Analyze output wires to identify tools that can be conditionally - * skipped ("overdefinition bypass"). - * - * When multiple wires target the same output path, the runtime's - * pull-based model evaluates them in authored order and returns the - * first non-null result — later tools are never called. + * Compile a source chain expression that wraps errors with bridgeLoc. * - * This method detects tools whose output contributions are ALL in - * secondary (non-first) position and returns check expressions that - * the caller uses to wrap the tool call in a null-guarded `if` block. + * For single-source chains, wraps the expression with the source's loc. + * For multi-source (fallback) chains, compiles into a statement block that + * tracks which source was active when the error occurred. * - * Returns a Map from tool trunk key → { checkExprs: string[] }. - * The tool should only be called if ANY check expression is null. + * Returns an expression string (may be an async IIFE). */ - private analyzeOverdefinitionBypass( - outputWires: Wire[], - toolOrder: string[], - forceMap: Map, - ): Map { - const result = new Map(); - - // Step 1: Group scalar output wires by path, preserving authored order. - // Skip root wires (empty path) and element wires (array mapping). - const outputByPath = new Map(); - for (const w of outputWires) { - if (w.to.path.length === 0) continue; - if (isPull(w) && wRef(w).element) continue; - const pathKey = w.to.path.join("."); - const arr = outputByPath.get(pathKey) ?? []; - arr.push(w); - outputByPath.set(pathKey, arr); + private compileSourceChainWithLoc( + sources: WireSourceEntry[], + wireCatch: WireCatch | undefined, + scope: ScopeChain, + ): string { + if (sources.length === 0) return "undefined"; + + // If no source has loc, fall back to the regular compilation + const hasLoc = sources.some((s) => s.expr.loc); + if (!hasLoc) { + return this.compileSourceChain(sources, wireCatch, scope); } - // Step 2: For each overdefined path, track tool positions. - // toolTk → { secondaryPaths, hasPrimary } - const toolInfo = new Map< - string, - { - secondaryPaths: { pathKey: string; priorExpr: string }[]; - hasPrimary: boolean; + // Single source — wrap with its loc + if (sources.length === 1) { + const expr = this.compileExpression(sources[0]!.expr, scope); + const loc = sources[0]!.expr.loc; + const locExpr = loc ? jsLoc(loc) : "undefined"; + + // Fast path: when inside an optimized array loop body, use a comma + // expression to set the loc index before the value is evaluated. The + // caller wraps the entire loop body in a single try/catch. Locs are + // precomputed in an array, so no object allocation per iteration. + if (this.loopLocInfo && !wireCatch && !expr.includes("await")) { + const idx = this.loopLocInfo.locs.length; + this.loopLocInfo.locs.push(locExpr); + return `(${this.loopLocInfo.indexVar} = ${idx}, ${expr})`; } - >(); - // Memoize tool sources referenced in prior chains per tool - const priorToolDeps = new Map>(); + const fatalGuard = `if (__isFatal(__e)) { if (__e && !__e.bridgeLoc) __e.bridgeLoc = ${locExpr}; throw __e; }`; + const catchBody = wireCatch ? this.compileCatch(wireCatch, scope) : ""; - for (const [pathKey, wires] of outputByPath) { - if (wires.length < 2) continue; // no overdefinition + // Adaptive: only use async IIFE when the expression actually awaits + const isAsync = expr.includes("await") || catchBody.includes("await"); + const wrap = isAsync ? "await (async () => {" : "(() => {"; - // Build progressive prior expression chain - let priorExpr: string | null = null; - const priorToolsForPath = new Set(); + if (wireCatch) { + return `${wrap} try { return ${expr}; } catch (__e) { ${fatalGuard} return ${catchBody}; } })()`; + } - for (let i = 0; i < wires.length; i++) { - const w = wires[i]!; - const wireExpr = this.wireToExpr(w); + return `${wrap} try { return ${expr}; } catch (__e) { ${fatalGuard} throw __wrapErr(__e, {bridgeLoc:${locExpr}}); } })()`; + } - // Check if this wire pulls from a tool - if (isPull(w) && !wRef(w).element) { - const srcTk = refTrunkKey(wRef(w)); - if (this.tools.has(srcTk) && !this.defineContainers.has(srcTk)) { - if (!toolInfo.has(srcTk)) { - toolInfo.set(srcTk, { secondaryPaths: [], hasPrimary: false }); - } - const info = toolInfo.get(srcTk)!; + // Multi-source fallback chain — build IIFE with per-entry loc tracking + const firstExpr = this.compileExpression(sources[0]!.expr, scope); + const firstLoc = sources[0]!.expr.loc; - if (i === 0) { - info.hasPrimary = true; - } else { - info.secondaryPaths.push({ - pathKey, - priorExpr: priorExpr!, - }); - // Record which tools are referenced in prior expressions - if (!priorToolDeps.has(srcTk)) - priorToolDeps.set(srcTk, new Set()); - for (const dep of priorToolsForPath) { - priorToolDeps.get(srcTk)!.add(dep); - } - } + const locDecl = `let __loc = ${firstLoc ? jsLoc(firstLoc) : "undefined"};`; + + const tryParts: string[] = []; + tryParts.push(`let __v = ${firstExpr};`); + + let anyAsync = firstExpr.includes("await"); + + for (let i = 1; i < sources.length; i++) { + const src = sources[i]!; + const fbExpr = this.compileExpression(src.expr, scope); + if (fbExpr.includes("await")) anyAsync = true; + const fbLoc = src.expr.loc; + + const cond = src.gate === "nullish" ? "__v == null" : "!__v"; + tryParts.push(`if (${cond}) {`); + if (fbLoc) tryParts.push(` __loc = ${jsLoc(fbLoc)};`); + tryParts.push(` __v = ${fbExpr};`); + tryParts.push(`}`); + } + tryParts.push(`return __v;`); + + const tryBody = tryParts.join(" "); + const multiFatalGuard = `if (__isFatal(__e)) { if (__e && !__e.bridgeLoc) __e.bridgeLoc = __loc; throw __e; }`; + const catchBody = wireCatch ? this.compileCatch(wireCatch, scope) : ""; + if (catchBody.includes("await")) anyAsync = true; + + const wrap = anyAsync ? "await (async () => {" : "(() => {"; + + if (wireCatch) { + return `${wrap} ${locDecl} try { ${tryBody} } catch (__e) { ${multiFatalGuard} return ${catchBody}; } })()`; + } + + return `${wrap} ${locDecl} try { ${tryBody} } catch (__e) { ${multiFatalGuard} throw __wrapErr(__e, {bridgeLoc:__loc}); } })()`; + } + + private compileCatch(wireCatch: WireCatch, scope: ScopeChain): string { + if ("value" in wireCatch) { + return JSON.stringify(wireCatch.value); + } + if ("ref" in wireCatch) { + return this.compileRefExpr(wireCatch.ref, scope); + } + if ("control" in wireCatch) { + return this.compileControlFlow(wireCatch.control); + } + if ("expr" in wireCatch) { + return this.compileExpression(wireCatch.expr, scope); + } + return "undefined"; + } + + // ── Expression compilation ──────────────────────────────────────────── + + private compileExpression(expr: Expression, scope: ScopeChain): string { + switch (expr.type) { + case "ref": + if (expr.safe) { + // Match runtime catchSafe: swallow non-fatal errors, rethrow fatal (panic/abort) + const inner = this.compileRefExpr(expr.ref, scope); + if (inner.includes("await")) { + // Async: use .catch on the async wrapper — avoids try/catch inside async IIFE + return `(await (async () => ${inner})().catch(__catchSafe))`; } + // Sync: lightweight non-async IIFE — no promise overhead + return `(() => { try { return ${inner}; } catch (__e) { return __catchSafe(__e); } })()`; } + return this.compileRefExpr(expr.ref, scope); - // Track tools referenced in this wire (for cascading conditionals) - if (isPull(w) && !wRef(w).element) { - const refTk = refTrunkKey(wRef(w)); - if (this.tools.has(refTk)) priorToolsForPath.add(refTk); - } + case "literal": + return JSON.stringify(expr.value); - // Extend prior expression chain - if (i === 0) { - priorExpr = wireExpr; - } else { - priorExpr = `(${priorExpr} ?? ${wireExpr})`; + case "ternary": + return `(${this.compileExpression(expr.cond, scope)} ? ${this.compileExpression(expr.then, scope)} : ${this.compileExpression(expr.else, scope)})`; + + case "and": + return this.compileAndOr(expr, scope, "and"); + + case "or": + return this.compileAndOr(expr, scope, "or"); + + case "control": + return this.compileControlFlow(expr.control); + + case "array": + return this.compileArrayExpr(expr, scope); + + case "pipe": + return this.compilePipeExpr(expr, scope); + + case "binary": + return this.compileBinaryExpr(expr, scope); + + case "unary": + if (expr.op === "not") { + return `(!${this.compileExpression(expr.operand, scope)})`; } - } + return "undefined"; + + case "concat": + return this.compileConcatExpr(expr, scope); } + } - // Step 3: Build topological order index for dependency checking - const topoIndex = new Map(toolOrder.map((tk, i) => [tk, i])); - - // Step 4: Determine which tools qualify for bypass - for (const [toolTk, info] of toolInfo) { - // Must be fully secondary (no primary contributions) - if (info.hasPrimary) continue; - if (info.secondaryPaths.length === 0) continue; - - // Exclude force tools, catch-guarded tools, internal tools - if (forceMap.has(toolTk)) continue; - if (this.catchGuardedTools.has(toolTk)) continue; - if (this.internalToolKeys.has(toolTk)) continue; - - // Exclude tools with onError in their ToolDef - const tool = this.tools.get(toolTk); - if (tool) { - const toolDef = this.resolveToolDef(tool.toolName); - if (toolDef?.onError) continue; + /** + * Combine a base expression with a path, using __getPath for mixed safe paths. + */ + private emitAccessPath( + base: string, + ref: NodeRef, + startIdx = 0, + forceRootSafe = false, + ): string { + // Static prototype-pollution guard: reject unsafe path segments at compile time + for (let i = startIdx; i < ref.path.length; i++) { + if (UNSAFE_KEYS.has(ref.path[i]!)) { + return `(() => { throw new Error("Unsafe property traversal: " + ${jsStr(ref.path[i]!)}); })()`; } + } + const pathStr = emitPath(ref, startIdx, forceRootSafe, base); + // If emitPath returned a __getPath call (full expression), use it directly + if (pathStr.startsWith("__getPath(")) return pathStr; + return `${base}${pathStr}`; + } - // Check that all prior tool dependencies appear earlier in topological order - const thisIdx = topoIndex.get(toolTk) ?? Infinity; - const deps = priorToolDeps.get(toolTk); - let valid = true; - if (deps) { - for (const dep of deps) { - if ((topoIndex.get(dep) ?? Infinity) >= thisIdx) { - valid = false; - break; - } - } + private compileRefExpr(ref: NodeRef, scope: ScopeChain): string { + // Element references (array iteration) — must resolve BEFORE self-module + // because element refs share the same module/type/field as self-module refs. + if (ref.element) { + const depth = ref.elementDepth ?? 0; + const stackIdx = this.iteratorStack.length - 1 - depth; + if (stackIdx >= 0) { + return this.emitAccessPath(this.iteratorStack[stackIdx]!.iterVar, ref); } - if (!valid) continue; + } - // Check that the tool has no uncaptured output contributions - // (e.g., root wires or element wires that we skipped in analysis) - let hasUncaptured = false; - const capturedPaths = new Set( - info.secondaryPaths.map((sp) => sp.pathKey), - ); - for (const w of outputWires) { - if (!isPull(w)) continue; - if (wRef(w).element) continue; - const srcTk = refTrunkKey(wRef(w)); - if (srcTk !== toolTk) continue; - if (w.to.path.length === 0) { - hasUncaptured = true; - break; - } - const pk = w.to.path.join("."); - if (!capturedPaths.has(pk)) { - hasUncaptured = true; - break; - } + // Local references (aliases) + if (ref.module === "__local" || ref.type === "__local") { + const binding = scope.get(ref.field); + if (binding) { + return this.emitAccessPath(binding.jsExpr, ref); } - if (hasUncaptured) continue; + } - // All checks passed — this tool can be conditionally skipped - const checkExprs = info.secondaryPaths.map((sp) => sp.priorExpr); - const uniqueChecks = [...new Set(checkExprs)]; - result.set(toolTk, { checkExprs: uniqueChecks }); + // Self-module references — in source position these are input reads + if ( + ref.module === SELF_MODULE && + ref.type === this.bridge.type && + ref.field === this.bridge.field + ) { + return this.emitAccessPath("input", ref); } - return result; - } + // Context references + if (ref.module === SELF_MODULE && ref.type === "Context") { + return this.emitAccessPath("context", ref); + } - // ── Dependency analysis & topological sort ──────────────────────────────── + // Const references + if (ref.module === SELF_MODULE && ref.type === "Const") { + return this.emitAccessPath("__consts", ref); + } - /** Get all source trunk keys a wire depends on. */ - private getSourceTrunks(w: Wire): string[] { - const trunks: string[] = []; - const collectTrunk = (ref: NodeRef) => trunks.push(refTrunkKey(ref)); + // Define-type references — inside a define body, source refs to the define + // itself resolve to the define's input (e.g., {type: "Define", field: "userProfile"}) + if (ref.module === SELF_MODULE && ref.type === "Define") { + const marker = scope.get("__defineInput_" + ref.field); + if (marker) { + return this.emitAccessPath(marker.jsExpr, ref); + } + } - if (isPull(w)) { - collectTrunk(wRef(w)); - if (fallbacks(w)) { - for (const fb of fallbacks(w)) { - if (eRef(fb.expr)) collectTrunk(eRef(fb.expr)); - } + // Tool references — resolve through scope chain first, then bridge handles + const refToolName = + ref.module === SELF_MODULE ? ref.field : `${ref.module}.${ref.field}`; + // Check scope chain for tool bindings (handles inner tool refs in ToolDef bodies + // and define bodies where handle name differs from tool name) + const scopeBinding = + scope.get(refToolName) ?? scope.findTool(refToolName, ref.instance); + if (scopeBinding?.kind === "tool") { + if (ref.rootSafe) { + return this.emitAccessPath( + `(await ${scopeBinding.jsExpr}().catch(() => undefined))`, + ref, + ); } - if (hasCatchRef(w)) collectTrunk(catchRef(w)!); + return this.emitAccessPath(`(await ${scopeBinding.jsExpr}())`, ref); } - if (isTern(w)) { - collectTrunk(eRef(wTern(w).cond)); - if ((wTern(w).then as RefExpr).ref) - collectTrunk((wTern(w).then as RefExpr).ref); - if ((wTern(w).else as RefExpr).ref) - collectTrunk((wTern(w).else as RefExpr).ref); + + const handle = this.findSourceHandle(ref, scope); + if (handle) { + const binding = scope.get(handle); + if (binding?.kind === "tool") { + if (ref.rootSafe) { + // Error suppression via ?. — swallow tool errors → undefined + return this.emitAccessPath( + `(await ${binding.jsExpr}().catch(() => undefined))`, + ref, + ); + } + return this.emitAccessPath(`(await ${binding.jsExpr}())`, ref); + } + if (binding) { + return this.emitAccessPath(binding.jsExpr, ref); + } } - if (isAndW(w)) { - collectTrunk(eRef(wAndOr(w).left)); - if (eRef(wAndOr(w).right)) collectTrunk(eRef(wAndOr(w).right)); + + // Define references — module starts with "__define_" + if (ref.module.startsWith("__define_")) { + const defineHandle = ref.module.substring("__define_".length); + const defineBinding = scope.get(defineHandle); + if (defineBinding?.kind === "define") { + return this.emitAccessPath(`(await ${defineBinding.jsExpr}())`, ref); + } } - if (isOrW(w)) { - collectTrunk(eRef(wAndOr(w).left)); - if (eRef(wAndOr(w).right)) collectTrunk(eRef(wAndOr(w).right)); + + // Fallback: direct tool access + const toolKey = + ref.module === SELF_MODULE ? ref.field : `${ref.module}.${ref.field}`; + if (ref.rootSafe) { + return this.emitAccessPath( + `(await tools[${jsStr(toolKey)}]().catch(() => undefined))`, + ref, + ); } - return trunks; + return this.emitAccessPath(`(await tools[${jsStr(toolKey)}]())`, ref); } /** - * Returns true if the tool can safely participate in a Promise.all() batch: - * plain normal-mode call with no bypass condition, no catch guard, no - * fire-and-forget, no onError ToolDef, and not an internal (sync) tool. + * Compute the runtime-compatible node key for a tool getter. + * Format: `{module}:Tools:{name}:{instance}` matching the runtime's toolKey(). */ - private isParallelizableTool( - tk: string, - conditionalTools: Map, - forceMap: Map, - ): boolean { - if (this.defineContainers.has(tk)) return false; - if (this.internalToolKeys.has(tk)) return false; - if (this.catchGuardedTools.has(tk)) return false; - if (forceMap.get(tk)?.catchError) return false; - if (conditionalTools.has(tk)) return false; - const tool = this.tools.get(tk); - if (!tool) return false; - const toolDef = this.resolveToolDef(tool.toolName); - if (toolDef?.onError) return false; - // Tools with ToolDef-level tool deps need their deps emitted first - if (toolDef?.handles.some((h) => h.kind === "tool")) return false; - return true; + private toolNodeKey(handleName: string, binding: ScopeBinding): string { + const toolName = binding.toolName ?? handleName; + // Count instance by iterating handles with same tool name + let instance = 0; + for (const h of this.bridge.handles) { + if (h.kind === "tool" && h.name === toolName) { + instance++; + if (h.handle === handleName) break; + } + } + return `_:Tools:${toolName}:${instance || 1}`; + } + + private findSourceHandle( + ref: NodeRef, + _scope: ScopeChain, + ): string | undefined { + // Look through scope bindings for a match. + // When multiple handles share the same tool name, use `instance` to + // pick the correct one. + let instanceCount = 0; + for (const h of this.bridge.handles) { + if (h.kind === "tool") { + const refName = + ref.module === SELF_MODULE ? ref.field : `${ref.module}.${ref.field}`; + const matches = refName === h.name; + if (matches) { + instanceCount++; + if (!ref.instance || instanceCount === ref.instance) { + return h.handle; + } + } + } + if (h.kind === "input") { + if ( + ref.module === SELF_MODULE && + ref.type === this.bridge.type && + ref.field === this.bridge.field + ) { + return h.handle; + } + } + } + return undefined; } + // ── Array expression ────────────────────────────────────────────────── + /** - * Build a raw `__call(__fnX, {...}, ...)` expression suitable for use - * inside `Promise.all([...])` — no `await`, no `const` declaration. - * Only call this for tools where `isParallelizableTool` returns true. + * Compile an array mapping expression as a wire assignment. + * + * Uses a for-loop to support continue/break control flow sentinels. + * When the loop body contains a tool with `batch` metadata, the loop + * is compiled concurrently (Promise.all + map) so that microtask-based + * batch queueing can accumulate all per-element tool calls into a single + * batched invocation. */ - private buildNormalCallExpr(tool: ToolInfo, bridgeWires: Wire[]): string { - const toolDef = this.resolveToolDef(tool.toolName); - - if (!toolDef) { - const inputObj = this.buildObjectLiteral( - bridgeWires, - (w) => w.to.path, - 4, + private compileArrayAssignment( + expr: Extract, + targetExpr: string, + scope: ScopeChain, + absolutePrefix: string[] = [], + ) { + const depth = this.arrayDepthCounter++; + const iterVar = `__el_${depth}`; + const outVar = `__elOut_${depth}`; + const resultVar = `__result_${depth}`; + const arrVar = `__arr_${depth}`; + + // Check if this loop body uses any tool — if so, we need batch support + const hasTool = expr.body.some( + (s) => s.kind === "with" && s.binding.kind === "tool", + ); + + // Compile the source iterable expression + const sourceExpr = this.compileExpression(expr.source, scope); + + // Preserve null/undefined source — only map when array-like + this.emit(`const ${arrVar} = ${sourceExpr};`); + this.emit(`if (${arrVar} == null) {`); + this.pushIndent(); + this.emit(`${targetExpr} = ${arrVar};`); + this.popIndent(); + this.emit(`} else {`); + this.pushIndent(); + + if (hasTool) { + this.needsBatchHelper = true; + // Emit batch queue shared across all iterations of this loop + const batchQueueVar = `__bq_${depth}`; + this.emit(`const ${batchQueueVar} = new Map();`); + this.currentBatchQueue = batchQueueVar; + + // Concurrent loop: Promise.all + map for batch support. + // Each element runs concurrently; batch tools queue their calls + // via microtask and flush once all elements have queued. + this.emit( + `const ${resultVar} = await Promise.all(${arrVar}.map(async (${iterVar}) => {`, ); - return this.syncAwareCallNoAwait(tool.toolName, inputObj, tool.trunkKey); - } + this.pushIndent(); + this.emit(`const ${outVar} = {};`); + } else { + this.emit(`const ${resultVar} = [];`); + } + + // Static analysis: does this loop body use break/continue sentinels? + const hasCtrlFlow = !hasTool && bodyHasControlFlow(expr.body); + + // Optimized sync path: consolidate per-wire IIFEs into a single try/catch + // with a shared __loc variable. Hoist try/catch OUTSIDE the loop so V8 + // doesn't enter/exit the catch frame per element. + const useLoopLoc = !hasTool && !hasCtrlFlow; + const locVar = useLoopLoc ? `__li_${depth}` : undefined; + const locsVar = useLoopLoc ? `__locs_${depth}` : undefined; + const prevLoopLocInfo = this.loopLocInfo; + const locCollector: string[] = []; + if (locVar && locsVar) { + this.emit(`let ${locVar} = 0;`); + this.emit(`try {`); + this.pushIndent(); + this.loopLocInfo = { indexVar: locVar, locsVar, locs: locCollector }; + } + + if (!hasTool) { + this.emit(`for (const ${iterVar} of ${arrVar}) {`); + this.pushIndent(); + this.emit(`const ${outVar} = {};`); + } + + // Create child scope — iterator may shadow parent bindings (scope rules) + const childScope = scope.child(); + childScope.set(expr.iteratorName, { + kind: "iterator", + jsExpr: iterVar, + }); - const fnName = toolDef.fn ?? tool.toolName; - const inputEntries = new Map(); - for (const tw of toolDef.wires) { - if (isLit(tw) && !isTern(tw)) { - const target = tw.to.path.join("."); - inputEntries.set( - target, - ` ${JSON.stringify(target)}: ${emitCoerced(wVal(tw))}`, - ); - } - } - for (const tw of toolDef.wires) { - if (isPull(tw)) { - const target = tw.to.path.join("."); - const expr = this.resolveToolWireSource(tw, toolDef); - inputEntries.set(target, ` ${JSON.stringify(target)}: ${expr}`); - } - } - for (const bw of bridgeWires) { - const path = bw.to.path; - if (path.length >= 1) { - const key = path[0]!; - inputEntries.set( - key, - ` ${JSON.stringify(key)}: ${this.wireToExpr(bw)}`, + // Push iterator stack for element ref resolution + this.iteratorStack.push({ iterVar, outVar }); + + // Compile body using the child scope with element output as outputVar + this.compileBody(expr.body, childScope, outVar, [], absolutePrefix); + + this.iteratorStack.pop(); + this.currentBatchQueue = undefined; + this.loopLocInfo = prevLoopLocInfo; + + if (hasTool) { + // Concurrent path: return element output from map callback + this.emit(`return ${outVar};`); + this.popIndent(); + this.emit(`}));`); + this.emit(`${targetExpr} = ${resultVar};`); + } else if (!hasCtrlFlow) { + // Optimized sequential path: no control flow → skip sentinel check + this.emit(`${resultVar}.push(${outVar});`); + this.popIndent(); + this.emit(`}`); // close for loop + if (locVar && locsVar) { + // Close the try block hoisted outside the loop + const locsExpr = `[${locCollector.join(",")}]`; + this.popIndent(); + this.emit( + `} catch (__e) { if (__isFatal(__e)) { if (__e && !__e.bridgeLoc) __e.bridgeLoc = ${locsExpr}[${locVar}]; throw __e; } throw __wrapErr(__e, {bridgeLoc:${locsExpr}[${locVar}]}); }`, ); } + this.emit(`${targetExpr} = ${resultVar};`); + } else { + // Sequential path with control flow: check for sentinels + const sigVar = `__sig_${depth}`; + this.emit( + `const ${sigVar} = Object.values(${outVar}).find(__v => __v === Symbol.for("BRIDGE_BREAK") || __v === Symbol.for("BRIDGE_CONTINUE") || (__v && typeof __v === 'object' && (__v.__bridgeControl === 'break' || __v.__bridgeControl === 'continue')));`, + ); + this.emit(`if (${sigVar} != null) {`); + this.pushIndent(); + // Single-level symbols: break/continue this loop directly + this.emit(`if (${sigVar} === Symbol.for("BRIDGE_BREAK")) break;`); + this.emit( + `if (${sigVar} === Symbol.for("BRIDGE_CONTINUE")) { continue; }`, + ); + // Multi-level: decrement and propagate as a value on the result array + this.emit( + `const __next = ${sigVar}.levels <= 2 ? (${sigVar}.__bridgeControl === 'break' ? Symbol.for("BRIDGE_BREAK") : Symbol.for("BRIDGE_CONTINUE")) : { __bridgeControl: ${sigVar}.__bridgeControl, levels: ${sigVar}.levels - 1 };`, + ); + this.emit( + `if (${sigVar}.__bridgeControl === 'break') { ${resultVar}.__propagate = __next; break; }`, + ); + this.emit(`${resultVar}.__propagate = __next; continue;`); + this.popIndent(); + this.emit("}"); + this.emit(`${resultVar}.push(${outVar});`); + this.popIndent(); + this.emit(`}`); + // If a multi-level signal was propagated, return it instead of the result + this.emit( + `if (${resultVar}.__propagate != null) { ${targetExpr} = ${resultVar}.__propagate; } else { ${targetExpr} = ${resultVar}; }`, + ); } - const inputParts = [...inputEntries.values()]; - const inputObj = - inputParts.length > 0 ? `{\n${inputParts.join(",\n")},\n }` : "{}"; - return this.syncAwareCallNoAwait( - fnName, - inputObj, - tool.trunkKey, - tool.toolName, + this.popIndent(); + this.emit(`}`); + } + + private compileArrayExpr( + _expr: Extract, + _scope: ScopeChain, + ): never { + throw new BridgeCompilerIncompatibleError( + `${this.bridge.type}.${this.bridge.field}`, + "Array mapping is not yet supported by the new compiler.", ); } - private topologicalLayers(toolWires: Map): string[][] { - const toolKeys = [...this.tools.keys()]; - const allKeys = [...toolKeys, ...this.defineContainers]; - const adj = new Map>(); + // ── Pipe expression ─────────────────────────────────────────────────── - for (const key of allKeys) { - adj.set(key, new Set()); - } + private compilePipeExpr( + expr: Extract, + scope: ScopeChain, + ): string { + const sourceExpr = this.compileExpression(expr.source, scope); + const pipePath = expr.path && expr.path.length > 0 ? expr.path : ["in"]; - for (const key of allKeys) { - const wires = toolWires.get(key) ?? []; - for (const w of wires) { - for (const src of this.getSourceTrunks(w)) { - if (src === key) { - const err = new BridgePanicError( - `Circular dependency detected: "${key}" depends on itself`, - ); - (err as any).bridgeLoc = isPull(w) ? wRefLoc(w) : w.loc; - throw err; + // Look up tool binding for the pipe handle + const binding = scope.get(expr.handle); + if (!binding || binding.kind !== "tool") { + throw new BridgeCompilerIncompatibleError( + `${this.bridge.type}.${this.bridge.field}`, + `Pipe handle "${expr.handle}" is not a tool binding.`, + ); + } + const toolName = binding.toolName ?? expr.handle; + // Resolve fn through ToolDef extends chain + const toolDef = this.resolveToolDef(toolName); + const fnName = toolDef?.fn ?? toolName; + const toolFnExpr = binding.toolFnExpr ?? `tools[${jsStr(fnName)}]`; + + // Check if this tool has ToolDef defaults or bridge input wires + const hasToolDefDefaults = toolDef && toolDef.body.length > 0; + + // Check for bridge-level wires targeting this tool handle + const hasBridgeWires = this.bridge.body.some( + (s) => + s.kind === "wire" && + this.findTargetHandle(s.target, scope) === expr.handle, + ); + + if (!hasToolDefDefaults && !hasBridgeWires) { + // Simple case — no ToolDef defaults or bridge wires, direct pipe call + const inputObj = `{ ${pipePath.map((p) => `${jsStr(p)}: ${sourceExpr}`).join(", ")} }`; + return `(await __pipe(${toolFnExpr}, ${jsStr(toolName)}, ${jsStr(fnName)}, ${inputObj}))`; + } + + // Complex case — merge ToolDef defaults + pipe source + // Emit as IIFE to build the input object + const parts: string[] = []; + parts.push("(await (async () => {"); + parts.push(" const __pipeInput = {};"); + + // Compile ToolDef self-wire defaults inline (if toolDef exists) + const defScope = scope.child(); + if (toolDef) { + for (const stmt of toolDef.body) { + if (stmt.kind === "with") { + // Just register in scope — actual bindings come from parent + if (stmt.binding.kind === "const") { + defScope.set(stmt.binding.handle, { + kind: "const", + jsExpr: "__consts", + }); + } else if (stmt.binding.kind === "context") { + defScope.set(stmt.binding.handle, { + kind: "context", + jsExpr: "context", + }); } - if (adj.has(src)) { - adj.get(src)!.add(key); + } + } + for (const stmt of toolDef.body) { + if (stmt.kind === "wire" && stmt.target.instance == null) { + const value = this.compileSourceChain( + stmt.sources, + stmt.catch, + defScope, + ); + const path = stmt.target.path; + if (path.length === 0) { + parts.push(` Object.assign(__pipeInput, ${value});`); + } else { + for (let i = 0; i < path.length - 1; i++) { + const pp = path + .slice(0, i + 1) + .map((p) => `[${jsStr(p)}]`) + .join(""); + parts.push(` __pipeInput${pp} ??= {};`); + } + parts.push( + ` __pipeInput${path.map((p) => `[${jsStr(p)}]`).join("")} = ${value};`, + ); } + parts.push( + ` __pipeInput${path.map((p) => `[${jsStr(p)}]`).join("")} = ${value};`, + ); } } } - const inDegree = new Map(); - for (const key of allKeys) inDegree.set(key, 0); - for (const [, neighbors] of adj) { - for (const n of neighbors) { - inDegree.set(n, (inDegree.get(n) ?? 0) + 1); + // Bridge-level wires targeting this tool handle override ToolDef defaults + for (const stmt of this.bridge.body) { + if (stmt.kind !== "wire") continue; + const handleName = this.findTargetHandle(stmt.target, scope); + if (handleName !== expr.handle) continue; + const value = this.compileSourceChain(stmt.sources, stmt.catch, scope); + const path = stmt.target.path; + if (path.length === 0) { + parts.push(` Object.assign(__pipeInput, ${value});`); + } else { + for (let i = 0; i < path.length - 1; i++) { + const pp = path + .slice(0, i + 1) + .map((p) => `[${jsStr(p)}]`) + .join(""); + parts.push(` __pipeInput${pp} ??= {};`); + } + parts.push( + ` __pipeInput${path.map((p) => `[${jsStr(p)}]`).join("")} = ${value};`, + ); } } - const layers: string[][] = []; - let frontier = allKeys.filter((k) => (inDegree.get(k) ?? 0) === 0); - - while (frontier.length > 0) { - layers.push([...frontier]); - const next: string[] = []; - for (const node of frontier) { - for (const neighbor of adj.get(node) ?? []) { - const newDeg = (inDegree.get(neighbor) ?? 1) - 1; - inDegree.set(neighbor, newDeg); - if (newDeg === 0) next.push(neighbor); - } - } - frontier = next; + // Pipe source overrides last + for (let i = 0; i < pipePath.length - 1; i++) { + const pp = pipePath + .slice(0, i + 1) + .map((p) => `[${jsStr(p)}]`) + .join(""); + parts.push(` __pipeInput${pp} ??= {};`); } + parts.push( + ` __pipeInput${pipePath.map((p) => `[${jsStr(p)}]`).join("")} = ${sourceExpr};`, + ); + parts.push( + ` return __pipe(${toolFnExpr}, ${jsStr(toolName)}, ${jsStr(fnName)}, __pipeInput);`, + ); + parts.push("})())"); - return layers; + return parts.join("\n"); } - private topologicalSort(toolWires: Map): string[] { - // All node keys: tools + define containers - const toolKeys = [...this.tools.keys()]; - const allKeys = [...toolKeys, ...this.defineContainers]; - const adj = new Map>(); + // ── And/Or expression ───────────────────────────────────────────────── - for (const key of allKeys) { - adj.set(key, new Set()); - } + private compileAndOr( + expr: Extract, + scope: ScopeChain, + kind: "and" | "or", + ): string { + const leftExpr = this.compileExpression(expr.left, scope); + const rightExpr = this.compileExpression(expr.right, scope); - // Build adjacency: src → dst edges (deduplicated via Set) - for (const key of allKeys) { - const wires = toolWires.get(key) ?? []; - for (const w of wires) { - for (const src of this.getSourceTrunks(w)) { - if (src === key) { - const err = new BridgePanicError( - `Circular dependency detected: "${key}" depends on itself`, - ); - (err as any).bridgeLoc = isPull(w) ? wRefLoc(w) : w.loc; - throw err; - } - if (adj.has(src)) { - adj.get(src)!.add(key); - } - } + // Fast path — no safe flags + if (!expr.leftSafe && !expr.rightSafe) { + // Bridge and/or return Boolean values, unlike JS && / || + if (kind === "and") { + return `(${leftExpr} ? Boolean(${rightExpr}) : false)`; } + return `(${leftExpr} ? true : Boolean(${rightExpr}))`; } - // Compute in-degree from the adjacency sets (avoids double-counting) - const inDegree = new Map(); - for (const key of allKeys) inDegree.set(key, 0); - for (const [, neighbors] of adj) { - for (const n of neighbors) { - inDegree.set(n, (inDegree.get(n) ?? 0) + 1); - } + // Safe flags present — use IIFE with try/catch via preamble __catchSafe + const hasAwait = leftExpr.includes("await") || rightExpr.includes("await"); + const parts: string[] = []; + parts.push(hasAwait ? "(await (async () => {" : "(() => {"); + + if (expr.leftSafe) { + parts.push( + ` let __l; try { __l = ${leftExpr}; } catch (__e) { __l = __catchSafe(__e); }`, + ); + } else { + parts.push(` const __l = ${leftExpr};`); } - // Kahn's algorithm - const queue: string[] = []; - for (const [key, deg] of inDegree) { - if (deg === 0) queue.push(key); + if (kind === "and") { + // and: if left falsy → false; else evaluate right + parts.push(" if (!__l) return false;"); + } else { + // or: if left truthy → true; else evaluate right + parts.push(" if (__l) return true;"); + } + + if (expr.rightSafe) { + parts.push( + ` let __r; try { __r = ${rightExpr}; } catch (__e) { __r = __catchSafe(__e); }`, + ); + parts.push(" return Boolean(__r);"); + } else { + parts.push(` return Boolean(${rightExpr});`); } - const sorted: string[] = []; - while (queue.length > 0) { - const node = queue.shift()!; - sorted.push(node); - for (const neighbor of adj.get(node) ?? []) { - const newDeg = (inDegree.get(neighbor) ?? 1) - 1; - inDegree.set(neighbor, newDeg); - if (newDeg === 0) queue.push(neighbor); + parts.push(hasAwait ? "})())" : "})()"); + return parts.join("\n"); + } + + // ── Binary expression ───────────────────────────────────────────────── + + private compileBinaryExpr( + expr: Extract, + scope: ScopeChain, + ): string { + const left = this.compileExpression(expr.left, scope); + const right = this.compileExpression(expr.right, scope); + + const opMap: Record = { + add: "+", + sub: "-", + mul: "*", + div: "/", + eq: "===", + neq: "!==", + gt: ">", + gte: ">=", + lt: "<", + lte: "<=", + }; + + const jsOp = opMap[expr.op]; + if (!jsOp) return "undefined"; + const isArithmetic = + expr.op === "add" || + expr.op === "sub" || + expr.op === "mul" || + expr.op === "div"; + // Parallelize when both sides contain await to avoid sequential bottleneck + if (left.includes("await") && right.includes("await")) { + const rawL = left.startsWith("await ") + ? left.slice(6) + : `(async () => ${left})()`; + const rawR = right.startsWith("await ") + ? right.slice(6) + : `(async () => ${right})()`; + if (isArithmetic) { + return `((__b) => __b[0] == null || __b[1] == null ? null : __b[0] ${jsOp} __b[1])(await Promise.all([${rawL}, ${rawR}]))`; + } + return `((__b) => __b[0] ${jsOp} __b[1])(await Promise.all([${rawL}, ${rawR}]))`; + } + if (isArithmetic) { + return `((__a, __b) => __a == null || __b == null ? null : __a ${jsOp} __b)(${left}, ${right})`; + } + return `(${left} ${jsOp} ${right})`; + } + + // ── Concat expression ───────────────────────────────────────────────── + + private compileConcatExpr( + expr: Extract, + scope: ScopeChain, + ): string { + const compiled = expr.parts.map((p) => this.compileExpression(p, scope)); + const asyncParts = compiled.filter((c) => c.includes("await")); + if (asyncParts.length > 1) { + // Parallelize async parts to avoid sequential await bottleneck + const syncParts: string[] = []; + const asyncIndices: number[] = []; + for (let i = 0; i < compiled.length; i++) { + if (compiled[i]!.includes("await")) asyncIndices.push(i); + } + const batchId = this.parallelBatchCount++; + const resolved = `__cp${batchId}`; + // Pre-resolve all async parts in parallel + this.emit( + `const ${resolved} = await Promise.all([${asyncIndices.map((i) => `(async () => ${compiled[i]!})()`).join(", ")}]);`, + ); + let asyncIdx = 0; + for (let i = 0; i < compiled.length; i++) { + if (compiled[i]!.includes("await")) { + syncParts.push(`__str(${resolved}[${asyncIdx++}])`); + } else { + syncParts.push(`__str(${compiled[i]!})`); + } } + return `(${syncParts.join(" + ")})`; } + const parts = compiled.map((c) => `__str(${c})`); + return `(${parts.join(" + ")})`; + } + + // ── Control flow ────────────────────────────────────────────────────── - if (sorted.length !== allKeys.length) { - const err = new Error("Circular dependency detected in tool calls"); - err.name = "BridgePanicError"; - throw err; + private compileControlFlow(ctrl: { + kind: string; + message?: string; + levels?: number; + }): string { + const levels = + Number.isInteger(ctrl.levels) && (ctrl.levels as number) > 0 + ? (ctrl.levels as number) + : 1; + switch (ctrl.kind) { + case "throw": + return `(() => { throw new Error(${jsStr(ctrl.message ?? "")}); })()`; + case "panic": + return `(() => { throw new (__opts?.__BridgePanicError ?? Error)(${jsStr(ctrl.message ?? "")}); })()`; + case "continue": + return levels <= 1 + ? `Symbol.for("BRIDGE_CONTINUE")` + : `({ __bridgeControl: "continue", levels: ${levels} })`; + case "break": + return levels <= 1 + ? `Symbol.for("BRIDGE_BREAK")` + : `({ __bridgeControl: "break", levels: ${levels} })`; + default: + return "undefined"; } + } +} + +// ── Public API ────────────────────────────────────────────────────────────── + +/** + * Compile a single bridge operation into a standalone async JavaScript function. + * + * The generated function uses a lazy-getter / pull-based model: + * - Tools are wrapped in memoized async getters + * - Output wires pull data on demand + * - Scopes are JS closures with lexical binding + */ +export function compileBridge( + document: BridgeDocument, + options: CompileOptions, +): CompileResult { + const { operation } = options; + const dotIdx = operation.indexOf("."); + if (dotIdx === -1) { + throw new Error( + `Invalid operation: "${operation}", expected "Type.field".`, + ); + } + const type = operation.substring(0, dotIdx); + const field = operation.substring(dotIdx + 1); + + const bridge = document.instructions.find( + (i): i is Bridge => + i.kind === "bridge" && i.type === type && i.field === field, + ); + if (!bridge) { + throw new Error(`No bridge definition found for operation: ${operation}`); + } + + // Collect const definitions from the document + const constDefs = new Map(); + for (const inst of document.instructions) { + if (inst.kind === "const") constDefs.set(inst.name, inst.value); + } + + // Collect tool definitions from the document + const toolDefs = new Map(); + for (const inst of document.instructions) { + if (inst.kind === "tool") toolDefs.set(inst.name, inst); + } - return sorted; + // Collect define definitions from the document + const defineDefs = new Map(); + for (const inst of document.instructions) { + if (inst.kind === "define") defineDefs.set(inst.name, inst); } + + const ctx = new CodegenContext( + bridge, + constDefs, + toolDefs, + defineDefs, + options.requestedFields, + ); + return ctx.compile(); } diff --git a/packages/bridge-compiler/src/execute-bridge.ts b/packages/bridge-compiler/src/execute-bridge.ts index e8844f70..e6a35d2f 100644 --- a/packages/bridge-compiler/src/execute-bridge.ts +++ b/packages/bridge-compiler/src/execute-bridge.ts @@ -20,6 +20,8 @@ import { BridgeRuntimeError, BridgeTimeoutError, attachBridgeErrorDocumentContext, + isFatalError, + wrapBridgeRuntimeError, executeBridge as executeCoreBridge, } from "@stackables/bridge-core"; import { std as bundledStd } from "@stackables/bridge-stdlib"; @@ -110,6 +112,17 @@ type BridgeFn = ( __BridgeAbortError?: new (...args: any[]) => Error; __BridgeTimeoutError?: new (...args: any[]) => Error; __BridgeRuntimeError?: new (...args: any[]) => Error; + __wrapBridgeRuntimeError?: ( + err: unknown, + options?: { + bridgeLoc?: { + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; + }; + }, + ) => Error; }, ) => Promise; @@ -309,6 +322,7 @@ export async function executeBridge( __BridgeAbortError: BridgeAbortError, __BridgeTimeoutError: BridgeTimeoutError, __BridgeRuntimeError: BridgeRuntimeError, + __wrapBridgeRuntimeError: wrapBridgeRuntimeError, __trace: tracer ? ( toolDefName: string, @@ -344,11 +358,17 @@ export async function executeBridge( try { data = await fn(input, flatTools, context, opts); } catch (err) { - if (err && typeof err === "object") { - (err as { executionTraceId?: bigint }).executionTraceId = 0n; - (err as { traces?: ToolTrace[] }).traces = tracer?.traces ?? []; + if (isFatalError(err)) { + if (err && typeof err === "object") { + (err as { executionTraceId?: bigint }).executionTraceId = 0n; + (err as { traces?: ToolTrace[] }).traces = tracer?.traces ?? []; + } + throw attachBridgeErrorDocumentContext(err, document); } - throw attachBridgeErrorDocumentContext(err, document); + const wrapped = wrapBridgeRuntimeError(err); + wrapped.executionTraceId = 0n; + wrapped.traces = tracer?.traces ?? []; + throw attachBridgeErrorDocumentContext(wrapped, document); } return { data: data as T, diff --git a/packages/bridge-compiler/test/fuzz-compile.fuzz.ts b/packages/bridge-compiler/test/fuzz-compile.fuzz.ts index d1ea7caf..c14db8f4 100644 --- a/packages/bridge-compiler/test/fuzz-compile.fuzz.ts +++ b/packages/bridge-compiler/test/fuzz-compile.fuzz.ts @@ -6,8 +6,8 @@ import type { Bridge, BridgeDocument, NodeRef, - Wire, WireSourceEntry, + WireStatement, } from "@stackables/bridge-core"; import { executeBridge as executeRuntime } from "@stackables/bridge-core"; import { @@ -72,7 +72,7 @@ function outputRef(type: string, field: string, path: string[]): NodeRef { }; } -const wireArb = (type: string, field: string): fc.Arbitrary => { +const wireArb = (type: string, field: string): fc.Arbitrary => { const toArb = pathArb.map((path) => outputRef(type, field, path)); const fromArb = pathArb.map((path) => inputRef(type, field, path)); @@ -96,8 +96,9 @@ const wireArb = (type: string, field: string): fc.Arbitrary => { return fc.oneof( // 1. Constant Wires fc.tuple(toArb, constantValueArb).map( - ([to, value]): Wire => ({ - to, + ([target, value]): WireStatement => ({ + kind: "wire", + target, sources: [{ expr: { type: "literal", value } }], }), ), @@ -110,12 +111,12 @@ const wireArb = (type: string, field: string): fc.Arbitrary => { fc.array(fallbackSourceArb, { minLength: 0, maxLength: 2 }), fc.option(constantValueArb, { nil: undefined }), ) - .map(([from, to, fallbacks, catchVal]): Wire => { + .map(([from, target, fallbacks, catchVal]): WireStatement => { const sources: WireSourceEntry[] = [ { expr: { type: "ref", ref: from } }, ...fallbacks, ]; - const wire: Wire = { to, sources }; + const wire: WireStatement = { kind: "wire", target, sources }; if (catchVal !== undefined) wire.catch = { value: catchVal }; return wire; }), @@ -129,8 +130,9 @@ const wireArb = (type: string, field: string): fc.Arbitrary => { fc.option(constantValueArb, { nil: undefined }), ) .map( - ([cond, to, thenVal, elseVal]): Wire => ({ - to, + ([cond, target, thenVal, elseVal]): WireStatement => ({ + kind: "wire", + target, sources: [ { expr: { @@ -154,8 +156,9 @@ const wireArb = (type: string, field: string): fc.Arbitrary => { fc .tuple(fromArb, toArb, fc.option(constantValueArb, { nil: undefined })) .map( - ([left, to, rightVal]): Wire => ({ - to, + ([left, target, rightVal]): WireStatement => ({ + kind: "wire", + target, sources: [ { expr: { @@ -175,8 +178,9 @@ const wireArb = (type: string, field: string): fc.Arbitrary => { fc .tuple(fromArb, toArb, fc.option(constantValueArb, { nil: undefined })) .map( - ([left, to, rightVal]): Wire => ({ - to, + ([left, target, rightVal]): WireStatement => ({ + kind: "wire", + target, sources: [ { expr: { @@ -194,20 +198,22 @@ const wireArb = (type: string, field: string): fc.Arbitrary => { ); }; -const flatWireArb = (type: string, field: string): fc.Arbitrary => { +const flatWireArb = (type: string, field: string): fc.Arbitrary => { const toArb = flatPathArb.map((path) => outputRef(type, field, path)); const fromArb = flatPathArb.map((path) => inputRef(type, field, path)); return fc.oneof( fc.tuple(toArb, constantValueArb).map( - ([to, value]): Wire => ({ - to, + ([target, value]): WireStatement => ({ + kind: "wire", + target, sources: [{ expr: { type: "literal", value } }], }), ), fc.tuple(fromArb, toArb).map( - ([from, to]): Wire => ({ - to, + ([from, target]): WireStatement => ({ + kind: "wire", + target, sources: [{ expr: { type: "ref", ref: from } }], }), ), @@ -228,7 +234,7 @@ const bridgeArb: fc.Arbitrary = fc { kind: "input", handle: "i" } as const, { kind: "output", handle: "o" } as const, ]), - wires: fc.array(wireArb(type, field), { minLength: 1, maxLength: 20 }), + body: fc.array(wireArb(type, field), { minLength: 1, maxLength: 20 }), }), ); @@ -246,10 +252,10 @@ const flatBridgeArb: fc.Arbitrary = fc { kind: "input", handle: "i" } as const, { kind: "output", handle: "o" } as const, ]), - wires: fc.uniqueArray(flatWireArb(type, field), { + body: fc.uniqueArray(flatWireArb(type, field), { minLength: 1, maxLength: 20, - selector: (wire) => wire.to.path.join("."), + selector: (wire) => wire.target.path.join("."), }), }), ); @@ -417,7 +423,7 @@ const fallbackHeavyBridgeArb: fc.Arbitrary = fc { kind: "input", handle: "i" } as const, { kind: "output", handle: "o" } as const, ]), - wires: fc.uniqueArray( + body: fc.uniqueArray( fc .tuple( flatPathArb.map((path) => inputRef(type, field, path)), @@ -444,8 +450,9 @@ const fallbackHeavyBridgeArb: fc.Arbitrary = fc constantValueArb, ) .map( - ([from, to, fallbacks, catchVal]): Wire => ({ - to, + ([from, target, fallbacks, catchVal]): WireStatement => ({ + kind: "wire", + target, sources: [{ expr: { type: "ref", ref: from } }, ...fallbacks], catch: { value: catchVal }, }), @@ -453,7 +460,7 @@ const fallbackHeavyBridgeArb: fc.Arbitrary = fc { minLength: 1, maxLength: 20, - selector: (wire) => wire.to.path.join("."), + selector: (wire) => wire.target.path.join("."), }, ), }), @@ -476,7 +483,7 @@ const logicalBridgeArb: fc.Arbitrary = fc { kind: "input", handle: "i" } as const, { kind: "output", handle: "o" } as const, ]), - wires: fc.uniqueArray( + body: fc.uniqueArray( fc.oneof( // Ternary fc @@ -487,8 +494,9 @@ const logicalBridgeArb: fc.Arbitrary = fc fc.option(constantValueArb, { nil: undefined }), ) .map( - ([cond, to, thenVal, elseVal]): Wire => ({ - to, + ([cond, target, thenVal, elseVal]): WireStatement => ({ + kind: "wire", + target, sources: [ { expr: { @@ -515,8 +523,9 @@ const logicalBridgeArb: fc.Arbitrary = fc fc.option(constantValueArb, { nil: undefined }), ) .map( - ([left, to, rightVal]): Wire => ({ - to, + ([left, target, rightVal]): WireStatement => ({ + kind: "wire", + target, sources: [ { expr: { @@ -539,8 +548,9 @@ const logicalBridgeArb: fc.Arbitrary = fc fc.option(constantValueArb, { nil: undefined }), ) .map( - ([left, to, rightVal]): Wire => ({ - to, + ([left, target, rightVal]): WireStatement => ({ + kind: "wire", + target, sources: [ { expr: { @@ -559,7 +569,7 @@ const logicalBridgeArb: fc.Arbitrary = fc { minLength: 1, maxLength: 20, - selector: (wire) => wire.to.path.join("."), + selector: (wire) => wire.target.path.join("."), }, ), }); diff --git a/packages/bridge-compiler/test/fuzz-runtime-parity.fuzz.ts b/packages/bridge-compiler/test/fuzz-runtime-parity.fuzz.ts index a5673c93..76ca8ca8 100644 --- a/packages/bridge-compiler/test/fuzz-runtime-parity.fuzz.ts +++ b/packages/bridge-compiler/test/fuzz-runtime-parity.fuzz.ts @@ -5,7 +5,7 @@ import type { Bridge, BridgeDocument, NodeRef, - Wire, + WireStatement, } from "@stackables/bridge-core"; import { executeBridge as executeRuntime } from "@stackables/bridge-core"; import { parseBridgeFormat } from "@stackables/bridge-parser"; @@ -83,21 +83,26 @@ function outputRef(type: string, field: string, path: string[]): NodeRef { // ── Deep-path bridge arbitrary ────────────────────────────────────────────── // Uses multi-segment paths (1–4 segments) to exercise deep property access. -const deepWireArb = (type: string, field: string): fc.Arbitrary => { - const toArb = flatPathArb.map((path) => outputRef(type, field, path)); +const deepWireArb = ( + type: string, + field: string, +): fc.Arbitrary => { + const targetArb = flatPathArb.map((path) => outputRef(type, field, path)); const fromArb = pathArb.map((path) => inputRef(type, field, path)); return fc.oneof( - fc.record({ - sources: constantValueArb.map((v) => [ - { expr: { type: "literal" as const, value: v } }, - ]), - to: toArb, - }), - fc.record({ - sources: fromArb.map((r) => [{ expr: { type: "ref" as const, ref: r } }]), - to: toArb, - }), + fc + .tuple( + constantValueArb.map((v) => [{ expr: { type: "literal" as const, value: v } }]), + targetArb, + ) + .map(([sources, target]): WireStatement => ({ kind: "wire", target, sources })), + fc + .tuple( + fromArb.map((r) => [{ expr: { type: "ref" as const, ref: r } }]), + targetArb, + ) + .map(([sources, target]): WireStatement => ({ kind: "wire", target, sources })), ); }; @@ -115,10 +120,10 @@ const deepBridgeArb: fc.Arbitrary = fc { kind: "input", handle: "i" } as const, { kind: "output", handle: "o" } as const, ]), - wires: fc.uniqueArray(deepWireArb(type, field), { + body: fc.uniqueArray(deepWireArb(type, field), { minLength: 1, maxLength: 20, - selector: (wire) => wire.to.path.join("."), + selector: (wire) => wire.target.path.join("."), }), }), ); diff --git a/packages/bridge-core/performance.md b/packages/bridge-core/performance.md index 5f969c87..cde5239c 100644 --- a/packages/bridge-core/performance.md +++ b/packages/bridge-core/performance.md @@ -4,24 +4,25 @@ Tracks engine performance work: what was tried, what failed, and what's planned. ## Summary -| # | Optimisation | Date | Result | -| --- | ---------------------------------- | ---------- | --------------------------------------------------- | -| 1 | WeakMap-cached DocumentIndex | March 2026 | ✗ Failed (–4–11%) | -| 2 | Lightweight shadow construction | March 2026 | ✅ Done (+5–7%) | -| 3 | Wire index by trunk key | March 2026 | ✗ Failed (–10–23%) | -| 4 | Cached element trunk key | March 2026 | ✅ Done (~0%, code cleanup) | -| 5 | Skip OTel when idle | March 2026 | ✅ Done (+7–9% tool-heavy) | -| 6 | Constant cache | March 2026 | ✅ Done (~0%, no regression) | -| 7 | pathEquals loop | March 2026 | ✅ Done (~0%, code cleanup) | -| 8 | Pre-group element wires | March 2026 | ✅ Done (see #9) | -| 9 | Batch element materialisation | March 2026 | ✅ Done (+44–130% arrays) | -| 10 | Sync fast path for resolved values | March 2026 | ✅ Done (+8–17% all, +42–114% arrays) | -| 11 | Pre-compute keys & cache wire tags | March 2026 | ✅ Done (+12–16% all, +60–129% arrays) | -| 12 | De-async schedule() & callTool() | March 2026 | ✅ Done (+11–18% tool, ~0% arrays) | -| 13 | Share toolDefCache across shadows | March 2026 | 🔲 Planned | -| 14 | Pre-compute output wire topology | March 2026 | 🔲 Planned | -| 15 | Cache executeBridge setup per doc | March 2026 | 🔲 Planned | -| 16 | Cheap strict-path hot-path guards | March 2026 | ✅ Done (partial recovery after error-mapping work) | +| # | Optimisation | Date | Result | +| --- | ---------------------------------- | ---------- | --------------------------------------------------------- | +| 1 | WeakMap-cached DocumentIndex | March 2026 | ✗ Failed (–4–11%) | +| 2 | Lightweight shadow construction | March 2026 | ✅ Done (+5–7%) | +| 3 | Wire index by trunk key | March 2026 | ✗ Failed (–10–23%) | +| 4 | Cached element trunk key | March 2026 | ✅ Done (~0%, code cleanup) | +| 5 | Skip OTel when idle | March 2026 | ✅ Done (+7–9% tool-heavy) | +| 6 | Constant cache | March 2026 | ✅ Done (~0%, no regression) | +| 7 | pathEquals loop | March 2026 | ✅ Done (~0%, code cleanup) | +| 8 | Pre-group element wires | March 2026 | ✅ Done (see #9) | +| 9 | Batch element materialisation | March 2026 | ✅ Done (+44–130% arrays) | +| 10 | Sync fast path for resolved values | March 2026 | ✅ Done (+8–17% all, +42–114% arrays) | +| 11 | Pre-compute keys & cache wire tags | March 2026 | ✅ Done (+12–16% all, +60–129% arrays) | +| 12 | De-async schedule() & callTool() | March 2026 | ✅ Done (+11–18% tool, ~0% arrays) | +| 13 | Share toolDefCache across shadows | March 2026 | 🔲 Planned | +| 14 | Pre-compute output wire topology | March 2026 | 🔲 Planned | +| 15 | Cache executeBridge setup per doc | March 2026 | 🔲 Planned | +| 16 | Cheap strict-path hot-path guards | March 2026 | ✅ Done (partial recovery after error-mapping work) | +| 17 | v3 scope-based engine | March 2026 | ✅ Done (+2.2× flat-1000 vs v2 baseline, 43% of compiled) | ## Baseline (main, March 2026) @@ -35,20 +36,20 @@ document are from this machine — compare only against the same hardware. | Benchmark | ops/sec | avg (ms) | | ---------------------------------- | ------- | -------- | -| parse: simple bridge | ~43K | 0.023 | -| parse: large bridge (20×5) | ~2.5K | 0.40 | -| exec: passthrough (no tools) | ~830K | 0.001 | -| exec: short-circuit | ~801K | 0.001 | -| exec: simple chain (1 tool) | ~558K | 0.002 | -| exec: chained 3-tool fan-out | ~216K | 0.005 | -| exec: flat array 10 | ~175K | 0.006 | -| exec: flat array 100 | ~28.2K | 0.036 | -| exec: flat array 1000 | ~2,980 | 0.335 | -| exec: nested array 5×5 | ~47.7K | 0.021 | -| exec: nested array 10×10 | ~17.5K | 0.057 | -| exec: nested array 20×10 | ~9.0K | 0.110 | -| exec: array + tool-per-element 10 | ~36.5K | 0.028 | -| exec: array + tool-per-element 100 | ~3.98K | 0.253 | +| parse: simple bridge | ~42K | 0.024 | +| parse: large bridge (20×5) | ~1.1K | 0.930 | +| exec: passthrough (no tools) | ~463K | 0.002 | +| exec: short-circuit | ~423K | 0.002 | +| exec: simple chain (1 tool) | ~276K | 0.004 | +| exec: chained 3-tool fan-out | ~89K | 0.012 | +| exec: flat array 10 | ~180K | 0.006 | +| exec: flat array 100 | ~50.5K | 0.020 | +| exec: flat array 1000 | ~6,420 | 0.157 | +| exec: nested array 5×5 | ~58.8K | 0.017 | +| exec: nested array 10×10 | ~26K | 0.039 | +| exec: nested array 20×10 | ~14K | 0.072 | +| exec: array + tool-per-element 10 | ~42.5K | 0.024 | +| exec: array + tool-per-element 100 | ~4.96K | 0.206 | This table is the current perf level. It is updated after a successful optimisation is committed. @@ -796,3 +797,85 @@ steps are profiling-driven, not blind micro-optimisation: - measure `ExecutionTree.applyPath()` in the runtime flamegraph on tool-heavy cases - consider additional small-shape fast paths for 2- and 3-segment strict traversal - evaluate whether any error-metadata work can be deferred off the success path + +--- + +### 17. v3 scope-based engine (AST interpreter) + +**Date:** March 2026 +**Result:** ✅ +2.2× flat-1000 vs v2 baseline, 43% of compiled engine + +**Context:** The v3 engine (`execute-bridge.ts`) replaced the v2 shadow-tree +based engine with a direct AST interpreter using `ExecutionScope` and pull-based +evaluation. The initial v3 port was ~10× slower on array workloads due to +per-element overhead from scope construction, AST re-indexing, and excessive +Promise allocation. A series of targeted optimisations closed the gap and then +surpassed the v2 baseline. + +**Key optimisations applied (in order):** + +1. **StaticScopeIndex** — Shared read-only pre-computed maps (`ownedTools`, + `toolInputWires`, `outputWires`, `aliases`, etc.) built once per array body + via `buildStaticIndex()`. Child scopes reference this shared index instead + of eagerly allocating 9+ Map/Set objects per element. + +2. **Lazy map allocation** — All `ExecutionScope` maps use `private _x: T | null` + with getter properties: `if (this.staticIndex) return this.staticIndex.x; +return (this._x ??= new Map())`. Elements that only read (the common case) + never allocate their own maps. + +3. **Execution plan hoisting** — `buildArrayExecutionPlan()` pre-computes wire + groups, ordering, and sub-field slicing once per array. The inner element + loop uses this plan directly, bypassing per-element `resolveRequestedFields`. + +4. **Chunked processing** — Elements processed in batches of 2048 via + `Promise.all`. Bounds concurrent promises to prevent GC panic on large arrays. + +5. **Promise.allSettled → Promise.all** — On the array hot path, replaced + `Promise.allSettled` with `Promise.all` in both `evaluateArrayElement` and + `evaluateArrayExpr`. The allSettled wrapper objects (3000+ per iteration for + flat-1000) were pure overhead since the inner code already handles errors. + +6. **Module-level `catchSafe()`** — Replaced per-call closure wrappers with a + single module-level function for safe expression evaluation. + +7. **Inlined `getActiveSourceLoc`** — Eliminated a per-wire closure allocation + in `evaluateSourceChain`'s catch block. + +8. **Synchronous fast path (`isPlanSynchronous`)** — The biggest single win. + Detects when every wire in an array body is a simple element property read + (no fallbacks, catches, overdefinition, or spreads). Evaluates the entire + array in a tight synchronous `for` loop: `getPath(element, ref.path)` → + `setPath(output, target)`. Zero Promises, zero ExecutionScope allocations, + zero async function calls. Eliminated ~47% CPU overhead (26.5% microtask + scheduling, 15.9% async closures, 5.1% GC) that was spent on async machinery + for zero actual I/O. + +**Benchmark results (v3 vs v2 baseline):** + +| Benchmark | v2 baseline | v3 current | Change | +| --------------------------- | ----------- | ---------- | ------------ | +| exec: passthrough | ~830K | ~463K | −44% | +| exec: short-circuit | ~801K | ~423K | −47% | +| exec: simple chain (1 tool) | ~558K | ~276K | −51% | +| exec: 3-tool fan-out | ~216K | ~89K | −59% | +| exec: flat array 10 | ~175K | ~180K | **≈ parity** | +| exec: flat array 100 | ~28.2K | ~50.5K | **+79%** | +| exec: flat array 1000 | ~2,980 | ~6,420 | **+2.2×** | +| exec: nested array 5×5 | ~47.7K | ~58.8K | **+23%** | +| exec: nested array 10×10 | ~17.5K | ~26K | **+49%** | +| exec: nested array 20×10 | ~9.0K | ~14K | **+56%** | +| exec: tool-per-element 10 | ~36.5K | ~42.5K | **+16%** | +| exec: tool-per-element 100 | ~3.98K | ~4.96K | **+25%** | + +**Summary:** + +- **Array workloads: v3 is significantly faster.** The sync fast path makes + pure element-ref arrays 2.2× faster than v2 for flat-1000, and nested arrays + see +23–56% gains. +- **Non-array workloads: v3 is ~44–59% slower.** The v3 scope-based pull engine + has inherent overhead from `ExecutionScope` construction, recursive expression + evaluation, and async function wrappers. The v2 shadow-tree engine's flat + wire-loop was simpler for non-array cases. +- **The v3 engine gains scope-based features** (defines, nested scopes, aliases, + lexical shadowing) that were not possible in the v2 flat wire model. diff --git a/packages/bridge-core/src/ExecutionTree.ts b/packages/bridge-core/src/ExecutionTree.ts deleted file mode 100644 index 3da63e4f..00000000 --- a/packages/bridge-core/src/ExecutionTree.ts +++ /dev/null @@ -1,1869 +0,0 @@ -import { materializeShadows as _materializeShadows } from "./materializeShadows.ts"; -import { resolveWires as _resolveWires } from "./resolveWires.ts"; -import { - schedule as _schedule, - trunkDependsOnElement, -} from "./scheduleTools.ts"; -import { lookupToolFn } from "./toolLookup.ts"; -import { internal } from "./tools/index.ts"; -import type { EffectiveToolLog, ToolTrace } from "./tracing.ts"; -import { - isOtelActive, - logToolError, - logToolSuccess, - recordSpanError, - resolveToolMeta, - toolCallCounter, - toolDurationHistogram, - toolErrorCounter, - TraceCollector, - withSpan, - withSyncSpan, -} from "./tracing.ts"; -import type { - Logger, - LoopControlSignal, - MaybePromise, - Path, - TreeContext, - Trunk, -} from "./tree-types.ts"; -import { - BREAK_SYM, - attachBridgeErrorMetadata, - BridgeAbortError, - BridgePanicError, - isFatalError, - wrapBridgeRuntimeError, - CONTINUE_SYM, - decrementLoopControl, - isLoopControlSignal, - isPromise, - MAX_EXECUTION_DEPTH, -} from "./tree-types.ts"; -import { - pathEquals, - getPrimaryRef, - isPullWire, - roundMs, - sameTrunk, - TRUNK_KEY_CACHE, - trunkKey, - UNSAFE_KEYS, -} from "./tree-utils.ts"; -import type { - Bridge, - BridgeDocument, - Expression, - Instruction, - NodeRef, - ToolContext, - ToolDef, - ToolMap, - Wire, -} from "./types.ts"; -import { SELF_MODULE } from "./types.ts"; -import { - filterOutputFields, - matchesRequestedFields, -} from "./requested-fields.ts"; -import { raceTimeout } from "./utils.ts"; -import type { TraceWireBits } from "./enumerate-traversals.ts"; -import { - buildTraceBitsMap, - buildEmptyArrayBitsMap, - enumerateTraversalIds, -} from "./enumerate-traversals.ts"; - -function stableMemoizeKey(value: unknown): string { - if (value === undefined) { - return "undefined"; - } - if (typeof value === "bigint") { - return `${value}n`; - } - if (value === null || typeof value !== "object") { - const serialized = JSON.stringify(value); - return serialized ?? String(value); - } - if (Array.isArray(value)) { - return `[${value.map((item) => stableMemoizeKey(item)).join(",")}]`; - } - - const entries = Object.entries(value as Record).sort( - ([left], [right]) => (left < right ? -1 : left > right ? 1 : 0), - ); - return `{${entries - .map( - ([key, entryValue]) => - `${JSON.stringify(key)}:${stableMemoizeKey(entryValue)}`, - ) - .join(",")}}`; -} - -type PendingBatchToolCall = { - input: Record; - resolve: (value: any) => void; - reject: (err: unknown) => void; -}; - -type BatchToolQueue = { - items: PendingBatchToolCall[]; - scheduled: boolean; - toolName: string; - fnName: string; - maxBatchSize?: number; -}; - -export class ExecutionTree implements TreeContext { - state: Record = {}; - bridge: Bridge | undefined; - source?: string; - filename?: string; - /** - * Cache for resolved tool dependency promises. - * Public to satisfy `ToolLookupContext` — used by `toolLookup.ts`. - */ - toolDepCache: Map> = new Map(); - /** - * Cache for resolved ToolDef objects (null = not found). - * Public to satisfy `ToolLookupContext` — used by `toolLookup.ts`. - */ - toolDefCache: Map = new Map(); - /** - * Pipe fork lookup map — maps fork trunk keys to their base trunk. - * Public to satisfy `SchedulerContext` — used by `scheduleTools.ts`. - */ - pipeHandleMap: - | Map[number]> - | undefined; - /** - * Maps trunk keys to `@version` strings from handle bindings. - * Populated in the constructor so `schedule()` can prefer versioned - * tool lookups (e.g. `std.str.toLowerCase@999.1`) over the default. - * Public to satisfy `SchedulerContext` — used by `scheduleTools.ts`. - */ - handleVersionMap: Map = new Map(); - /** Tool trunks marked with `memoize`. Shared with shadow trees. */ - memoizedToolKeys: Set = new Set(); - /** Per-tool memoization caches keyed by stable input fingerprints. */ - private toolMemoCache: Map>> = - new Map(); - /** Per-request batch queues for tools declared with `.bridge.batch`. */ - private toolBatchQueues: Map<(...args: any[]) => any, BatchToolQueue> = - new Map(); - /** Promise that resolves when all critical `force` handles have settled. */ - private forcedExecution?: Promise; - /** Cached spread data for field-by-field GraphQL resolution. */ - private spreadCache?: Record; - /** Shared trace collector — present only when tracing is enabled. */ - tracer?: TraceCollector; - /** - * Per-wire bit positions for execution trace recording. - * Built once from the bridge manifest. Shared across shadow trees. - */ - traceBits?: Map; - /** - * Per-array-iterator bit positions for "empty-array" trace recording. - * Keys are `arrayIterators` path keys (`""` for root, `"entries"` for nested). - * Shared across shadow trees. - */ - emptyArrayBits?: Map; - /** - * Shared mutable trace bitmask — `[mask]`. Boxed in a single-element - * array so shadow trees can share the same mutable reference. - * Uses `bigint` to support manifests with more than 31 entries. - */ - traceMask?: [bigint]; - /** Structured logger passed from BridgeOptions. Defaults to no-ops. */ - logger?: Logger; - /** External abort signal — cancels execution when triggered. */ - signal?: AbortSignal; - /** - * Hard timeout for tool calls in milliseconds. - * When set, tool calls that exceed this duration throw a `BridgeTimeoutError`. - * Default: 15_000 (15 seconds). Set to `0` to disable. - */ - toolTimeoutMs: number = 15_000; - /** - * Maximum shadow-tree nesting depth. - * Overrides `MAX_EXECUTION_DEPTH` when set. - * Default: `MAX_EXECUTION_DEPTH` (30). - */ - maxDepth: number = MAX_EXECUTION_DEPTH; - /** - * Registered tool function map. - * Public to satisfy `ToolLookupContext` — used by `toolLookup.ts`. - */ - toolFns?: ToolMap; - /** Shadow-tree nesting depth (0 for root). */ - private depth: number; - /** Pre-computed `trunkKey({ ...this.trunk, element: true })`. See packages/bridge-core/performance.md (#4). */ - private elementTrunkKey: string; - /** Sparse fieldset filter — set by `run()` when requestedFields is provided. */ - requestedFields: string[] | undefined; - - constructor( - public trunk: Trunk, - private document: BridgeDocument, - toolFns?: ToolMap, - /** - * User-supplied context object. - * Public to satisfy `ToolLookupContext` — used by `toolLookup.ts`. - */ - public context?: Record, - /** - * Parent tree (shadow-tree nesting). - * Public to satisfy `ToolLookupContext` — used by `toolLookup.ts`. - */ - public parent?: ExecutionTree, - ) { - this.depth = parent ? parent.depth + 1 : 0; - if (this.depth > MAX_EXECUTION_DEPTH) { - throw new BridgePanicError( - `Maximum execution depth exceeded (${this.depth}) at ${trunkKey(trunk)}. Check for infinite recursion or circular array mappings.`, - ); - } - this.elementTrunkKey = `${trunk.module}:${trunk.type}:${trunk.field}:*`; - this.toolFns = { internal, ...(toolFns ?? {}) }; - const instructions = document.instructions; - this.bridge = instructions.find( - (i): i is Bridge => - i.kind === "bridge" && i.type === trunk.type && i.field === trunk.field, - ); - if (this.bridge?.pipeHandles) { - this.pipeHandleMap = new Map( - this.bridge.pipeHandles.map((ph) => [ph.key, ph]), - ); - } - // Build handle→version map from bridge handle bindings - if (this.bridge) { - const instanceCounters = new Map(); - for (const h of this.bridge.handles) { - if (h.kind !== "tool") continue; - const name = h.name; - const lastDot = name.lastIndexOf("."); - let module: string, field: string, counterKey: string, type: string; - if (lastDot !== -1) { - module = name.substring(0, lastDot); - field = name.substring(lastDot + 1); - counterKey = `${module}:${field}`; - type = this.trunk.type; - } else { - module = SELF_MODULE; - field = name; - counterKey = `Tools:${name}`; - type = "Tools"; - } - const instance = (instanceCounters.get(counterKey) ?? 0) + 1; - instanceCounters.set(counterKey, instance); - const key = trunkKey({ module, type, field, instance }); - if (h.version) { - this.handleVersionMap.set(key, h.version); - } - if (h.memoize) { - this.memoizedToolKeys.add(key); - } - } - } - if (context) { - this.state[ - trunkKey({ module: SELF_MODULE, type: "Context", field: "context" }) - ] = context; - } - // Collect const definitions into a single namespace object - const constObj: Record = {}; - for (const inst of instructions) { - if (inst.kind === "const") { - constObj[inst.name] = JSON.parse(inst.value); - } - } - if (Object.keys(constObj).length > 0) { - this.state[ - trunkKey({ module: SELF_MODULE, type: "Const", field: "const" }) - ] = constObj; - } - } - - /** - * Accessor for the document's instruction list. - * Public to satisfy `ToolLookupContext` — used by `toolLookup.ts`. - */ - get instructions(): readonly Instruction[] { - return this.document.instructions; - } - - /** Schedule resolution for a target trunk — delegates to `scheduleTools.ts`. */ - schedule(target: Trunk, pullChain?: Set): MaybePromise { - return _schedule(this, target, pullChain); - } - - /** - * Invoke a tool function, recording both an OpenTelemetry span and (when - * tracing is enabled) a ToolTrace entry. All tool-call sites in the - * engine delegate here so instrumentation lives in exactly one place. - * - * Public to satisfy `ToolLookupContext` — called by `toolLookup.ts`. - */ - callTool( - toolName: string, - fnName: string, - fnImpl: (...args: any[]) => any, - input: Record, - memoizeKey?: string, - ): MaybePromise { - if (memoizeKey) { - const cacheKey = stableMemoizeKey(input); - let toolCache = this.toolMemoCache.get(memoizeKey); - if (!toolCache) { - toolCache = new Map(); - this.toolMemoCache.set(memoizeKey, toolCache); - } - - const cached = toolCache.get(cacheKey); - if (cached !== undefined) return cached; - - try { - const result = this.callTool(toolName, fnName, fnImpl, input); - if (isPromise(result)) { - const pending = Promise.resolve(result).catch((error) => { - toolCache.delete(cacheKey); - throw error; - }); - toolCache.set(cacheKey, pending); - return pending; - } - toolCache.set(cacheKey, result); - return result; - } catch (error) { - toolCache.delete(cacheKey); - throw error; - } - } - - // Short-circuit before starting if externally aborted - if (this.signal?.aborted) { - throw new BridgeAbortError(); - } - const tracer = this.tracer; - const logger = this.logger; - const toolContext: ToolContext = { - logger: logger ?? {}, - signal: this.signal, - }; - - const timeoutMs = this.toolTimeoutMs; - const { sync: isSyncTool, batch, doTrace, log } = resolveToolMeta(fnImpl); - - if (batch) { - return this.callBatchedTool( - toolName, - fnName, - fnImpl, - input, - timeoutMs, - toolContext, - doTrace, - log, - batch.maxBatchSize, - ); - } - - // ── Fast path: no instrumentation configured ────────────────── - // When there is no internal tracer, no logger, and OpenTelemetry - // has its default no-op provider, skip all instrumentation to - // avoid closure allocation, template-string building, and no-op - // metric calls. See packages/bridge-core/performance.md (#5). - if (!tracer && !logger && !isOtelActive()) { - try { - const result = fnImpl(input, toolContext); - if (isSyncTool) { - if (isPromise(result)) { - throw new Error( - `Tool "${fnName}" declared {sync:true} but returned a Promise`, - ); - } - return result; - } - if (timeoutMs > 0 && isPromise(result)) { - return raceTimeout(result, timeoutMs, toolName); - } - return result; - } catch (err) { - // Normalize platform AbortError to BridgeAbortError - if ( - this.signal?.aborted && - err instanceof DOMException && - err.name === "AbortError" - ) { - throw new BridgeAbortError(); - } - throw err; - } - } - - // ── Instrumented path ───────────────────────────────────────── - const traceStart = tracer?.now(); - const metricAttrs = { - "bridge.tool.name": toolName, - "bridge.tool.fn": fnName, - }; - - // ── Sync-optimised instrumented path ───────────────────────── - // When the tool declares {sync: true}, use withSyncSpan to avoid - // returning a Promise while still honouring OTel trace metadata. - if (isSyncTool) { - return withSyncSpan( - doTrace, - `bridge.tool.${toolName}.${fnName}`, - metricAttrs, - (span) => { - const wallStart = performance.now(); - try { - const result = fnImpl(input, toolContext); - if (isPromise(result)) { - throw new Error( - `Tool "${fnName}" declared {sync:true} but returned a Promise`, - ); - } - const durationMs = roundMs(performance.now() - wallStart); - toolCallCounter.add(1, metricAttrs); - toolDurationHistogram.record(durationMs, metricAttrs); - if (tracer && traceStart != null && doTrace) { - tracer.record( - tracer.entry({ - tool: toolName, - fn: fnName, - input, - output: result, - durationMs: roundMs(tracer.now() - traceStart), - startedAt: traceStart, - }), - ); - } - logToolSuccess(logger, log.execution, toolName, fnName, durationMs); - return result; - } catch (err) { - const durationMs = roundMs(performance.now() - wallStart); - toolCallCounter.add(1, metricAttrs); - toolDurationHistogram.record(durationMs, metricAttrs); - toolErrorCounter.add(1, metricAttrs); - if (tracer && traceStart != null && doTrace) { - tracer.record( - tracer.entry({ - tool: toolName, - fn: fnName, - input, - error: (err as Error).message, - durationMs: roundMs(tracer.now() - traceStart), - startedAt: traceStart, - }), - ); - } - recordSpanError(span, err as Error); - logToolError(logger, log.errors, toolName, fnName, err as Error); - // Normalize platform AbortError to BridgeAbortError - if ( - this.signal?.aborted && - err instanceof DOMException && - err.name === "AbortError" - ) { - throw new BridgeAbortError(); - } - throw err; - } finally { - span?.end(); - } - }, - ); - } - - return withSpan( - doTrace, - `bridge.tool.${toolName}.${fnName}`, - metricAttrs, - async (span) => { - const wallStart = performance.now(); - try { - const toolPromise = fnImpl(input, toolContext); - const result = - timeoutMs > 0 && isPromise(toolPromise) - ? await raceTimeout(toolPromise, timeoutMs, toolName) - : await toolPromise; - const durationMs = roundMs(performance.now() - wallStart); - toolCallCounter.add(1, metricAttrs); - toolDurationHistogram.record(durationMs, metricAttrs); - if (tracer && traceStart != null && doTrace) { - tracer.record( - tracer.entry({ - tool: toolName, - fn: fnName, - input, - output: result, - durationMs: roundMs(tracer.now() - traceStart), - startedAt: traceStart, - }), - ); - } - logToolSuccess(logger, log.execution, toolName, fnName, durationMs); - return result; - } catch (err) { - const durationMs = roundMs(performance.now() - wallStart); - toolCallCounter.add(1, metricAttrs); - toolDurationHistogram.record(durationMs, metricAttrs); - toolErrorCounter.add(1, metricAttrs); - if (tracer && traceStart != null && doTrace) { - tracer.record( - tracer.entry({ - tool: toolName, - fn: fnName, - input, - error: (err as Error).message, - durationMs: roundMs(tracer.now() - traceStart), - startedAt: traceStart, - }), - ); - } - recordSpanError(span, err as Error); - logToolError(logger, log.errors, toolName, fnName, err as Error); - // Normalize platform AbortError to BridgeAbortError - if ( - this.signal?.aborted && - err instanceof DOMException && - err.name === "AbortError" - ) { - throw new BridgeAbortError(); - } - throw err; - } finally { - span?.end(); - } - }, - ); - } - - private callBatchedTool( - toolName: string, - fnName: string, - fnImpl: (...args: any[]) => any, - input: Record, - timeoutMs: number, - toolContext: ToolContext, - doTrace: boolean, - log: EffectiveToolLog, - maxBatchSize?: number, - ): Promise { - let queue = this.toolBatchQueues.get(fnImpl); - if (!queue) { - queue = { - items: [], - scheduled: false, - toolName, - fnName, - maxBatchSize, - }; - this.toolBatchQueues.set(fnImpl, queue); - } - - if (maxBatchSize !== undefined) { - queue.maxBatchSize = maxBatchSize; - } - - return new Promise((resolve, reject) => { - queue!.items.push({ input, resolve, reject }); - if (queue!.scheduled) return; - queue!.scheduled = true; - queueMicrotask(() => { - void this.flushBatchedToolQueue( - fnImpl, - toolContext, - timeoutMs, - doTrace, - log, - ); - }); - }); - } - - private async flushBatchedToolQueue( - fnImpl: (...args: any[]) => any, - toolContext: ToolContext, - timeoutMs: number, - doTrace: boolean, - log: EffectiveToolLog, - ): Promise { - const queue = this.toolBatchQueues.get(fnImpl); - if (!queue) return; - - const pending = queue.items.splice(0, queue.items.length); - queue.scheduled = false; - if (pending.length === 0) return; - - if (this.signal?.aborted) { - const abortErr = new BridgeAbortError(); - for (const item of pending) item.reject(abortErr); - return; - } - - const chunkSize = - queue.maxBatchSize && queue.maxBatchSize > 0 - ? Math.floor(queue.maxBatchSize) - : pending.length; - - for (let start = 0; start < pending.length; start += chunkSize) { - const chunk = pending.slice(start, start + chunkSize); - const batchInput = chunk.map((item) => item.input); - const tracer = this.tracer; - const logger = this.logger; - const metricAttrs = { - "bridge.tool.name": queue.toolName, - "bridge.tool.fn": queue.fnName, - }; - - try { - const executeBatch = async () => { - const batchResult = fnImpl(batchInput, toolContext); - return timeoutMs > 0 && isPromise(batchResult) - ? await raceTimeout(batchResult, timeoutMs, queue.toolName) - : await batchResult; - }; - - const resolved = - !tracer && !logger && !isOtelActive() - ? await executeBatch() - : await withSpan( - doTrace, - `bridge.tool.${queue.toolName}.${queue.fnName}`, - metricAttrs, - async (span) => { - const traceStart = tracer?.now(); - const wallStart = performance.now(); - try { - const result = await executeBatch(); - const durationMs = roundMs(performance.now() - wallStart); - toolCallCounter.add(1, metricAttrs); - toolDurationHistogram.record(durationMs, metricAttrs); - if (tracer && traceStart != null && doTrace) { - tracer.record( - tracer.entry({ - tool: queue.toolName, - fn: queue.fnName, - input: batchInput, - output: result, - durationMs: roundMs(tracer.now() - traceStart), - startedAt: traceStart, - }), - ); - } - logToolSuccess( - logger, - log.execution, - queue.toolName, - queue.fnName, - durationMs, - ); - return result; - } catch (err) { - const durationMs = roundMs(performance.now() - wallStart); - toolCallCounter.add(1, metricAttrs); - toolDurationHistogram.record(durationMs, metricAttrs); - toolErrorCounter.add(1, metricAttrs); - if (tracer && traceStart != null && doTrace) { - tracer.record( - tracer.entry({ - tool: queue.toolName, - fn: queue.fnName, - input: batchInput, - error: (err as Error).message, - durationMs: roundMs(tracer.now() - traceStart), - startedAt: traceStart, - }), - ); - } - recordSpanError(span, err as Error); - logToolError( - logger, - log.errors, - queue.toolName, - queue.fnName, - err as Error, - ); - if ( - this.signal?.aborted && - err instanceof DOMException && - err.name === "AbortError" - ) { - throw new BridgeAbortError(); - } - throw err; - } finally { - span?.end(); - } - }, - ); - - if (!Array.isArray(resolved)) { - throw new Error( - `Batch tool "${queue.fnName}" must return an array of results`, - ); - } - if (resolved.length !== chunk.length) { - throw new Error( - `Batch tool "${queue.fnName}" returned ${resolved.length} results for ${chunk.length} queued calls`, - ); - } - - for (let i = 0; i < chunk.length; i++) { - const value = resolved[i]; - if (value instanceof Error) { - chunk[i]!.reject(value); - } else { - chunk[i]!.resolve(value); - } - } - } catch (err) { - for (const item of chunk) item.reject(err); - } - } - } - - shadow(): ExecutionTree { - // Lightweight: bypass the constructor to avoid redundant work that - // re-derives data identical to the parent (bridge lookup, pipeHandleMap, - // handleVersionMap, constObj, toolFns spread). See packages/bridge-core/performance.md (#2). - const child = Object.create(ExecutionTree.prototype) as ExecutionTree; - child.trunk = this.trunk; - child.document = this.document; - child.parent = this; - child.depth = this.depth + 1; - child.maxDepth = this.maxDepth; - child.toolTimeoutMs = this.toolTimeoutMs; - if (child.depth > child.maxDepth) { - throw new BridgePanicError( - `Maximum execution depth exceeded (${child.depth}) at ${trunkKey(this.trunk)}. Check for infinite recursion or circular array mappings.`, - ); - } - child.state = {}; - child.toolDepCache = new Map(); - child.toolDefCache = new Map(); - // Share read-only pre-computed data from parent - child.bridge = this.bridge; - child.pipeHandleMap = this.pipeHandleMap; - child.handleVersionMap = this.handleVersionMap; - child.memoizedToolKeys = this.memoizedToolKeys; - child.toolMemoCache = this.toolMemoCache; - child.toolBatchQueues = this.toolBatchQueues; - child.toolFns = this.toolFns; - child.elementTrunkKey = this.elementTrunkKey; - child.tracer = this.tracer; - child.traceBits = this.traceBits; - child.emptyArrayBits = this.emptyArrayBits; - child.traceMask = this.traceMask; - child.logger = this.logger; - child.signal = this.signal; - child.source = this.source; - child.filename = this.filename; - return child; - } - - /** - * Wrap raw array items into shadow trees, honouring `break` / `continue` - * sentinels. Shared by `pullOutputField`, `response`, and `run`. - * - * When `arrayPathKey` is provided and the resulting shadow array is empty, - * the corresponding "empty-array" traversal bit is recorded. - */ - private createShadowArray( - items: any[], - arrayPathKey?: string, - ): ExecutionTree[] { - const shadows: ExecutionTree[] = []; - for (const item of items) { - // Abort discipline — yield immediately if client disconnected - if (this.signal?.aborted) { - throw new BridgeAbortError(); - } - if (isLoopControlSignal(item)) { - const ctrl = decrementLoopControl(item); - if (ctrl === BREAK_SYM) break; - if (ctrl === CONTINUE_SYM) continue; - } - const s = this.shadow(); - s.state[this.elementTrunkKey] = item; - shadows.push(s); - } - if (shadows.length === 0 && arrayPathKey !== undefined) { - this.recordEmptyArray(arrayPathKey); - } - return shadows; - } - - /** Returns collected traces (empty array when tracing is disabled). */ - getTraces(): ToolTrace[] { - return this.tracer?.traces ?? []; - } - - /** Returns the execution trace bitmask (0n when tracing is disabled). */ - getExecutionTrace(): bigint { - return this.traceMask?.[0] ?? 0n; - } - - /** - * Enable execution trace recording. - * Builds the wire-to-bit map from the bridge manifest and initialises - * the shared mutable bitmask. Safe to call before `run()`. - */ - enableExecutionTrace(): void { - if (!this.bridge) return; - const manifest = enumerateTraversalIds(this.bridge); - this.traceBits = buildTraceBitsMap(this.bridge, manifest); - this.emptyArrayBits = buildEmptyArrayBitsMap(manifest); - this.traceMask = [0n]; - } - - /** Record an empty-array traversal bit for the given array-iterator path key. */ - private recordEmptyArray(pathKey: string): void { - const bit = this.emptyArrayBits?.get(pathKey); - if (bit !== undefined && this.traceMask) { - this.traceMask[0] |= 1n << BigInt(bit); - } - } - - /** - * Traverse `ref.path` on an already-resolved value, respecting null guards. - * Extracted from `pullSingle` so the sync and async paths can share logic. - */ - private applyPath(resolved: any, ref: NodeRef, bridgeLoc?: Wire["loc"]): any { - if (!ref.path.length) return resolved; - - // Single-segment access dominates hot paths; keep it on a dedicated branch - // to preserve the partial recovery recorded in packages/bridge-core/performance.md (#16). - if (ref.path.length === 1) { - const segment = ref.path[0]!; - const accessSafe = ref.pathSafe?.[0] ?? ref.rootSafe ?? false; - if (resolved == null) { - if (ref.element || accessSafe) return undefined; - throw wrapBridgeRuntimeError( - new TypeError( - `Cannot read properties of ${resolved} (reading '${segment}')`, - ), - { bridgeLoc }, - ); - } - - if (UNSAFE_KEYS.has(segment)) { - throw new Error(`Unsafe property traversal: ${segment}`); - } - if ( - this.logger?.warn && - Array.isArray(resolved) && - !/^\d+$/.test(segment) - ) { - this.logger?.warn?.( - `[bridge] Accessing ".${segment}" on an array (${resolved.length} items) — did you mean to use pickFirst or array mapping? Source: ${trunkKey(ref)}.${ref.path.join(".")}`, - ); - } - - const next = resolved[segment]; - const isPrimitiveBase = - resolved !== null && - typeof resolved !== "object" && - typeof resolved !== "function"; - if (isPrimitiveBase && next === undefined) { - throw wrapBridgeRuntimeError( - new TypeError( - `Cannot read properties of ${resolved} (reading '${segment}')`, - ), - { bridgeLoc }, - ); - } - return next; - } - - let result: any = resolved; - - for (let i = 0; i < ref.path.length; i++) { - const segment = ref.path[i]!; - const accessSafe = - ref.pathSafe?.[i] ?? (i === 0 ? (ref.rootSafe ?? false) : false); - - if (result == null) { - if ((i === 0 && ref.element) || accessSafe) { - result = undefined; - continue; - } - throw wrapBridgeRuntimeError( - new TypeError( - `Cannot read properties of ${result} (reading '${segment}')`, - ), - { bridgeLoc }, - ); - } - - if (UNSAFE_KEYS.has(segment)) - throw new Error(`Unsafe property traversal: ${segment}`); - if ( - this.logger?.warn && - Array.isArray(result) && - !/^\d+$/.test(segment) - ) { - this.logger?.warn?.( - `[bridge] Accessing ".${segment}" on an array (${result.length} items) — did you mean to use pickFirst or array mapping? Source: ${trunkKey(ref)}.${ref.path.join(".")}`, - ); - } - const next = result[segment]; - const isPrimitiveBase = - result !== null && - typeof result !== "object" && - typeof result !== "function"; - if (isPrimitiveBase && next === undefined) { - throw wrapBridgeRuntimeError( - new TypeError( - `Cannot read properties of ${result} (reading '${segment}')`, - ), - { bridgeLoc }, - ); - } - result = next; - } - return result; - } - - /** - * Pull a single value. Returns synchronously when already in state; - * returns a Promise only when the value is a pending tool call. - * See packages/bridge-core/performance.md (#10). - * - * Public to satisfy `TreeContext` — extracted modules call this via - * the interface. - */ - pullSingle( - ref: NodeRef, - pullChain: Set = new Set(), - bridgeLoc?: Wire["loc"], - ): MaybePromise { - // Cache trunkKey on the NodeRef via a Symbol key to avoid repeated - // string allocation. Symbol keys don't affect V8 hidden classes, - // so this won't degrade parser allocation-site throughput. - // See packages/bridge-core/performance.md (#11). - const key: string = ((ref as any)[TRUNK_KEY_CACHE] ??= trunkKey(ref)); - - // ── Cycle detection ───────────────────────────────────────────── - if (pullChain.has(key)) { - throw attachBridgeErrorMetadata( - new BridgePanicError( - `Circular dependency detected: "${key}" depends on itself`, - ), - { bridgeLoc }, - ); - } - - // Shadow trees must share cached values for refs that do not depend on the - // current element. Otherwise top-level aliases/tools reused inside arrays - // are recomputed once per element instead of being memoized at the parent. - if (this.parent && !ref.element && !this.isElementScopedTrunk(ref)) { - return this.parent.pullSingle(ref, pullChain, bridgeLoc); - } - - // Walk the full parent chain — shadow trees may be nested multiple levels - let value: any = undefined; - let cursor: ExecutionTree | undefined = this; - if (ref.element && ref.elementDepth && ref.elementDepth > 0) { - let remaining = ref.elementDepth; - while (remaining > 0 && cursor) { - cursor = cursor.parent; - remaining--; - } - } - while (cursor && value === undefined) { - value = cursor.state[key]; - cursor = cursor.parent; - } - - if (value === undefined) { - const nextChain = new Set(pullChain).add(key); - - // ── Lazy define field resolution ──────────────────────────────── - // For define trunks (__define_in_* / __define_out_*) with a specific - // field path, resolve ONLY the wire(s) targeting that field instead - // of scheduling the entire trunk. This avoids triggering unrelated - // dependency chains (e.g. requesting "city" should not fire the - // lat/lon coalesce chains that call the geo tool). - if (ref.path.length > 0 && ref.module.startsWith("__define_")) { - const fieldWires = - this.bridge?.wires.filter( - (w) => sameTrunk(w.to, ref) && pathEquals(w.to.path, ref.path), - ) ?? []; - if (fieldWires.length > 0) { - // resolveWires already delivers the value at ref.path — no applyPath. - return this.resolveWires(fieldWires, nextChain); - } - } - - try { - this.state[key] = this.schedule(ref, nextChain); - } catch (err) { - if (isFatalError(err)) throw err; - throw wrapBridgeRuntimeError(err, { bridgeLoc }); - } - value = this.state[key]; // sync value or Promise (see #12) - } - - // Sync fast path: value is already resolved (not a pending Promise). - if (!isPromise(value)) { - return this.applyPath(value, ref, bridgeLoc); - } - - // Async: chain path traversal onto the pending promise. - // Attach bridgeLoc to tool execution errors so they carry source context. - return (value as Promise).then( - (resolved: any) => this.applyPath(resolved, ref, bridgeLoc), - (err: unknown) => { - if (isFatalError(err)) throw err; - throw wrapBridgeRuntimeError(err, { bridgeLoc }); - }, - ); - } - - push(args: Record) { - this.state[trunkKey(this.trunk)] = args; - } - - /** Store the aggregated promise for critical forced handles so - * `response()` can await it exactly once per bridge execution. */ - setForcedExecution(p: Promise): void { - this.forcedExecution = p; - } - - /** Return the critical forced-execution promise (if any). */ - getForcedExecution(): Promise | undefined { - return this.forcedExecution; - } - - /** - * Eagerly schedule tools targeted by `force ` statements. - * - * Returns an array of promises for **critical** forced handles (those - * without `?? null`). Fire-and-forget handles (`catchError: true`) are - * scheduled but their errors are silently suppressed. - * - * Callers must `await Promise.all(...)` the returned promises so that a - * critical force failure propagates as a standard error. - */ - executeForced(): Promise[] { - const forces = this.bridge?.forces; - if (!forces || forces.length === 0) return []; - - const critical: Promise[] = []; - const scheduled = new Set(); - for (const f of forces) { - const trunk: Trunk = { - module: f.module, - type: f.type, - field: f.field, - instance: f.instance, - }; - const key = trunkKey(trunk); - if (scheduled.has(key) || this.state[key] !== undefined) continue; - scheduled.add(key); - this.state[key] = this.schedule(trunk); - - if (f.catchError) { - // Fire-and-forget: suppress unhandled rejection. - Promise.resolve(this.state[key]).catch(() => {}); - } else { - // Critical: caller must await and let failure propagate. - critical.push( - Promise.resolve(this.state[key]).catch((err) => { - if (isFatalError(err)) throw err; - throw wrapBridgeRuntimeError(err, {}); - }), - ); - } - } - return critical; - } - - /** - * Resolve a set of matched wires — delegates to the extracted - * `resolveWires` module. See `resolveWires.ts` for the full - * architecture comment (modifier layers, overdefinition, etc.). - * - * Public to satisfy `SchedulerContext` — used by `scheduleTools.ts`. - */ - resolveWires(wires: Wire[], pullChain?: Set): MaybePromise { - return _resolveWires(this, wires, pullChain); - } - - classifyOverdefinitionWire(wire: Wire): number { - // Optimistic cost — cost of the first source only. - // This is the minimum we'll pay; used for overdefinition ordering. - const visited = new Set(); - return this.computeExprCost(wire.sources[0]!.expr, visited); - } - - /** - * Pessimistic wire cost — sum of all source expression costs plus catch. - * Represents worst-case cost when all fallback sources fire. - */ - private computeWireCost(wire: Wire, visited: Set): number { - let cost = 0; - for (const source of wire.sources) { - cost += this.computeExprCost(source.expr, visited); - } - if (wire.catch && "ref" in wire.catch) { - cost += this.computeRefCost(wire.catch.ref, visited); - } - return cost; - } - - private computeExprCost(expr: Expression, visited: Set): number { - switch (expr.type) { - case "literal": - case "control": - return 0; - case "ref": - return this.computeRefCost(expr.ref, visited); - case "ternary": - return Math.max( - this.computeExprCost(expr.cond, visited), - this.computeExprCost(expr.then, visited), - this.computeExprCost(expr.else, visited), - ); - case "and": - case "or": - return Math.max( - this.computeExprCost(expr.left, visited), - this.computeExprCost(expr.right, visited), - ); - } - } - - private computeRefCost(ref: NodeRef, visited: Set): number { - if (ref.element) return 0; - // Already resolved or already-scheduled promise — cost already paid - if (this.hasCachedRef(ref)) return 0; - - const key = ((ref as any)[TRUNK_KEY_CACHE] ??= trunkKey(ref)); - if (visited.has(key)) return Infinity; - visited.add(key); - - // Self-module input/context/const — free - if ( - ref.module === SELF_MODULE && - ((ref.type === this.bridge?.type && ref.field === this.bridge?.field) || - ref.type === "Context" || - ref.type === "Const") - ) { - return 0; - } - - // Define — recursive, best (cheapest) incoming wire wins - if (ref.module.startsWith("__define_")) { - const incoming = - this.bridge?.wires.filter((wire) => sameTrunk(wire.to, ref)) ?? []; - let best = Infinity; - for (const wire of incoming) { - best = Math.min(best, this.computeWireCost(wire, visited)); - } - return best === Infinity ? 2 : best; - } - - // Local alias — recursive, cheapest incoming wire wins - if (ref.module === "__local") { - const incoming = - this.bridge?.wires.filter((wire) => sameTrunk(wire.to, ref)) ?? []; - let best = Infinity; - for (const wire of incoming) { - best = Math.min(best, this.computeWireCost(wire, visited)); - } - return best === Infinity ? 2 : best; - } - - // External tool — look up metadata for cost - const toolName = - ref.module === SELF_MODULE ? ref.field : `${ref.module}.${ref.field}`; - const fn = lookupToolFn(this, toolName); - if (fn) { - const meta = (fn as any).bridge; - if (meta?.cost != null) return meta.cost; - return meta?.sync ? 1 : 2; - } - return 2; - } - - private hasCachedRef(ref: NodeRef): boolean { - if (this.parent && !ref.element && !this.isElementScopedTrunk(ref)) { - return this.parent.hasCachedRef(ref); - } - - const key: string = ((ref as any)[TRUNK_KEY_CACHE] ??= trunkKey(ref)); - let cursor: ExecutionTree | undefined = this; - if (ref.element && ref.elementDepth && ref.elementDepth > 0) { - let remaining = ref.elementDepth; - while (remaining > 0 && cursor) { - cursor = cursor.parent; - remaining--; - } - } - while (cursor) { - if (cursor.state[key] !== undefined) return true; - cursor = cursor.parent; - } - return false; - } - - /** - * Resolve an output field by path for use outside of a GraphQL resolver. - * - * This is the non-GraphQL equivalent of what `response()` does per field: - * it finds all wires targeting `this.trunk` at `path` and resolves them. - * - * Used by `executeBridge()` so standalone bridge execution does not need to - * fabricate GraphQL Path objects to pull output data. - * - * @param path - Output field path, e.g. `["lat"]`. Pass `[]` for whole-output - * array bridges (`o <- items[] as x { ... }`). - * @param array - When `true` and the result is an array, wraps each element - * in a shadow tree (mirrors `response()` array handling). - */ - async pullOutputField(path: string[], array = false): Promise { - const matches = - this.bridge?.wires.filter( - (w) => sameTrunk(w.to, this.trunk) && pathEquals(w.to.path, path), - ) ?? []; - if (matches.length === 0) return undefined; - const result = this.resolveWires(matches); - if (!array) return result; - const resolved = await result; - if (resolved == null || !Array.isArray(resolved)) return resolved; - const arrayPathKey = path.join("."); - if (isLoopControlSignal(resolved)) { - this.recordEmptyArray(arrayPathKey); - return []; - } - return this.createShadowArray(resolved as any[], arrayPathKey); - } - - private isElementScopedTrunk(ref: NodeRef): boolean { - return trunkDependsOnElement(this.bridge, { - module: ref.module, - type: ref.type, - field: ref.field, - instance: ref.instance, - }); - } - - /** - * Resolve pre-grouped wires on this shadow tree without re-filtering. - * Called by the parent's `materializeShadows` to skip per-element wire - * filtering. Returns synchronously when the wire resolves sync (hot path). - * See packages/bridge-core/performance.md (#8, #10). - */ - resolvePreGrouped(wires: Wire[]): MaybePromise { - return this.resolveWires(wires); - } - - /** - * Recursively resolve an output field at `prefix` — either via exact-match - * wires (leaf) or by collecting sub-fields from deeper wires (nested object). - * - * Shared by `collectOutput()` and `run()`. - */ - private async resolveNestedField(prefix: string[]): Promise { - const bridge = this.bridge!; - const { type, field } = this.trunk; - - const exactWires = bridge.wires.filter( - (w) => - w.to.module === SELF_MODULE && - w.to.type === type && - w.to.field === field && - pathEquals(w.to.path, prefix), - ); - - // Separate spread wires from regular wires - const spreadWires = exactWires.filter((w) => isPullWire(w) && w.spread); - const regularWires = exactWires.filter((w) => !(isPullWire(w) && w.spread)); - - if (regularWires.length > 0) { - // Check for array mapping: exact wires (the array source) PLUS - // element-level wires deeper than prefix (the field mappings). - // E.g. `o.entries <- src[] as x { .id <- x.item_id }` produces - // an exact wire at ["entries"] and element wires at ["entries","id"]. - const hasElementWires = bridge.wires.some((w) => { - const ref = getPrimaryRef(w); - return ( - ref != null && - (ref.element === true || - this.isElementScopedTrunk(ref) || - w.to.element === true) && - w.to.module === SELF_MODULE && - w.to.type === type && - w.to.field === field && - w.to.path.length > prefix.length && - prefix.every((seg, i) => w.to.path[i] === seg) - ); - }); - - if (hasElementWires) { - // Array mapping on a sub-field: resolve the array source, - // create shadow trees, and materialise with field mappings. - const resolved = await this.resolveWires(regularWires); - if (!Array.isArray(resolved)) return null; - const shadows = this.createShadowArray(resolved, prefix.join(".")); - return this.materializeShadows(shadows, prefix); - } - - return this.resolveWires(regularWires); - } - - // Collect sub-fields from deeper wires - const subFields = new Set(); - for (const wire of bridge.wires) { - const p = wire.to.path; - if ( - wire.to.module === SELF_MODULE && - wire.to.type === type && - wire.to.field === field && - p.length > prefix.length && - prefix.every((seg, i) => p[i] === seg) - ) { - subFields.add(p[prefix.length]!); - } - } - - // Spread wires: resolve and merge, then overlay sub-field wires - if (spreadWires.length > 0) { - const result: Record = {}; - - // First resolve spread sources (in order) - for (const wire of spreadWires) { - const spreadValue = await this.resolveWires([wire]); - if (spreadValue != null && typeof spreadValue === "object") { - Object.assign(result, spreadValue); - } - } - - // Then resolve sub-fields and overlay on spread result - const prefixStr = prefix.join("."); - const activeSubFields = this.requestedFields - ? [...subFields].filter((sub) => { - const fullPath = prefixStr ? `${prefixStr}.${sub}` : sub; - return matchesRequestedFields(fullPath, this.requestedFields); - }) - : [...subFields]; - - await Promise.all( - activeSubFields.map(async (sub) => { - result[sub] = await this.resolveNestedField([...prefix, sub]); - }), - ); - - return result; - } - - if (subFields.size === 0) return undefined; - - // Apply sparse fieldset filter at nested level - const prefixStr = prefix.join("."); - const activeSubFields = this.requestedFields - ? [...subFields].filter((sub) => { - const fullPath = prefixStr ? `${prefixStr}.${sub}` : sub; - return matchesRequestedFields(fullPath, this.requestedFields); - }) - : [...subFields]; - if (activeSubFields.length === 0) return undefined; - - const obj: Record = {}; - await Promise.all( - activeSubFields.map(async (sub) => { - obj[sub] = await this.resolveNestedField([...prefix, sub]); - }), - ); - return obj; - } - - /** - * Materialise all output wires into a plain JS object. - * - * Used by the GraphQL adapter when a bridge field returns a scalar type - * (e.g. `JSON`, `JSONObject`). In that case GraphQL won't call sub-field - * resolvers, so we need to eagerly resolve every output wire and assemble - * the result ourselves — the same logic `run()` uses for object output. - */ - async collectOutput(): Promise { - const bridge = this.bridge; - if (!bridge) return undefined; - - const { type, field } = this.trunk; - - // Shadow tree (array element) — resolve element-level output fields. - // For scalar arrays ([JSON!]) GraphQL won't call sub-field resolvers, - // so we eagerly materialise each element here. - if (this.parent) { - const elementData = this.state[this.elementTrunkKey]; - - // Scalar element (string, number, boolean, null): return directly. - // Shadow trees wrapping non-object values have no sub-fields to - // resolve — re-entering wire resolution would incorrectly re-trigger - // the array-level wire that produced this element. - if (typeof elementData !== "object" || elementData === null) { - return elementData; - } - - const outputFields = new Set(); - for (const wire of bridge.wires) { - if ( - wire.to.module === SELF_MODULE && - wire.to.type === type && - wire.to.field === field && - wire.to.path.length > 0 - ) { - outputFields.add(wire.to.path[0]!); - } - } - if (outputFields.size > 0) { - const result: Record = {}; - await Promise.all( - [...outputFields].map(async (name) => { - result[name] = await this.pullOutputField([name]); - }), - ); - return result; - } - // Passthrough: return stored element data directly - return this.state[this.elementTrunkKey]; - } - - // Root wire (`o <- src`) — whole-object passthrough - const hasRootWire = bridge.wires.some( - (w) => - isPullWire(w) && - w.to.module === SELF_MODULE && - w.to.type === type && - w.to.field === field && - w.to.path.length === 0, - ); - if (hasRootWire) { - return this.pullOutputField([]); - } - - // Object output — collect unique top-level field names - const outputFields = new Set(); - for (const wire of bridge.wires) { - if ( - wire.to.module === SELF_MODULE && - wire.to.type === type && - wire.to.field === field && - wire.to.path.length > 0 - ) { - outputFields.add(wire.to.path[0]!); - } - } - - if (outputFields.size === 0) return undefined; - - const result: Record = {}; - - await Promise.all( - [...outputFields].map(async (name) => { - result[name] = await this.resolveNestedField([name]); - }), - ); - return result; - } - - /** - * Execute the bridge end-to-end without GraphQL. - * - * Injects `input` as the trunk arguments, runs forced wires, then pulls - * and materialises every output field into a plain JS object (or array of - * objects for array-mapped bridges). - * - * When `requestedFields` is provided, only matching output fields are - * resolved — unneeded tools are never called because the pull-based - * engine never reaches them. - * - * This is the single entry-point used by `executeBridge()`. - */ - async run( - input: Record, - requestedFields?: string[], - ): Promise { - const bridge = this.bridge; - if (!bridge) { - throw new Error( - `No bridge definition found for ${this.trunk.type}.${this.trunk.field}`, - ); - } - - this.push(input); - this.requestedFields = requestedFields; - const forcePromises = this.executeForced(); - - const { type, field } = this.trunk; - - // Separate root-level wires into passthrough vs spread - const rootWires = bridge.wires.filter( - (w) => - isPullWire(w) && - w.to.module === SELF_MODULE && - w.to.type === type && - w.to.field === field && - w.to.path.length === 0, - ); - - // Passthrough wire: root wire without spread flag - const hasPassthroughWire = rootWires.some( - (w) => isPullWire(w) && !w.spread, - ); - - // Spread wires: root wires with spread flag - const spreadWires = rootWires.filter((w) => isPullWire(w) && !!w.spread); - - const hasRootWire = rootWires.length > 0; - - // Array-mapped output (`o <- items[] as x { ... }`) has BOTH a root wire - // AND element-level wires (from.element === true). A plain passthrough - // (`o <- api.user`) only has the root wire. - // Pipe fork output wires in element context (e.g. concat template strings) - // may have to.element === true instead. - const hasElementWires = bridge.wires.some((w) => { - const ref = getPrimaryRef(w); - return ( - ref != null && - (ref.element === true || - this.isElementScopedTrunk(ref) || - w.to.element === true) && - w.to.module === SELF_MODULE && - w.to.type === type && - w.to.field === field - ); - }); - - if (hasRootWire && hasElementWires) { - const [shadows] = await Promise.all([ - this.pullOutputField([], true) as Promise, - ...forcePromises, - ]); - return this.materializeShadows(shadows, []); - } - - // Whole-object passthrough: `o <- api.user` (non-spread root wire) - if (hasPassthroughWire) { - const [result] = await Promise.all([ - this.pullOutputField([]), - ...forcePromises, - ]); - return result; - } - - // Object output — collect unique top-level field names - const outputFields = new Set(); - for (const wire of bridge.wires) { - if ( - wire.to.module === SELF_MODULE && - wire.to.type === type && - wire.to.field === field && - wire.to.path.length > 0 - ) { - outputFields.add(wire.to.path[0]!); - } - } - - // Spread wires: resolve and merge source objects - // Later field wires will override spread properties - const hasSpreadWires = spreadWires.length > 0; - - if (outputFields.size === 0 && !hasSpreadWires) { - throw new Error( - `Bridge "${type}.${field}" has no output wires. ` + - `Ensure at least one wire targets the output (e.g. \`o.field <- ...\`).`, - ); - } - - // Apply sparse fieldset filter - const activeFields = filterOutputFields(outputFields, requestedFields); - - const result: Record = {}; - - // First resolve spread wires (in order) to build base object - // Each spread source's properties are merged into result - for (const wire of spreadWires) { - const spreadValue = await this.resolveWires([wire]); - if (spreadValue != null && typeof spreadValue === "object") { - Object.assign(result, spreadValue); - } - } - - // Then resolve explicit field wires - these override spread properties - await Promise.all([ - ...[...activeFields].map(async (name) => { - result[name] = await this.resolveNestedField([name]); - }), - ...forcePromises, - ]); - return result; - } - - /** - * Recursively convert shadow trees into plain JS objects — - * delegates to `materializeShadows.ts`. - */ - private materializeShadows( - items: ExecutionTree[], - pathPrefix: string[], - ): Promise { - return _materializeShadows(this, items, pathPrefix); - } - - async response(ipath: Path, array: boolean, scalar = false): Promise { - // Build path segments from GraphQL resolver info - const pathSegments: string[] = []; - let index = ipath; - while (index.prev) { - pathSegments.unshift(`${index.key}`); - index = index.prev; - } - - if (pathSegments.length === 0 && (array || scalar)) { - // Direct output for scalar/list return types (e.g. [String!]) - const directOutput = - this.bridge?.wires.filter( - (w) => - sameTrunk(w.to, this.trunk) && - w.to.path.length === 1 && - w.to.path[0] === this.trunk.field, - ) ?? []; - if (directOutput.length > 0) { - return this.resolveWires(directOutput); - } - } - - // Strip numeric indices (array positions) from path for wire matching - const cleanPath = pathSegments.filter((p) => !/^\d+$/.test(p)); - - // Find wires whose target matches this trunk + path - const matches = - this.bridge?.wires.filter( - (w) => - (w.to.element ? !!this.parent : true) && - sameTrunk(w.to, this.trunk) && - pathEquals(w.to.path, cleanPath), - ) ?? []; - - if (matches.length > 0) { - // ── Lazy define resolution ────────────────────────────────────── - // When ALL matches at the root object level (path=[]) are - // whole-object wires sourced from define output modules, defer - // resolution to field-by-field GraphQL traversal. This avoids - // eagerly scheduling every tool inside the define block — only - // fields actually requested by the query will trigger their - // dependency chains. - if ( - cleanPath.length === 0 && - !array && - matches.every( - (w): boolean => - w.sources.length === 1 && - w.sources[0]!.expr.type === "ref" && - w.sources[0]!.expr.ref.module.startsWith("__define_out_") && - w.sources[0]!.expr.ref.path.length === 0, - ) - ) { - return this; - } - - // ── Lazy spread resolution ───────────────────────────────────── - // When ALL matches are spread wires, resolve them eagerly, cache - // the result, then return `this` so GraphQL sub-field resolvers - // can pick up both spread properties and explicit wires. - if ( - !array && - matches.every((w): boolean => isPullWire(w) && !!w.spread) - ) { - const spreadData = await this.resolveWires(matches); - if (spreadData != null && typeof spreadData === "object") { - const prefix = cleanPath.join("."); - this.spreadCache ??= {}; - if (prefix === "") { - Object.assign( - this.spreadCache, - spreadData as Record, - ); - } else { - (this.spreadCache as Record)[prefix] = spreadData; - } - } - return this; - } - - const response = this.resolveWires(matches); - - if (!array) { - return response; - } - - // Array: create shadow trees for per-element resolution. - // However, when the field is a scalar type (e.g. [JSONObject]) and - // the array is a pure passthrough (no element-level field mappings), - // GraphQL won't call sub-field resolvers so shadow trees are - // unnecessary — return the plain resolved array directly. - if (scalar) { - const { type, field } = this.trunk; - const hasElementWires = this.bridge?.wires.some((w) => { - const ref = getPrimaryRef(w); - return ( - ref != null && - (ref.element === true || - this.isElementScopedTrunk(ref) || - w.to.element === true) && - w.to.module === SELF_MODULE && - w.to.type === type && - w.to.field === field && - w.to.path.length > cleanPath.length && - cleanPath.every((seg, i) => w.to.path[i] === seg) - ); - }); - if (!hasElementWires) { - return response; - } - } - - const resolved = await response; - if (resolved == null || !Array.isArray(resolved)) return resolved; - const arrayPathKey = cleanPath.join("."); - if (isLoopControlSignal(resolved)) { - this.recordEmptyArray(arrayPathKey); - return []; - } - return this.createShadowArray(resolved as any[], arrayPathKey); - } - - // ── Resolve field from deferred define ──────────────────────────── - // No direct wires for this field path — check whether a define - // forward wire exists at the root level (`o <- defineHandle`) and - // resolve only the matching field wire from the define's output. - if (cleanPath.length > 0) { - const defineFieldWires = this.findDefineFieldWires(cleanPath); - if (defineFieldWires.length > 0) { - const response = this.resolveWires(defineFieldWires); - if (!array) return response; - const resolved = await response; - if (resolved == null || !Array.isArray(resolved)) return resolved; - const definePathKey = cleanPath.join("."); - if (isLoopControlSignal(resolved)) { - this.recordEmptyArray(definePathKey); - return []; - } - return this.createShadowArray(resolved as any[], definePathKey); - } - } - - // ── Spread cache fallback ───────────────────────────────────────── - // If a spread wire was resolved at a parent path, field-by-field GraphQL - // resolution consults the cached spread data for fields not covered by - // explicit wires. - if (cleanPath.length > 0 && this.spreadCache) { - // Check for a parent-level spread: e.g. cleanPath=["author"] with - // spread cached under "" (root spread), or cleanPath=["info","author"] - // with spread cached under "info". - const fieldName = cleanPath[cleanPath.length - 1]!; - const parentPrefix = cleanPath.slice(0, -1).join("."); - const parentSpread = - parentPrefix === "" - ? this.spreadCache - : (this.spreadCache[parentPrefix] as - | Record - | undefined); - if ( - parentSpread != null && - typeof parentSpread === "object" && - fieldName in parentSpread - ) { - return (parentSpread as Record)[fieldName]; - } - } - - // Fallback: if this shadow tree has stored element data, resolve the - // requested field directly from it. This handles passthrough arrays - // where the bridge maps an inner array (e.g. `.stops <- j.stops`) but - // doesn't explicitly wire each scalar field on the element type. - if (this.parent) { - const elementData = this.state[this.elementTrunkKey]; - if ( - elementData != null && - typeof elementData === "object" && - !Array.isArray(elementData) - ) { - const fieldName = cleanPath[cleanPath.length - 1]; - if (fieldName !== undefined && fieldName in elementData) { - const value = (elementData as Record)[fieldName]; - if (array && Array.isArray(value)) { - // Nested array: when the field is a scalar type (e.g. [JSONObject]) - // GraphQL won't call sub-field resolvers, so return the plain - // data directly instead of wrapping in shadow trees. - if (scalar) { - return value; - } - // Nested array: wrap items in shadow trees so they can - // resolve their own fields via this same fallback path. - return value.map((item: any) => { - const s = this.shadow(); - s.state[this.elementTrunkKey] = item; - return s; - }); - } - return value; - } - } - } - - // Scalar sub-field fallback: when the GraphQL schema declares this - // field as a scalar type (e.g. JSONObject), sub-field resolvers won't - // fire, so we must eagerly materialise the sub-field from deeper wires. - if (scalar && cleanPath.length > 0) { - return this.resolveNestedField(cleanPath); - } - - // Return self to trigger downstream resolvers - return this; - } - - /** - * Find define output wires for a specific field path. - * - * Looks for whole-object define forward wires (`o <- defineHandle`) - * at path=[] for this trunk, then searches the define's output wires - * for ones matching the requested field path. - */ - private findDefineFieldWires(cleanPath: string[]): Wire[] { - const forwards = - this.bridge?.wires.filter( - (w): boolean => - w.sources.length === 1 && - w.sources[0]!.expr.type === "ref" && - sameTrunk(w.to, this.trunk) && - w.to.path.length === 0 && - w.sources[0]!.expr.ref.module.startsWith("__define_out_") && - w.sources[0]!.expr.ref.path.length === 0, - ) ?? []; - - if (forwards.length === 0) return []; - - const result: Wire[] = []; - for (const fw of forwards) { - const defOutTrunk = ( - fw.sources[0]!.expr as Extract - ).ref; - const fieldWires = - this.bridge?.wires.filter( - (w) => - sameTrunk(w.to, defOutTrunk) && pathEquals(w.to.path, cleanPath), - ) ?? []; - result.push(...fieldWires); - } - return result; - } -} diff --git a/packages/bridge-core/src/enumerate-traversals.ts b/packages/bridge-core/src/enumerate-traversals.ts index 274e301a..ae443bcd 100644 --- a/packages/bridge-core/src/enumerate-traversals.ts +++ b/packages/bridge-core/src/enumerate-traversals.ts @@ -17,11 +17,13 @@ import type { Bridge, - Wire, WireSourceEntry, NodeRef, ControlFlowInstruction, SourceLocation, + Expression, + SourceChain, + Statement, } from "./types.ts"; // ── Public types ──────────────────────────────────────────────────────────── @@ -42,6 +44,7 @@ export interface TraversalEntry { | "fallback" | "catch" | "empty-array" + | "scope" | "then" | "else" | "const"; @@ -101,27 +104,6 @@ function canRefError(ref: NodeRef | undefined): boolean { return false; } -/** - * True when the wire is an array-source wire that simply feeds an array - * iteration scope without any fallback/catch choices of its own. - * - * Such wires always execute (to fetch the array), so they are not a - * traversal "choice". The separate `empty-array` entry already covers - * the "no elements" outcome. - */ -function isPlainArraySourceWire( - w: Wire, - arrayIterators: Record | undefined, -): boolean { - if (!arrayIterators) return false; - if (w.sources.length !== 1 || w.catch) return false; - const primary = w.sources[0]!.expr; - if (primary.type !== "ref" || primary.ref.element) return false; - const targetPath = w.to.path.join("."); - if (!(targetPath in arrayIterators)) return false; - return true; -} - // ── Description helpers ──────────────────────────────────────────────────── /** Map from ref type+field → handle alias for readable ref descriptions. */ @@ -137,12 +119,6 @@ function buildHandleMap(bridge: Bridge): Map { map.set("context", h.handle); } } - // Pipe handles use a non-"_" module (e.g., "std.str") with type="Query". - if (bridge.pipeHandles) { - for (const ph of bridge.pipeHandles) { - map.set(`pipe:${ph.baseTrunk.module}`, ph.handle); - } - } return map; } @@ -195,168 +171,471 @@ function sourceEntryDescription( return gate; } -function catchDescription(w: Wire, hmap: Map): string { - if (!w.catch) return "catch"; - if ("value" in w.catch) return `catch ${w.catch.value}`; - if ("ref" in w.catch) return `catch ${refLabel(w.catch.ref, hmap)}`; - if ("control" in w.catch) return `catch ${controlLabel(w.catch.control)}`; - return "catch"; +// ── Public API ────────────────────────────────────────────────────────────── + +/** + * Build the static traversal manifest for a bridge. + * + * Entries are sorted lexicographically by semantic ID before bit indices + * are assigned, guaranteeing ABI stability across source-code reorderings. + */ +export function buildTraversalManifest(bridge: Bridge): TraversalEntry[] { + return buildBodyTraversalMaps(bridge).manifest; +} + +// ── Body-based traversal enumeration ──────────────────────────────────────── + +/** Collected traceable item from body walking. */ +/** Collected traceable item from body walking. */ +type BodyTraceItem = { + chain: SourceChain; + target: string[]; +}; + +/** Collected empty-array item from body walking. */ +type EmptyArrayItem = { + expr: Expression; + target: string[]; +}; + +/** A scope block whose header should be dimmed when all descendants are inactive. */ +type ScopeItem = { + loc: SourceLocation; +}; + +/** + * Walk a Statement[] body tree and collect all traceable SourceChain + * references with their effective target paths. + */ +function collectTraceableItems( + statements: Statement[], + pathPrefix: string[], + items: BodyTraceItem[], + emptyArrayItems: EmptyArrayItem[], + scopeItems: ScopeItem[], +): void { + for (const stmt of statements) { + switch (stmt.kind) { + case "wire": { + const target = + stmt.target.path.length === 0 && stmt.target.module === "__local" + ? [stmt.target.field] + : [...pathPrefix, ...stmt.target.path]; + + // Plain array source wire — skip traversal entry for the wire, + // add empty-array entry, and recurse into array body. + const primary = stmt.sources[0]?.expr; + if ( + primary?.type === "array" && + stmt.sources.length === 1 && + !stmt.catch + ) { + emptyArrayItems.push({ expr: primary, target: [...target] }); + collectTraceableItems(primary.body, target, items, emptyArrayItems, scopeItems); + } else { + items.push({ chain: stmt, target }); + // Check for array expressions in any source (e.g., with fallbacks) + for (const source of stmt.sources) { + collectArrayExprs(source.expr, target, items, emptyArrayItems, scopeItems); + } + } + break; + } + case "alias": + items.push({ chain: stmt, target: [stmt.name] }); + for (const source of stmt.sources) { + collectArrayExprs(source.expr, [stmt.name], items, emptyArrayItems, scopeItems); + } + break; + case "spread": + items.push({ + chain: stmt, + target: pathPrefix.length > 0 ? [...pathPrefix] : [], + }); + break; + case "scope": + if (stmt.loc) scopeItems.push({ loc: stmt.loc }); + collectTraceableItems( + stmt.body, + [...pathPrefix, ...stmt.target.path], + items, + emptyArrayItems, + scopeItems, + ); + break; + // "with" and "force" don't produce traversal entries + } + } +} + +/** Recurse into expression tree to find nested ArrayExpressions. */ +function collectArrayExprs( + expr: Expression, + target: string[], + items: BodyTraceItem[], + emptyArrayItems: EmptyArrayItem[], + scopeItems: ScopeItem[], +): void { + switch (expr.type) { + case "array": + emptyArrayItems.push({ expr, target: [...target] }); + collectTraceableItems(expr.body, target, items, emptyArrayItems, scopeItems); + collectArrayExprs(expr.source, target, items, emptyArrayItems, scopeItems); + break; + case "ternary": + collectArrayExprs(expr.cond, target, items, emptyArrayItems, scopeItems); + collectArrayExprs(expr.then, target, items, emptyArrayItems, scopeItems); + collectArrayExprs(expr.else, target, items, emptyArrayItems, scopeItems); + break; + case "and": + case "or": + case "binary": + collectArrayExprs(expr.left, target, items, emptyArrayItems, scopeItems); + collectArrayExprs(expr.right, target, items, emptyArrayItems, scopeItems); + break; + case "unary": + collectArrayExprs(expr.operand, target, items, emptyArrayItems, scopeItems); + break; + case "pipe": + collectArrayExprs(expr.source, target, items, emptyArrayItems, scopeItems); + break; + case "concat": + for (const part of expr.parts) { + collectArrayExprs(part, target, items, emptyArrayItems, scopeItems); + } + break; + case "ref": + case "literal": + case "control": + break; // Leaves: no nested arrays possible + } } /** - * Compute the effective target path for a wire. - * For `__local` module wires (aliases), use `to.field` as the target - * since `to.path` is always empty for alias wires. + * Generate TraversalEntry items for a single SourceChain. + * Mirrors the wire-based logic but works on the SourceChain interface. */ -function effectiveTarget(w: Wire): string[] { - if (w.to.path.length === 0 && w.to.module === "__local") { - return [w.to.field]; +function generateChainEntries( + chain: SourceChain, + base: string, + target: string[], + hmap: Map, +): TraversalEntry[] { + const entries: TraversalEntry[] = []; + const primary = chain.sources[0]?.expr; + if (!primary) return entries; + + const chainLoc = (chain as { loc?: SourceLocation }).loc; + + // Constant wire — single literal source, no catch + if ( + primary.type === "literal" && + chain.sources.length === 1 && + !chain.catch + ) { + entries.push({ + id: `${base}/const`, + wireIndex: -1, + target, + kind: "const", + bitIndex: -1, + loc: chainLoc, + wireLoc: chainLoc, + description: `= ${primary.value}`, + }); + return entries; + } + + // Pull wire (ref primary) + if (primary.type === "ref") { + entries.push({ + id: `${base}/primary`, + wireIndex: -1, + target, + kind: "primary", + bitIndex: -1, + loc: chainLoc ?? primary.refLoc ?? primary.loc, + wireLoc: chainLoc, + description: refLabel(primary.ref, hmap), + }); + addChainFallbacks(entries, base, target, chain, hmap); + addChainCatch(entries, base, target, chain, hmap); + addChainErrors(entries, base, target, chain, hmap, primary, !!primary.safe); + return entries; + } + + // Conditional (ternary) + if (primary.type === "ternary") { + const thenExpr = primary.then; + const elseExpr = primary.else; + const thenDesc = + thenExpr.type === "ref" + ? `? ${refLabel(thenExpr.ref, hmap)}` + : thenExpr.type === "literal" + ? `? ${thenExpr.value}` + : "then"; + const elseDesc = + elseExpr.type === "ref" + ? `: ${refLabel(elseExpr.ref, hmap)}` + : elseExpr.type === "literal" + ? `: ${elseExpr.value}` + : "else"; + entries.push({ + id: `${base}/then`, + wireIndex: -1, + target, + kind: "then", + bitIndex: -1, + loc: primary.thenLoc ?? thenExpr.loc ?? chainLoc, + wireLoc: chainLoc, + description: thenDesc, + }); + entries.push({ + id: `${base}/else`, + wireIndex: -1, + target, + kind: "else", + bitIndex: -1, + loc: primary.elseLoc ?? elseExpr.loc ?? chainLoc, + wireLoc: chainLoc, + description: elseDesc, + }); + addChainFallbacks(entries, base, target, chain, hmap); + addChainCatch(entries, base, target, chain, hmap); + addChainErrors( + entries, + base, + target, + chain, + hmap, + thenExpr, + false, + elseExpr, + ); + return entries; + } + + // Logical AND/OR + if (primary.type === "and" || primary.type === "or") { + const leftRef = primary.left.type === "ref" ? primary.left.ref : undefined; + const rightExpr = primary.right; + const op = primary.type === "and" ? "&&" : "||"; + const leftLabel = leftRef ? refLabel(leftRef, hmap) : "?"; + const rightLabel = + rightExpr.type === "ref" + ? refLabel(rightExpr.ref, hmap) + : rightExpr.type === "literal" && rightExpr.value !== "true" + ? rightExpr.value + : undefined; + const desc = rightLabel ? `${leftLabel} ${op} ${rightLabel}` : leftLabel; + entries.push({ + id: `${base}/primary`, + wireIndex: -1, + target, + kind: "primary", + bitIndex: -1, + loc: chainLoc, + wireLoc: chainLoc, + description: desc, + }); + addChainFallbacks(entries, base, target, chain, hmap); + addChainCatch(entries, base, target, chain, hmap); + addChainErrors( + entries, + base, + target, + chain, + hmap, + primary.left, + !!primary.leftSafe, + ); + return entries; } - return w.to.path; + + // Other expression types (control, pipe, binary, etc.) + entries.push({ + id: `${base}/primary`, + wireIndex: -1, + target, + kind: "primary", + bitIndex: -1, + loc: chainLoc, + wireLoc: chainLoc, + }); + addChainFallbacks(entries, base, target, chain, hmap); + addChainCatch(entries, base, target, chain, hmap); + addChainErrors(entries, base, target, chain, hmap, primary, false); + return entries; } -/** Source location of the primary expression. */ -function primaryLoc(w: Wire): SourceLocation | undefined { - const primary = w.sources[0]; - if (!primary) return w.loc; - const expr = primary.expr; - if (expr.type === "ref") return expr.refLoc ?? w.loc; - return w.loc; +function chainCatchDesc(chain: SourceChain, hmap: Map): string { + if (!chain.catch) return "catch"; + if ("value" in chain.catch) + return `catch ${typeof chain.catch.value === "string" ? chain.catch.value : JSON.stringify(chain.catch.value)}`; + if ("ref" in chain.catch) return `catch ${refLabel(chain.catch.ref, hmap)}`; + if ("control" in chain.catch) + return `catch ${controlLabel(chain.catch.control)}`; + return "catch"; } -function addFallbackEntries( +function addChainFallbacks( entries: TraversalEntry[], base: string, - wireIndex: number, target: string[], - w: Wire, + chain: SourceChain, hmap: Map, ): void { - for (let i = 1; i < w.sources.length; i++) { - const entry = w.sources[i]!; + const chainLoc = (chain as { loc?: SourceLocation }).loc; + for (let i = 1; i < chain.sources.length; i++) { + const entry = chain.sources[i]!; entries.push({ id: `${base}/fallback:${i - 1}`, - wireIndex, + wireIndex: -1, target, kind: "fallback", fallbackIndex: i - 1, gateType: entry.gate, bitIndex: -1, loc: entry.loc, - wireLoc: w.loc, + wireLoc: chainLoc, description: sourceEntryDescription(entry, hmap), }); } } -function addCatchEntry( +function addChainCatch( entries: TraversalEntry[], base: string, - wireIndex: number, target: string[], - w: Wire, + chain: SourceChain, hmap: Map, ): void { - if (w.catch) { - entries.push({ - id: `${base}/catch`, - wireIndex, - target, - kind: "catch", - bitIndex: -1, - loc: w.catch.loc, - wireLoc: w.loc, - description: catchDescription(w, hmap), - }); - } + if (!chain.catch) return; + const chainLoc = (chain as { loc?: SourceLocation }).loc; + entries.push({ + id: `${base}/catch`, + wireIndex: -1, + target, + kind: "catch", + bitIndex: -1, + loc: chain.catch.loc, + wireLoc: chainLoc, + description: chainCatchDesc(chain, hmap), + }); } /** - * Add error-path entries for wire sources that can throw. - * - * Rules: - * - When the wire has a `catch`, individual source error entries are - * omitted because the catch absorbs all errors. Only a `catch/error` - * entry is added if the catch source itself can throw. - * - When the wire does NOT have a `catch`, each source ref that - * {@link canRefError} adds an error variant. - * - The wire-level `safe` flag suppresses primary-source error entries - * (errors are caught → undefined). + * True when an expression can throw at runtime (e.g., pipes or unsafe refs). */ -function addErrorEntries( +function canExprThrow(expr: Expression | undefined): boolean { + if (!expr) return false; + switch (expr.type) { + case "ref": + if (expr.safe || expr.ref.element) return false; + return canRefError(expr.ref); + case "pipe": + return true; // Pipes execute tools, which can throw + case "ternary": + return ( + canExprThrow(expr.cond) || + canExprThrow(expr.then) || + canExprThrow(expr.else) + ); + case "and": + case "or": + case "binary": + return canExprThrow(expr.left) || canExprThrow(expr.right); + case "unary": + return canExprThrow(expr.operand); + case "concat": + return expr.parts.some(canExprThrow); + case "array": + return canExprThrow(expr.source); + case "literal": + case "control": + return false; + } +} + +function addChainErrors( entries: TraversalEntry[], base: string, - wireIndex: number, target: string[], - w: Wire, + chain: SourceChain, hmap: Map, - primaryRef: NodeRef | undefined, + primaryExpr: Expression | undefined, wireSafe: boolean, - elseRef?: NodeRef | undefined, + elseExpr?: Expression | undefined, ): void { - if (w.catch) { - // Catch absorbs source errors — only check if the catch source itself - // can throw. - if ("ref" in w.catch && canRefError(w.catch.ref)) { + const chainLoc = (chain as { loc?: SourceLocation }).loc; + + if (chain.catch) { + const catchCanThrow = + "ref" in chain.catch + ? canRefError(chain.catch.ref) + : "expr" in chain.catch + ? canExprThrow(chain.catch.expr) + : false; + if (catchCanThrow) { entries.push({ id: `${base}/catch/error`, - wireIndex, + wireIndex: -1, target, kind: "catch", error: true, bitIndex: -1, - loc: w.catch.loc, - wireLoc: w.loc, - description: `${catchDescription(w, hmap)} error`, + loc: chain.catch.loc, + wireLoc: chainLoc, + description: `${chainCatchDesc(chain, hmap)} error`, }); } return; } - // No catch — add per-source error entries. - - // Primary / then source - if (!wireSafe && canRefError(primaryRef)) { - const desc = primaryRef ? refLabel(primaryRef, hmap) : undefined; + if (!wireSafe && canExprThrow(primaryExpr)) { + const desc = + primaryExpr?.type === "ref" ? refLabel(primaryExpr.ref, hmap) : undefined; + const pLoc = + primaryExpr?.type === "ref" + ? (primaryExpr.refLoc ?? primaryExpr.loc ?? chainLoc) + : (primaryExpr?.loc ?? chainLoc); entries.push({ id: `${base}/primary/error`, - wireIndex, + wireIndex: -1, target, kind: "primary", error: true, bitIndex: -1, - loc: primaryLoc(w), - wireLoc: w.loc, + loc: pLoc, + wireLoc: chainLoc, description: desc ? `${desc} error` : "error", }); } - // Else source (conditionals only) - if (elseRef && canRefError(elseRef)) { - const primary = w.sources[0]?.expr; - const elseLoc = - primary?.type === "ternary" ? (primary.elseLoc ?? w.loc) : w.loc; + if (canExprThrow(elseExpr)) { + const elseLoc = elseExpr!.loc ?? chainLoc; entries.push({ id: `${base}/else/error`, - wireIndex, + wireIndex: -1, target, kind: "else", error: true, bitIndex: -1, loc: elseLoc, - wireLoc: w.loc, - description: `${refLabel(elseRef, hmap)} error`, + wireLoc: chainLoc, + description: + elseExpr!.type === "ref" + ? `${refLabel(elseExpr!.ref, hmap)} error` + : "else error", }); } - // Fallback sources - for (let i = 1; i < w.sources.length; i++) { - const entry = w.sources[i]!; - const fbRef = entry.expr.type === "ref" ? entry.expr.ref : undefined; - if (canRefError(fbRef)) { + for (let i = 1; i < chain.sources.length; i++) { + const entry = chain.sources[i]!; + if (canExprThrow(entry.expr)) { entries.push({ id: `${base}/fallback:${i - 1}/error`, - wireIndex, + wireIndex: -1, target, kind: "fallback", error: true, @@ -364,241 +643,142 @@ function addErrorEntries( gateType: entry.gate, bitIndex: -1, loc: entry.loc, - wireLoc: w.loc, + wireLoc: chainLoc, description: `${sourceEntryDescription(entry, hmap)} error`, }); } } } -// ── Main function ─────────────────────────────────────────────────────────── - /** - * Enumerate every possible traversal path through a bridge. + * Build traversal manifest and runtime trace maps from a Bridge's Statement[] body. * - * Returns a flat list of {@link TraversalEntry} objects, one per - * unique code-path through the bridge's wires. The total length - * of the returned array is a useful proxy for bridge complexity. + * Entries are sorted lexicographically by semantic ID before bit indices + * are assigned. This guarantees the bitmask encoding is stable across + * source-code reorderings (ABI stability). * - * `bitIndex` is initially set to `-1` during construction and - * assigned sequentially (0, 1, 2, …) at the end. No entry is - * exposed with `bitIndex === -1`. + * Returns: + * - `manifest` — the ordered TraversalEntry[] (for decoding, coverage checks) + * - `chainBitsMap` — Map for O(1) runtime lookups + * (keyed by the `sources` array reference, shared between original and scope-prefixed copies) + * - `emptyArrayBits` — Map keyed by ArrayExpression reference for + * O(1) runtime lookups in evaluateArrayExpr */ -export function enumerateTraversalIds(bridge: Bridge): TraversalEntry[] { - const entries: TraversalEntry[] = []; +export function buildBodyTraversalMaps(bridge: Bridge): { + manifest: TraversalEntry[]; + chainBitsMap: Map; + emptyArrayBits: Map; +} { + // 1. Collect all traceable chains from body + const items: BodyTraceItem[] = []; + const emptyArrayItems: EmptyArrayItem[] = []; + const scopeItems: ScopeItem[] = []; + collectTraceableItems(bridge.body, [], items, emptyArrayItems, scopeItems); + + // 2. Generate traversal entries for each chain const hmap = buildHandleMap(bridge); - - // Track per-target occurrence counts for disambiguation when - // multiple wires write to the same target (overdefinition). const targetCounts = new Map(); + const allEntries: { entry: TraversalEntry; chain: SourceChain }[] = []; - for (let i = 0; i < bridge.wires.length; i++) { - const w = bridge.wires[i]; - const target = effectiveTarget(w); + for (const { chain, target } of items) { const tKey = pathKey(target); - - // Disambiguate overdefined targets (same target written by >1 wire). const seen = targetCounts.get(tKey) ?? 0; targetCounts.set(tKey, seen + 1); const base = seen > 0 ? `${tKey}#${seen}` : tKey; - // ── Classify by primary expression type ──────────────────────── - const primary = w.sources[0]?.expr; - if (!primary) continue; - - // ── Constant wire ─────────────────────────────────────────────── - if (primary.type === "literal" && w.sources.length === 1 && !w.catch) { - entries.push({ - id: `${base}/const`, - wireIndex: i, - target, - kind: "const", - bitIndex: -1, - loc: w.loc, - wireLoc: w.loc, - description: `= ${primary.value}`, - }); - continue; - } - - // ── Pull wire (ref primary) ───────────────────────────────────── - if (primary.type === "ref") { - // Skip plain array source wires — they always execute and the - // separate "empty-array" entry covers the "no elements" path. - if (!isPlainArraySourceWire(w, bridge.arrayIterators)) { - entries.push({ - id: `${base}/primary`, - wireIndex: i, - target, - kind: "primary", - bitIndex: -1, - loc: primaryLoc(w), - wireLoc: w.loc, - description: refLabel(primary.ref, hmap), - }); - addFallbackEntries(entries, base, i, target, w, hmap); - addCatchEntry(entries, base, i, target, w, hmap); - addErrorEntries( - entries, - base, - i, - target, - w, - hmap, - primary.ref, - !!primary.safe, - ); - } - continue; - } - - // ── Conditional (ternary) wire ────────────────────────────────── - if (primary.type === "ternary") { - const thenExpr = primary.then; - const elseExpr = primary.else; - const thenDesc = - thenExpr.type === "ref" - ? `? ${refLabel(thenExpr.ref, hmap)}` - : thenExpr.type === "literal" - ? `? ${thenExpr.value}` - : "then"; - const elseDesc = - elseExpr.type === "ref" - ? `: ${refLabel(elseExpr.ref, hmap)}` - : elseExpr.type === "literal" - ? `: ${elseExpr.value}` - : "else"; - entries.push({ - id: `${base}/then`, - wireIndex: i, - target, - kind: "then", - bitIndex: -1, - loc: primary.thenLoc ?? w.loc, - wireLoc: w.loc, - description: thenDesc, - }); - entries.push({ - id: `${base}/else`, - wireIndex: i, - target, - kind: "else", - bitIndex: -1, - loc: primary.elseLoc ?? w.loc, - wireLoc: w.loc, - description: elseDesc, - }); - addFallbackEntries(entries, base, i, target, w, hmap); - addCatchEntry(entries, base, i, target, w, hmap); - const thenRef = thenExpr.type === "ref" ? thenExpr.ref : undefined; - const elseRef = elseExpr.type === "ref" ? elseExpr.ref : undefined; - addErrorEntries( - entries, - base, - i, - target, - w, - hmap, - thenRef, - false, - elseRef, - ); - continue; - } - - // ── condAnd / condOr (logical binary) ─────────────────────────── - if (primary.type === "and" || primary.type === "or") { - const leftRef = - primary.left.type === "ref" ? primary.left.ref : undefined; - const rightExpr = primary.right; - const op = primary.type === "and" ? "&&" : "||"; - const leftLabel = leftRef ? refLabel(leftRef, hmap) : "?"; - const rightLabel = - rightExpr.type === "ref" - ? refLabel(rightExpr.ref, hmap) - : rightExpr.type === "literal" && rightExpr.value !== "true" - ? rightExpr.value - : undefined; - const desc = rightLabel ? `${leftLabel} ${op} ${rightLabel}` : leftLabel; - entries.push({ - id: `${base}/primary`, - wireIndex: i, - target, - kind: "primary", - bitIndex: -1, - loc: primaryLoc(w), - wireLoc: w.loc, - description: desc, - }); - addFallbackEntries(entries, base, i, target, w, hmap); - addCatchEntry(entries, base, i, target, w, hmap); - addErrorEntries( - entries, - base, - i, - target, - w, - hmap, - leftRef, - !!primary.leftSafe, - ); - continue; + for (const entry of generateChainEntries(chain, base, target, hmap)) { + allEntries.push({ entry, chain }); } + } - // ── Other expression types (control, literal with catch/fallbacks) ── - entries.push({ - id: `${base}/primary`, - wireIndex: i, + // 3. Add empty-array entries + const emptyArrayEntries: { entry: TraversalEntry; expr: Expression }[] = []; + let emptyIdx = 0; + for (const { expr, target } of emptyArrayItems) { + const label = target.join(".") || "(root)"; + const entry: TraversalEntry = { + id: `${label}/empty-array`, + wireIndex: -++emptyIdx, target, - kind: "primary", + kind: "empty-array", bitIndex: -1, - loc: w.loc, - wireLoc: w.loc, - }); - addFallbackEntries(entries, base, i, target, w, hmap); - addCatchEntry(entries, base, i, target, w, hmap); + description: `[] empty`, + }; + allEntries.push({ entry, chain: { sources: [] } }); + emptyArrayEntries.push({ entry, expr }); } - // ── Array iterators — each scope adds an "empty-array" path ───── - if (bridge.arrayIterators) { - let emptyIdx = 0; - for (const key of Object.keys(bridge.arrayIterators)) { - const iterName = bridge.arrayIterators[key]; - const target = key ? key.split(".") : []; - const label = key || "(root)"; - const id = `${label}/empty-array`; - entries.push({ - id, - // Use unique negative wireIndex per empty-array so they don't group together. - wireIndex: -++emptyIdx, - target, - kind: "empty-array", - bitIndex: -1, - description: `${iterName}[] empty`, - }); + // 4. Sort by ID for ABI stability + allEntries.sort((a, b) => a.entry.id.localeCompare(b.entry.id)); + + // 5. Assign sequential bitIndex + for (let i = 0; i < allEntries.length; i++) { + allEntries[i]!.entry.bitIndex = i; + } + + // 6. Build chain → bits map (keyed by sources array reference) + const chainBitsMap = new Map(); + for (const { entry, chain } of allEntries) { + if (entry.kind === "empty-array") continue; + if (!chain.sources.length) continue; + + let bits = chainBitsMap.get(chain.sources); + if (!bits) { + bits = {}; + chainBitsMap.set(chain.sources, bits); + } + + switch (entry.kind) { + case "primary": + case "then": + case "const": + if (entry.error) bits.primaryError = entry.bitIndex; + else bits.primary = entry.bitIndex; + break; + case "else": + if (entry.error) bits.elseError = entry.bitIndex; + else bits.else = entry.bitIndex; + break; + case "fallback": + if (entry.error) { + if (!bits.fallbackErrors) bits.fallbackErrors = []; + bits.fallbackErrors[entry.fallbackIndex ?? 0] = entry.bitIndex; + } else { + if (!bits.fallbacks) bits.fallbacks = []; + bits.fallbacks[entry.fallbackIndex ?? 0] = entry.bitIndex; + } + break; + case "catch": + if (entry.error) bits.catchError = entry.bitIndex; + else bits.catch = entry.bitIndex; + break; } } - // Assign sequential bit indices - for (let i = 0; i < entries.length; i++) { - entries[i].bitIndex = i; + // 7. Build empty-array bits map (keyed by ArrayExpression reference) + const emptyArrayBits = new Map(); + for (const { entry, expr } of emptyArrayEntries) { + emptyArrayBits.set(expr, entry.bitIndex); } - return entries; + // 8. Append scope marker entries (bitIndex stays -1, never assigned a bit) + const scopeEntries: TraversalEntry[] = scopeItems.map(({ loc }) => ({ + id: `scope:${loc.startLine}:${loc.startColumn}`, + wireIndex: -1, + target: [], + kind: "scope", + bitIndex: -1, + loc, + wireLoc: loc, + })); + + return { + manifest: [...allEntries.map((e) => e.entry), ...scopeEntries], + chainBitsMap, + emptyArrayBits, + }; } -// ── New public API ────────────────────────────────────────────────────────── - -/** - * Build the static traversal manifest for a bridge. - * - * Alias for {@link enumerateTraversalIds} with the recommended naming. - * Returns the ordered array of {@link TraversalEntry} objects. Each entry - * carries a `bitIndex` that maps it to a bit position in the runtime - * execution trace bitmask. - */ -export const buildTraversalManifest = enumerateTraversalIds; - /** * Decode a runtime execution trace bitmask against a traversal manifest. * @@ -614,6 +794,8 @@ export function decodeExecutionTrace( ): TraversalEntry[] { const result: TraversalEntry[] = []; for (const entry of manifest) { + // Scope marker entries have bitIndex: -1 and are never in the trace. + if (entry.bitIndex < 0) continue; // Check if the bit at position `entry.bitIndex` is set in the trace, // indicating this path was taken during execution. if (trace & (1n << BigInt(entry.bitIndex))) { @@ -648,74 +830,9 @@ export interface TraceWireBits { catchError?: number; } -/** - * Build a lookup map from Wire objects to their trace bit positions. - * - * This is called once per bridge at setup time. The returned map is - * used by `resolveWires` to flip bits in the shared trace mask with - * minimal overhead (one Map.get + one bitwise OR per decision). - */ -export function buildTraceBitsMap( - bridge: Bridge, - manifest: TraversalEntry[], -): Map { - const map = new Map(); - for (const entry of manifest) { - if (entry.kind === "empty-array") continue; // handled by buildEmptyArrayBitsMap - if (entry.wireIndex < 0) continue; - const wire = bridge.wires[entry.wireIndex]; - if (!wire) continue; - - let bits = map.get(wire); - if (!bits) { - bits = {}; - map.set(wire, bits); - } - - switch (entry.kind) { - case "primary": - case "then": - case "const": - if (entry.error) { - bits.primaryError = entry.bitIndex; - } else { - bits.primary = entry.bitIndex; - } - break; - case "else": - if (entry.error) { - bits.elseError = entry.bitIndex; - } else { - bits.else = entry.bitIndex; - } - break; - case "fallback": - if (entry.error) { - if (!bits.fallbackErrors) bits.fallbackErrors = []; - bits.fallbackErrors[entry.fallbackIndex ?? 0] = entry.bitIndex; - } else { - if (!bits.fallbacks) bits.fallbacks = []; - bits.fallbacks[entry.fallbackIndex ?? 0] = entry.bitIndex; - } - break; - case "catch": - if (entry.error) { - bits.catchError = entry.bitIndex; - } else { - bits.catch = entry.bitIndex; - } - break; - } - } - return map; -} - /** * Build a lookup map from array-iterator path keys to their "empty-array" * trace bit positions. - * - * Path keys match `Object.keys(bridge.arrayIterators)` — `""` for a root - * array, `"entries"` for `o.entries <- src[] as x { ... }`, etc. */ export function buildEmptyArrayBitsMap( manifest: TraversalEntry[], diff --git a/packages/bridge-core/src/execute-bridge.ts b/packages/bridge-core/src/execute-bridge.ts index e64de13d..ec3acc95 100644 --- a/packages/bridge-core/src/execute-bridge.ts +++ b/packages/bridge-core/src/execute-bridge.ts @@ -1,15 +1,58 @@ -import { ExecutionTree } from "./ExecutionTree.ts"; -import { attachBridgeErrorDocumentContext } from "./formatBridgeError.ts"; -import { TraceCollector } from "./tracing.ts"; -import type { Logger } from "./tree-types.ts"; import type { ToolTrace, TraceLevel } from "./tracing.ts"; -import type { BridgeDocument, ToolMap } from "./types.ts"; +import type { Logger } from "./tree-types.ts"; +import type { SourceLocation } from "@stackables/bridge-types"; +import type { + Bridge, + BridgeDocument, + ConstDef, + DefineDef, + Expression, + ForceStatement, + HandleBinding, + NodeRef, + ScopeStatement, + SourceChain, + SpreadStatement, + Statement, + ToolDef, + ToolMap, + WireAliasStatement, + WireCatch, + WireSourceEntry, + WireStatement, +} from "./types.ts"; import { SELF_MODULE } from "./types.ts"; +import { + TraceCollector, + resolveToolMeta, + logToolSuccess, + logToolError, + type EffectiveToolLog, +} from "./tracing.ts"; +import { + BridgeAbortError, + BridgePanicError, + isFatalError, + isPromise, + applyControlFlow, + isLoopControlSignal, + decrementLoopControl, + wrapBridgeRuntimeError, + BREAK_SYM, + CONTINUE_SYM, + MAX_EXECUTION_DEPTH, +} from "./tree-types.ts"; +import type { LoopControlSignal } from "./tree-types.ts"; +import { UNSAFE_KEYS } from "./tree-utils.ts"; +import { raceTimeout } from "./utils.ts"; +import { attachBridgeErrorDocumentContext } from "./formatBridgeError.ts"; import { std as bundledStd, STD_VERSION as BUNDLED_STD_VERSION, } from "@stackables/bridge-stdlib"; -import { resolveStd, checkHandleVersions } from "./version-check.ts"; +import { resolveStd } from "./version-check.ts"; +import { buildBodyTraversalMaps } from "./enumerate-traversals.ts"; +import type { TraceWireBits } from "./enumerate-traversals.ts"; export type ExecuteBridgeOptions = { /** Parsed bridge document (from `parseBridge` or `parseBridgeDiagnostics`). */ @@ -73,6 +116,18 @@ export type ExecuteBridgeOptions = { * Omit or pass an empty array to resolve all fields (the default). */ requestedFields?: string[]; + /** + * Enable partial success (Error Sentinels). + * + * When `true`, non-fatal errors on individual output fields are planted as + * `Error` sentinels in the output tree rather than thrown. A GraphQL + * resolver higher in the stack can intercept them to deliver per-field + * errors while sibling fields still resolve successfully. + * + * When `false` (default), the first non-fatal error is re-thrown and + * surfaces as a single top-level field error. + */ + partialSuccess?: boolean; }; export type ExecuteBridgeResult = { @@ -82,104 +137,2951 @@ export type ExecuteBridgeResult = { executionTraceId: bigint; }; +// ── Scope-based pull engine (v3) ──────────────────────────────────────────── + +/** Shared empty pull path — avoids allocating a new Set on every entry point. */ +const EMPTY_PULL_PATH: ReadonlySet = new Set(); + +/** Unique key for a tool instance trunk. */ +function toolKey(module: string, field: string, instance?: number): string { + return instance + ? `${module}:Tools:${field}:${instance}` + : `${module}:Tools:${field}`; +} + +/** Ownership key for a tool (module:field, no instance). */ +function toolOwnerKey(module: string, field: string): string { + return module === SELF_MODULE ? field : `${module}:${field}`; +} + /** - * Execute a bridge operation without GraphQL. + * Derive ownership key from a `with` binding name. + * "std.httpCall" → "std:httpCall" + */ +function bindingOwnerKey(name: string): string { + const dot = name.lastIndexOf("."); + return dot === -1 + ? name + : `${name.substring(0, dot)}:${name.substring(dot + 1)}`; +} + +/** + * Read a nested property from an object following a path array. + * Returns undefined if any segment is missing. + * + * When `rootSafe` or `pathSafe` flags are provided, null/undefined at + * safe-flagged segments returns undefined instead of propagating. + */ +function getPath( + obj: unknown, + path: string[], + rootSafe?: boolean, + pathSafe?: boolean[], +): unknown { + let current: unknown = obj; + for (let i = 0; i < path.length; i++) { + const segment = path[i]!; + if (UNSAFE_KEYS.has(segment)) + throw new Error(`Unsafe property traversal: ${segment}`); + if (current == null) { + const safe = pathSafe?.[i] ?? (i === 0 ? (rootSafe ?? false) : false); + if (safe) { + current = undefined; + continue; + } + // Throws TypeError: Cannot read properties of null/undefined + return (current as unknown as Record)[segment]; + } + const isPrimitive = + typeof current !== "object" && typeof current !== "function"; + const next = (current as Record)[segment]; + if (isPrimitive && next === undefined) { + const safe = pathSafe?.[i] ?? (i === 0 ? (rootSafe ?? false) : false); + if (safe) { + current = undefined; + continue; + } + throw new TypeError( + `Cannot read properties of ${String(current)} (reading '${segment}')`, + ); + } + current = next; + } + return current; +} + +/** + * Set a nested property on an object following a path array, + * creating intermediate objects as needed. * - * Runs a bridge file's data-wiring logic standalone — no schema, no server, - * no HTTP layer required. Useful for CLI tools, background jobs, tests, and - * any context where you want Bridge's declarative data-fetching outside of - * a GraphQL server. + * Empty path with a plain object merges into root. Empty path with + * any other value (array, primitive) stores under `__rootValue__` + * for the caller to extract. + */ +function setPath( + obj: Record, + path: string[], + value: unknown, +): void { + // Empty path — merge value into root object or store raw value + if (path.length === 0) { + if (value != null && typeof value === "object" && !Array.isArray(value)) { + Object.assign(obj, value as Record); + } else { + obj.__rootValue__ = value; + } + return; + } + let current: Record = obj; + for (let i = 0; i < path.length - 1; i++) { + const segment = path[i]!; + if (UNSAFE_KEYS.has(segment)) + throw new Error(`Unsafe assignment key: ${segment}`); + if ( + current[segment] == null || + typeof current[segment] !== "object" || + Array.isArray(current[segment]) + ) { + current[segment] = {}; + } + current = current[segment] as Record; + } + const leaf = path[path.length - 1]; + if (leaf !== undefined) { + if (UNSAFE_KEYS.has(leaf)) + throw new Error(`Unsafe assignment key: ${leaf}`); + current[leaf] = value; + } +} + +/** + * Look up a tool function by dotted name in the tools map. + * Supports namespace traversal (e.g. "std.httpCall" → tools.std.httpCall). + */ +function lookupToolFn( + tools: ToolMap, + name: string, +): ((...args: unknown[]) => unknown) | undefined { + // Flat key first + const flat = (tools as Record)[name]; + if (typeof flat === "function") + return flat as (...args: unknown[]) => unknown; + + // Namespace traversal + if (name.includes(".")) { + const parts = name.split("."); + let current: unknown = tools; + for (const part of parts) { + if (UNSAFE_KEYS.has(part)) return undefined; + if (current == null || typeof current !== "object") return undefined; + current = (current as Record)[part]; + } + if (typeof current === "function") + return current as (...args: unknown[]) => unknown; + } + + return undefined; +} + +/** + * Pre-computed, read-only index of statements for a scope body. + * Built once by `buildStaticIndex()` and shared across all array element + * scopes that execute the same loop body — avoids re-indexing per element. + */ +interface StaticScopeIndex { + readonly ownedTools: Set; + readonly toolInputWires: Map; + readonly outputWires: Map; + readonly spreadStatements: { stmt: SpreadStatement; pathPrefix: string[] }[]; + readonly aliases: Map; + readonly handleBindings: Map; + readonly ownedDefines: Set; + readonly forceStatements: ForceStatement[]; + readonly defineInputWires: Map; + readonly memoizedToolKeys: Set; +} + +/** Empty shared instances for scopes that use a static index (no local maps needed). */ +const EMPTY_SET = new Set() as Set & ReadonlySet; +const EMPTY_MAP = new Map() as Map; +const EMPTY_ARRAY: never[] = [] as never[]; + +/** + * Execution scope — the core of the v3 pull-based engine. * - * @example - * ```ts - * import { parseBridge, executeBridge } from "@stackables/bridge"; - * import { readFileSync } from "node:fs"; + * Each scope holds: + * - A parent pointer for lexical scope chain traversal + * - Owned tool bindings (declared via `with` in this scope) + * - Indexed tool input wires (evaluated lazily on first tool read) + * - Memoized tool call results + * - Element data stack for array iteration + * - Output object reference * - * const document = parseBridge(readFileSync("my.bridge", "utf8")); - * const { data } = await executeBridge({ - * document, - * operation: "Query.myField", - * input: { city: "Berlin" }, - * }); - * console.log(data); - * ``` + * When constructed with a `StaticScopeIndex`, the scope reads wire/alias/tool + * metadata from the shared index instead of allocating its own maps. + * This dramatically reduces allocations in array iteration hot loops. */ -export async function executeBridge( - options: ExecuteBridgeOptions, -): Promise> { - const { document: doc, operation, input = {}, context = {} } = options; +class ExecutionScope { + readonly parent: ExecutionScope | null; + readonly output: Record; + readonly selfInput: Record; + readonly engine: EngineContext; - const parts = operation.split("."); - if (parts.length !== 2 || !parts[0] || !parts[1]) { - throw new Error( - `Invalid operation "${operation}" — expected "Type.field" (e.g. "Query.myField")`, + /** + * Optional shared static index — when set, the scope reads from it + * instead of the local mutable maps. + */ + private readonly staticIndex: StaticScopeIndex | null; + + /** Tools declared via `with` at this scope level — keyed by "module:field". */ + private _ownedTools: Set | null = null; + + /** Tool input wires indexed by full tool key — evaluated lazily on demand. */ + private _toolInputWires: Map | null = null; + + /** Memoized tool call results — cached Promise per tool key. */ + private _toolResults: Map> | null = null; + + /** Element data stack for array iteration nesting. */ + private _elementData: unknown[] | null = null; + + /** Output wires (self-module and element) indexed by dot-joined target path. + * Multiple wires to the same path are stored as an array for overdefinition. */ + private _outputWires: Map | null = null; + + /** Spread statements collected during indexing, with optional path prefix for scope blocks. */ + private _spreadStatements: + | { + stmt: SpreadStatement; + pathPrefix: string[]; + }[] + | null = null; + + /** Alias statements indexed by name — evaluated lazily on first read. */ + private _aliases: Map | null = null; + + /** Cached alias evaluation results. */ + private _aliasResults: Map> | null = null; + + /** Handle bindings — maps handle alias to binding info. */ + private _handleBindings: Map | null = null; + + /** Owned define modules — keyed by __define_ prefix. */ + private _ownedDefines: Set | null = null; + + /** Force statements collected during indexing. */ + private _forceStatements: ForceStatement[] | null = null; + + /** Define input wires indexed by "module:field" key. */ + private _defineInputWires: Map | null = null; + + /** + * Lazy-input factories for define scopes: keyed by dot-joined selfInput path. + * When a selfInput reference is read, the factory is called once and the + * result promise is cached, enabling lazy input wire evaluation so only + * the wires needed for requested output fields are actually executed. + */ + private lazyInputFactories?: Map Promise>; + private lazyInputCache?: Map>; + + /** When true, this scope acts as a root for output writes (define scopes). */ + private isRootScope = false; + + /** Depth counter for array nesting — used for infinite loop protection. */ + private readonly depth: number; + + /** Set of tool owner keys that have memoize enabled. */ + private _memoizedToolKeys: Set | null = null; + + constructor( + parent: ExecutionScope | null, + selfInput: Record, + output: Record, + engine: EngineContext, + depth = 0, + staticIndex: StaticScopeIndex | null = null, + ) { + this.parent = parent; + this.selfInput = selfInput; + this.output = output; + this.engine = engine; + this.depth = depth; + this.staticIndex = staticIndex; + } + + // ── Lazy accessors: read from staticIndex if available, else local maps ── + + private get ownedTools(): Set { + if (this.staticIndex) return this.staticIndex.ownedTools; + return (this._ownedTools ??= new Set()); + } + + private get toolInputWires(): Map { + if (this.staticIndex) return this.staticIndex.toolInputWires; + return (this._toolInputWires ??= new Map()); + } + + private get toolResults(): Map> { + return (this._toolResults ??= new Map()); + } + + private get elementData(): unknown[] { + return (this._elementData ??= []); + } + + private get outputWires(): Map { + if (this.staticIndex) return this.staticIndex.outputWires; + return (this._outputWires ??= new Map()); + } + + private get spreadStatements(): { + stmt: SpreadStatement; + pathPrefix: string[]; + }[] { + if (this.staticIndex) return this.staticIndex.spreadStatements; + return (this._spreadStatements ??= []); + } + + private get aliases(): Map { + if (this.staticIndex) return this.staticIndex.aliases; + return (this._aliases ??= new Map()); + } + + private get aliasResults(): Map> { + return (this._aliasResults ??= new Map()); + } + + private get handleBindings(): Map { + if (this.staticIndex) return this.staticIndex.handleBindings; + return (this._handleBindings ??= new Map()); + } + + private get ownedDefines(): Set { + if (this.staticIndex) return this.staticIndex.ownedDefines; + return (this._ownedDefines ??= new Set()); + } + + get forceStatements(): ForceStatement[] { + if (this.staticIndex) return this.staticIndex.forceStatements; + return (this._forceStatements ??= []); + } + + private get defineInputWires(): Map { + if (this.staticIndex) return this.staticIndex.defineInputWires; + return (this._defineInputWires ??= new Map()); + } + + private get memoizedToolKeys(): Set { + if (this.staticIndex) return this.staticIndex.memoizedToolKeys; + return (this._memoizedToolKeys ??= new Set()); + } + + /** + * Extract a StaticScopeIndex from this scope's current indexed state. + * Used by `buildStaticIndex` to capture the maps after calling indexStatements + * on a temporary scope. + */ + extractStaticIndex(): StaticScopeIndex { + return { + ownedTools: this._ownedTools ?? (EMPTY_SET as Set), + toolInputWires: + this._toolInputWires ?? (EMPTY_MAP as Map), + outputWires: + this._outputWires ?? (EMPTY_MAP as Map), + spreadStatements: + this._spreadStatements ?? + (EMPTY_ARRAY as { stmt: SpreadStatement; pathPrefix: string[] }[]), + aliases: this._aliases ?? (EMPTY_MAP as Map), + handleBindings: + this._handleBindings ?? (EMPTY_MAP as Map), + ownedDefines: this._ownedDefines ?? (EMPTY_SET as Set), + forceStatements: + this._forceStatements ?? (EMPTY_ARRAY as ForceStatement[]), + defineInputWires: + this._defineInputWires ?? (EMPTY_MAP as Map), + memoizedToolKeys: this._memoizedToolKeys ?? (EMPTY_SET as Set), + }; + } + + /** Register that this scope owns a tool declared via `with`. */ + declareToolBinding(name: string, memoize?: true): void { + (this._ownedTools ??= new Set()).add(bindingOwnerKey(name)); + if (memoize) { + (this._memoizedToolKeys ??= new Set()).add(bindingOwnerKey(name)); + } + } + + /** Register that this scope owns a define block declared via `with`. */ + declareDefineBinding(handle: string): void { + (this._ownedDefines ??= new Set()).add(`__define_${handle}`); + } + + /** Index a define input wire (wire targeting a __define_* module). */ + addDefineInputWire(wire: WireStatement): void { + const map = (this._defineInputWires ??= new Map()); + const key = `${wire.target.module}:${wire.target.field}`; + let wires = map.get(key); + if (!wires) { + wires = []; + map.set(key, wires); + } + wires.push(wire); + } + + /** Register a handle binding for later lookup (pipe expressions, etc.). */ + registerHandle(binding: HandleBinding): void { + (this._handleBindings ??= new Map()).set(binding.handle, binding); + } + + /** Look up a handle binding by alias, walking the scope chain. */ + getHandleBinding(handle: string): HandleBinding | undefined { + const local = this.handleBindings.get(handle); + if (local) return local; + return this.parent?.getHandleBinding(handle); + } + + /** + * Collect all tool input wires matching a tool name (any instance). + * Used by pipe expressions to merge bridge wires into the pipe call. + */ + collectToolInputWiresFor(toolName: string): WireStatement[] { + const dot = toolName.lastIndexOf("."); + const module = dot === -1 ? SELF_MODULE : toolName.substring(0, dot); + const field = dot === -1 ? toolName : toolName.substring(dot + 1); + const prefix = `${module}:Tools:${field}`; + const result: WireStatement[] = []; + for (const [key, wires] of this.toolInputWires) { + if (key === prefix || key.startsWith(prefix + ":")) { + result.push(...wires); + } + } + return result; + } + + /** Index a tool input wire for lazy evaluation during tool call. */ + addToolInputWire(wire: WireStatement): void { + const map = (this._toolInputWires ??= new Map()); + const key = toolKey( + wire.target.module, + wire.target.field, + wire.target.instance, ); + let wires = map.get(key); + if (!wires) { + wires = []; + map.set(key, wires); + } + wires.push(wire); } - const [type, field] = parts as [string, string]; - const trunk = { module: SELF_MODULE, type, field }; + /** Index an output wire (self-module or element) by its target path. + * Multiple wires to the same path are collected for overdefinition. */ + addOutputWire(wire: WireStatement): void { + const map = (this._outputWires ??= new Map()); + const key = wire.target.path.join("."); + let wires = map.get(key); + if (!wires) { + wires = []; + map.set(key, wires); + } + wires.push(wire); + } - const userTools = options.tools ?? {}; + /** Add a spread statement with an optional path prefix for scope blocks. */ + addSpread(stmt: SpreadStatement, pathPrefix: string[] = []): void { + (this._spreadStatements ??= []).push({ stmt, pathPrefix }); + } - // Resolve which std to use: bundled, or a versioned namespace from tools - const { namespace: activeStd, version: activeStdVersion } = resolveStd( - doc.version, - bundledStd, - BUNDLED_STD_VERSION, - userTools, - ); + /** Get all spread statements with their path prefixes. */ + getSpreads(): { stmt: SpreadStatement; pathPrefix: string[] }[] { + return this.spreadStatements; + } - const allTools: ToolMap = { std: activeStd, ...userTools }; + /** Get output wires by field path key. Returns array (may have multiple for overdefinition). */ + getOutputWires(field: string): WireStatement[] | undefined { + return this.outputWires.get(field); + } - // Verify all @version-tagged handles can be satisfied - checkHandleVersions(doc.instructions, allTools, activeStdVersion); + /** Get all indexed output field names. */ + allOutputFields(): string[] { + return Array.from(this.outputWires.keys()); + } + + /** + * Collect all output wire groups matching the requested fields via prefix matching. + * Returns arrays of wires (one array per matched path, for overdefinition). + */ + collectMatchingOutputWireGroups( + requestedFields: string[], + ): WireStatement[][] { + // Bare "*" means all fields — skip filtering + if (requestedFields.includes("*")) { + return this.allOutputFields().map((f) => this.getOutputWires(f)!); + } - const tree = new ExecutionTree(trunk, doc, allTools, context); + const matched = new Set(); + const result: WireStatement[][] = []; - tree.source = doc.source; - tree.filename = doc.filename; + for (const field of requestedFields) { + for (const [key, wires] of this.outputWires) { + if (matched.has(key)) continue; - if (options.logger) tree.logger = options.logger; - if (options.signal) tree.signal = options.signal; - if ( - options.toolTimeoutMs !== undefined && - Number.isFinite(options.toolTimeoutMs) && - options.toolTimeoutMs >= 0 - ) { - tree.toolTimeoutMs = Math.floor(options.toolTimeoutMs); + // Root key "" always matches — it IS the entire output + if (key === "") { + matched.add(key); + result.push(wires); + continue; + } + + // Trailing wildcard: "legs.*" matches "legs.duration", "legs.distance" + if (field.endsWith(".*")) { + const prefix = field.slice(0, -2); + if (key === prefix || key.startsWith(prefix + ".")) { + matched.add(key); + result.push(wires); + continue; + } + } + + if ( + key === field || + key.startsWith(field + ".") || + field.startsWith(key + ".") + ) { + matched.add(key); + result.push(wires); + } + } + } + + return result; } - if ( - options.maxDepth !== undefined && - Number.isFinite(options.maxDepth) && - options.maxDepth >= 0 - ) { - tree.maxDepth = Math.floor(options.maxDepth); + + /** Index an alias statement for lazy evaluation. */ + addAlias(stmt: WireAliasStatement): void { + (this._aliases ??= new Map()).set(stmt.name, stmt); } - const traceLevel = options.trace ?? "off"; - if (traceLevel !== "off") { - tree.tracer = new TraceCollector(traceLevel); + /** + * Resolve an alias by name — walks the scope chain. + * Evaluates lazily and caches the result. + */ + resolveAlias( + name: string, + evaluator: ( + chain: SourceChain, + scope: ExecutionScope, + requestedFields: undefined, + pullPath: ReadonlySet, + ) => Promise, + pullPath: ReadonlySet = EMPTY_PULL_PATH, + ): Promise { + const aliasKey = `alias:${name}`; + + // 1. Cycle check first + if (pullPath.has(aliasKey)) { + throw new BridgePanicError( + `Circular dependency detected in alias "${name}"`, + ); + } + + // 2. Cache check second + if (this.aliasResults.has(name)) return this.aliasResults.get(name)!; + + // Do I have this alias? + const alias = this.aliases.get(name); + if (alias) { + // 3. Branch the path + const nextPath = new Set(pullPath).add(aliasKey); + const promise = evaluator(alias, this, undefined, nextPath); + this.aliasResults.set(name, promise); + return promise; + } + + // Delegate to parent + if (this.parent) { + return this.parent.resolveAlias(name, evaluator, pullPath); + } + + throw new Error(`Alias "${name}" not found in any scope`); + } + + /** Push element data for array iteration. */ + pushElement(data: unknown): void { + this.elementData.push(data); } - // Always enable execution trace recording — the overhead is one - // Map.get + one bitwise OR per wire decision (negligible). - tree.enableExecutionTrace(); + /** Get element data at a given depth (0 = current, 1 = parent array, etc). */ + getElement(depth: number): unknown { + const idx = this.elementData.length - 1 - depth; + if (idx >= 0) return this.elementData[idx]; + if (this.parent) + return this.parent.getElement(depth - this.elementData.length); + return undefined; + } - let data: unknown; - try { - data = await tree.run(input, options.requestedFields); - } catch (err) { - if (err && typeof err === "object") { - (err as { executionTraceId?: bigint }).executionTraceId = - tree.getExecutionTrace(); - (err as { traces?: ToolTrace[] }).traces = tree.getTraces(); + /** Get the root scope (stops at define boundaries). */ + root(): ExecutionScope { + let scope: ExecutionScope = this; + while (scope.parent && !scope.isRootScope) scope = scope.parent; + return scope; + } + + /** + * Resolve a tool result via lexical scope chain. + * + * Walks up the parent chain to find the scope that owns the tool + * (declared via `with`). Tool calls are lazy — the tool function is + * only invoked when its output is first read, at which point its + * input wires are evaluated on demand. + * + * Cycle detection: tracks active pull keys to detect circular deps. + */ + async resolveToolResult( + module: string, + field: string, + instance: number | undefined, + bridgeLoc?: SourceLocation, + pullPath: ReadonlySet = EMPTY_PULL_PATH, + ): Promise { + const key = toolKey(module, field, instance); + + // Cycle detection — must happen before the cache check. + // If this key is already in our pull path, we have a circular dependency. + if (pullPath.has(key)) { + const err = new BridgePanicError( + `Circular dependency detected: "${key}" depends on itself`, + ); + if (bridgeLoc) + (err as unknown as { bridgeLoc: SourceLocation }).bridgeLoc = bridgeLoc; + throw err; + } + + // Does this scope own the tool? + const ownerKey = toolOwnerKey(module, field); + if (this.ownedTools.has(ownerKey)) { + // Check local memoization cache + if (this.toolResults.has(key)) return this.toolResults.get(key)!; + + // Branch the path for this tool's input evaluation + const nextPath = new Set(pullPath); + nextPath.add(key); + return this.callTool(key, module, field, bridgeLoc, nextPath); + } + + // Check local memoization cache for non-owned (delegated) results + if (this.toolResults.has(key)) return this.toolResults.get(key)!; + + // Delegate to parent scope (lexical chain traversal) + if (this.parent) { + return this.parent.resolveToolResult( + module, + field, + instance, + bridgeLoc, + pullPath, + ); + } + + throw new Error(`Tool "${module}.${field}" not found in any scope`); + } + + /** + * Lazily call a tool — evaluates input wires on demand, invokes the + * tool function, and caches the result. + * + * Supports ToolDef resolution, memoization, sync validation, + * batching, timeouts, and bridgeLoc error attachment. + */ + private callTool( + key: string, + module: string, + field: string, + bridgeLoc: SourceLocation | undefined, + pullPath: ReadonlySet, + ): Promise { + const promise = (async () => { + const toolName = module === SELF_MODULE ? field : `${module}.${field}`; + + // Resolve ToolDef (extends chain → root fn, merged wires, onError) + const toolDef = resolveToolDefByName( + this.engine.instructions, + toolName, + this.engine.toolDefCache, + ); + const fnName = toolDef?.fn ?? toolName; + const fn = lookupToolFn(this.engine.tools, fnName); + + // Build input: ToolDef base wires first, then bridge wires override. + // Evaluated before the "fn not found" check so that tool-input wire + // traversal bits are recorded even when the tool function is missing. + // pullPath already contains this key — any re-entrant resolveToolResult + // for the same key will detect the cycle. + const input: Record = {}; + + if (toolDef?.body) { + await evaluateToolDefBody(toolDef.body, input, this, pullPath); + } + + const wires = this.toolInputWires.get(key) ?? []; + const wireGroups = groupWiresByPath(wires); + await Promise.all( + wireGroups.map(async (group) => { + const ordered = + group.length > 1 + ? orderOverdefinedWires(group, this.engine) + : group; + let lastError: unknown; + for (const wire of ordered) { + try { + const value = await evaluateSourceChain( + wire, + this, + undefined, + pullPath, + ); + setPath(input, wire.target.path, value); + if (value != null) return; // short-circuit: non-nullish wins + lastError = undefined; // reset — wire succeeded (null) + } catch (err) { + if (isFatalError(err) || isLoopControlSignal(err)) throw err; + lastError = err; + } + } + if (lastError) throw lastError; + }), + ); + + if (!fn) throw new Error(`No tool found for "${fnName}"`); + const { + doTrace, + sync: isSyncTool, + batch: batchMeta, + log: toolLog, + } = resolveToolMeta(fn); + + // Short-circuit if externally aborted + if (this.engine.signal?.aborted) throw new BridgeAbortError(); + + // Memoize check — if this tool is memoized, check cache by input hash + // Use `key` (includes instance) so different handles for the same tool + // maintain isolated caches. + const ownerKey = toolOwnerKey(module, field); + const isMemoized = this.memoizedToolKeys.has(ownerKey); + if (isMemoized) { + const cacheKey = stableMemoizeKey(input); + let toolCache = this.engine.toolMemoCache.get(key); + if (!toolCache) { + toolCache = new Map(); + this.engine.toolMemoCache.set(key, toolCache); + } + const cached = toolCache.get(cacheKey); + if (cached !== undefined) return cached; + + // Not cached — call and cache result + const resultPromise = this.invokeToolFn( + fn, + input, + toolName, + fnName, + isSyncTool, + batchMeta, + doTrace, + toolLog, + bridgeLoc, + ); + toolCache.set(cacheKey, resultPromise); + return resultPromise; + } + + return this.invokeToolFn( + fn, + input, + toolName, + fnName, + isSyncTool, + batchMeta, + doTrace, + toolLog, + bridgeLoc, + ); + })(); + + this.toolResults.set(key, promise); + return promise; + } + + /** + * Invoke a tool function with tracing, timeout, sync validation, + * batching, and error handling. + */ + private async invokeToolFn( + fn: (...args: unknown[]) => unknown, + input: Record, + toolName: string, + fnName: string, + isSyncTool: boolean, + batchMeta: { maxBatchSize?: number } | undefined, + doTrace: boolean, + toolLog: EffectiveToolLog, + bridgeLoc?: SourceLocation, + ): Promise { + const toolContext = { + logger: this.engine.logger, + signal: this.engine.signal, + }; + const startMs = performance.now(); + const timeoutMs = this.engine.toolTimeoutMs; + try { + let result: unknown; + + if (batchMeta) { + // Batched tool call — queue and flush on microtask + // Tracing and logging are done in flushBatchedToolQueue, not here. + result = await callBatchedTool( + this.engine, + fn, + input, + toolName, + fnName, + batchMeta, + doTrace, + toolLog, + ); + } else { + result = fn(input, toolContext); + + // Sync tool validation + if (isSyncTool) { + if (isPromise(result)) { + throw new Error( + `Tool "${fnName}" declared {sync:true} but returned a Promise`, + ); + } + } else if (isPromise(result)) { + // Apply timeout if configured + if (timeoutMs > 0) { + result = await raceTimeout( + result as Promise, + timeoutMs, + toolName, + ); + } else { + result = await result; + } + } + } + + const durationMs = performance.now() - startMs; + + // Batch calls have their own tracing/logging in flushBatchedToolQueue + if (!batchMeta) { + if (this.engine.tracer && doTrace) { + this.engine.tracer.record( + this.engine.tracer.entry({ + tool: toolName, + fn: fnName, + input, + output: result, + durationMs, + startedAt: this.engine.tracer.now() - durationMs, + }), + ); + } + logToolSuccess( + this.engine.logger, + toolLog.execution, + toolName, + fnName, + durationMs, + ); + } + + return result; + } catch (err) { + // Normalize platform AbortError to BridgeAbortError + if ( + this.engine.signal?.aborted && + err instanceof DOMException && + err.name === "AbortError" + ) { + throw new BridgeAbortError(); + } + + const durationMs = performance.now() - startMs; + + if (!batchMeta) { + if (this.engine.tracer && doTrace) { + this.engine.tracer.record( + this.engine.tracer.entry({ + tool: toolName, + fn: fnName, + input, + error: (err as Error).message, + durationMs, + startedAt: this.engine.tracer.now() - durationMs, + }), + ); + } + logToolError( + this.engine.logger, + toolLog.errors, + toolName, + fnName, + err as Error, + ); + } + + if (isFatalError(err)) throw err; + + const toolDef = resolveToolDefByName( + this.engine.instructions, + toolName, + this.engine.toolDefCache, + ); + if (toolDef?.onError) { + if ("value" in toolDef.onError) + return JSON.parse(toolDef.onError.value); + // source-based onError — resolve from ToolDef handles + if ("source" in toolDef.onError) { + const parts = toolDef.onError.source.split("."); + const src = parts[0]!; + const path = parts.slice(1); + const handle = toolDef.handles.find((h) => h.handle === src); + if (handle?.kind === "context") { + return getPath(this.engine.context, path); + } + } + } + + // Attach bridgeLoc to error for source location reporting + throw wrapBridgeRuntimeError(err, { bridgeLoc }); + } + } + + /** + * Resolve a define block result via scope chain. + * Creates a child scope, indexes define body, and pulls output. + * + * @param subFields - Optional field filter; when non-empty, only the listed + * output fields (and their transitive deps) are resolved in the define + * scope, enabling lazy evaluation when the caller only needs a subset. + * Ignored on cache hits — the first-call's field set wins. + */ + async resolveDefine( + module: string, + field: string, + instance: number | undefined, + pullPath: ReadonlySet = EMPTY_PULL_PATH, + subFields?: string[], + ): Promise { + const key = `${module}:${field}`; + + // 1. Cycle check first + if (pullPath.has(key)) { + throw new BridgePanicError( + `Circular dependency detected in define "${module}"`, + ); + } + + // 2. Cache check second + if (this.toolResults.has(key)) return this.toolResults.get(key)!; + + // Check ownership + if (this.ownedDefines.has(module)) { + // 3. Branch the path + const nextPath = new Set(pullPath).add(key); + return this.executeDefine(key, module, nextPath, subFields); + } + + // Delegate to parent + if (this.parent) { + return this.parent.resolveDefine( + module, + field, + instance, + pullPath, + subFields, + ); + } + + throw new Error(`Define "${module}" not found in any scope`); + } + + /** + * Register a lazy input factory for this define scope. + * Called by `executeDefine` so input wires are only evaluated on demand. + */ + registerLazyInput(pathKey: string, factory: () => Promise): void { + if (!this.lazyInputFactories) this.lazyInputFactories = new Map(); + this.lazyInputFactories.set(pathKey, factory); + } + + /** + * Resolve a lazy selfInput value, computing the wire on first access and + * caching the result (memoized lazy evaluation). + */ + resolveLazyInput(pathKey: string): Promise | undefined { + const factory = this.lazyInputFactories?.get(pathKey); + if (!factory) return undefined; + if (!this.lazyInputCache) this.lazyInputCache = new Map(); + let cached = this.lazyInputCache.get(pathKey); + if (!cached) { + cached = factory().then((value) => { + // Hydrate selfInput so subsequent getPath reads work + setPath(this.selfInput, pathKey ? pathKey.split(".") : [], value); + return value; + }); + this.lazyInputCache.set(pathKey, cached); } - throw attachBridgeErrorDocumentContext(err, doc); + return cached; + } + + /** + * Execute a define block — build input from bridge wires, create + * child scope with define body, pull output. + */ + private executeDefine( + key: string, + module: string, + pullPath: ReadonlySet, + subFields?: string[], + ): Promise { + const promise = (async () => { + // Map from handle alias to define name via handle bindings + const handle = module.substring("__define_".length); + const binding = this.getHandleBinding(handle); + const defineName = binding?.kind === "define" ? binding.name : handle; + + const defineDef = this.engine.instructions.find( + (i): i is DefineDef => i.kind === "define" && i.name === defineName, + ); + if (!defineDef?.body) + throw new Error(`Define "${defineName}" not found or has no body`); + + // Collect bridge wires targeting this define (input wires). + // Register them as lazy factories — they will only be evaluated when the + // define scope actually reads from selfInput for the corresponding path. + const inputWires = this.defineInputWires.get(key) ?? []; + const defineInput: Record = {}; + const defineOutput: Record = {}; + const defineScope = new ExecutionScope( + this, + defineInput, + defineOutput, + this.engine, + ); + defineScope.isRootScope = true; + + // Register each input wire (or group of overdefined wires) as a lazy + // factory so it only fires when the define body reads that field. + const parentScope = this; + const wireGroups = groupWiresByPath(inputWires); + for (const group of wireGroups) { + const pathKey = group[0]!.target.path.join("."); + const ordered = + group.length > 1 + ? orderOverdefinedWires(group, parentScope.engine) + : group; + defineScope.registerLazyInput(pathKey, async () => { + let lastError: unknown; + for (const wire of ordered) { + try { + const value = await evaluateSourceChain( + wire, + parentScope, + undefined, + pullPath, + ); + if (value != null) return value; // short-circuit: non-nullish wins + lastError = undefined; // reset — wire succeeded (null) + } catch (err) { + if (isFatalError(err) || isLoopControlSignal(err)) throw err; + lastError = err; + } + } + if (lastError) throw lastError; + return undefined; + }); + } + + // Index define body and pull output. + // Use caller-supplied subFields to enable lazy evaluation when only a + // subset of the define's output fields are actually needed. + indexStatements(defineDef.body, defineScope); + await resolveRequestedFields(defineScope, subFields ?? [], pullPath); + + return "__rootValue__" in defineOutput + ? defineOutput.__rootValue__ + : defineOutput; + })(); + + this.toolResults.set(key, promise); + return promise; } +} + +/** Shared engine-wide context. */ +interface EngineContext { + readonly tools: ToolMap; + readonly instructions: readonly (Bridge | ToolDef | ConstDef | DefineDef)[]; + readonly type: string; + readonly field: string; + readonly context: Record; + readonly logger?: Logger; + readonly tracer?: TraceCollector; + readonly signal?: AbortSignal; + readonly toolDefCache: Map; + readonly toolTimeoutMs: number; + /** Memoize caches — shared across all scopes. Keyed by owner tool key → input hash → result. */ + readonly toolMemoCache: Map>>; + /** Batch queues — shared across all scopes. Keyed by fn reference. */ + readonly toolBatchQueues: Map< + (...args: unknown[]) => unknown, + BatchToolQueue + >; + /** Maximum nesting depth for array mappings / shadow scopes. */ + readonly maxDepth: number; + /** Whether non-fatal errors are planted as sentinels instead of thrown. */ + readonly partialSuccess: boolean; + /** Trace bits map — keyed by sources array reference for O(1) lookup. */ + readonly traceBits: Map | undefined; + /** Empty-array bits map — keyed by ArrayExpression reference. */ + readonly emptyArrayBits: Map | undefined; + /** Mutable trace bitmask accumulator. */ + readonly traceMask: [bigint] | undefined; +} + +/** Record a single trace bit in the engine's trace mask. */ +function recordTraceBit(engine: EngineContext, bit: number | undefined): void { + if (bit != null && engine.traceMask) { + engine.traceMask[0] |= 1n << BigInt(bit); + } +} + +/** Pending batched tool call. */ +type PendingBatchToolCall = { + input: Record; + resolve: (value: unknown) => void; + reject: (err: unknown) => void; +}; + +/** Queue for collecting same-tick batched calls. */ +type BatchToolQueue = { + items: PendingBatchToolCall[]; + scheduled: boolean; + toolName: string; + fnName: string; + maxBatchSize?: number; + doTrace: boolean; + log: EffectiveToolLog; +}; + +/** + * Build a deterministic cache key from an arbitrary value. + * Used for memoize deduplication. + */ +function stableMemoizeKey(value: unknown): string { + if (value === undefined) return "u"; + if (value === null) return "n"; + if (typeof value === "boolean") return value ? "T" : "F"; + if (typeof value === "number") return `d:${value}`; + if (typeof value === "string") return `s:${value}`; + if (typeof value === "bigint") return `B:${value}`; + if (Array.isArray(value)) return `[${value.map(stableMemoizeKey).join(",")}]`; + if (typeof value === "object") { + const keys = Object.keys(value as Record).sort(); + return `{${keys.map((k) => `${k}:${stableMemoizeKey((value as Record)[k])}`).join(",")}}`; + } + return String(value); +} + +// ── ToolDef resolution ────────────────────────────────────────────────────── + +/** + * Resolve a ToolDef by name, walking the extends chain. + * Returns a merged ToolDef with fn from root, accumulated body, last onError. + * Returns undefined if no ToolDef exists for this name. + */ +function resolveToolDefByName( + instructions: readonly (Bridge | ToolDef | ConstDef | DefineDef)[], + name: string, + cache: Map, +): ToolDef | undefined { + if (cache.has(name)) return cache.get(name) ?? undefined; + + const toolDefs = instructions.filter((i): i is ToolDef => i.kind === "tool"); + const base = toolDefs.find((t) => t.name === name); + if (!base) { + cache.set(name, null); + return undefined; + } + + // Build extends chain: root → ... → leaf + const chain: ToolDef[] = [base]; + let current = base; + while (current.extends) { + const parent = toolDefs.find((t) => t.name === current.extends); + if (!parent) + throw new Error( + `Tool "${current.name}" extends unknown tool "${current.extends}"`, + ); + chain.unshift(parent); + current = parent; + } + + // Merge: fn from root, handles deduplicated, body accumulated, onError last wins + const merged: ToolDef = { + kind: "tool", + name, + fn: chain[0]!.fn, + handles: [], + body: [], + }; + + for (const def of chain) { + for (const h of def.handles) { + if (!merged.handles.some((mh) => mh.handle === h.handle)) { + merged.handles.push(h); + } + } + if (def.body) { + merged.body.push(...def.body); + } + if (def.onError) merged.onError = def.onError; + } + + cache.set(name, merged); + return merged; +} + +/** + * Evaluate ToolDef body statements to build base tool input. + * Creates a child scope for inner tool handles and context resolution. + */ +async function evaluateToolDefBody( + body: Statement[], + input: Record, + callerScope: ExecutionScope, + pullPath: ReadonlySet, +): Promise { + // Create a temporary scope for ToolDef body — inner tools are owned here + const toolDefScope = new ExecutionScope( + callerScope, + callerScope.selfInput, + {}, + callerScope.engine, + ); + + // Register inner tool handles + for (const stmt of body) { + if (stmt.kind === "with") { + if (stmt.binding.kind === "tool") { + toolDefScope.declareToolBinding(stmt.binding.name); + } + toolDefScope.registerHandle(stmt.binding); + } + } + + // Index inner tool input wires (for tool-to-tool deps within ToolDef) + for (const stmt of body) { + if (stmt.kind === "wire" && stmt.target.instance != null) { + toolDefScope.addToolInputWire(stmt); + } + } + + // Evaluate wires targeting the tool itself (no instance = tool config) + const configStmts = body.filter( + (stmt): stmt is WireStatement | ScopeStatement | SpreadStatement => + (stmt.kind === "wire" && stmt.target.instance == null) || + stmt.kind === "scope" || + stmt.kind === "spread", + ); + await Promise.all( + configStmts.map(async (stmt) => { + if (stmt.kind === "wire") { + const value = await evaluateSourceChain( + stmt, + toolDefScope, + undefined, + pullPath, + ); + setPath(input, stmt.target.path, value); + } else if (stmt.kind === "spread") { + const spreadValue = await evaluateSourceChain( + stmt, + toolDefScope, + undefined, + pullPath, + ); + if ( + spreadValue != null && + typeof spreadValue === "object" && + !Array.isArray(spreadValue) + ) { + Object.assign(input, spreadValue as Record); + } + } else { + await evaluateToolDefScope(stmt, input, toolDefScope, pullPath); + } + }), + ); +} + +/** Recursively evaluate scope blocks inside ToolDef bodies. */ +async function evaluateToolDefScope( + scope: ScopeStatement, + input: Record, + toolDefScope: ExecutionScope, + pullPath: ReadonlySet, +): Promise { + const prefix = scope.target.path; + await Promise.all( + scope.body.map(async (inner) => { + if (inner.kind === "wire" && inner.target.instance == null) { + const value = await evaluateSourceChain( + inner, + toolDefScope, + undefined, + pullPath, + ); + setPath(input, [...prefix, ...inner.target.path], value); + } else if (inner.kind === "scope") { + const nested: ScopeStatement = { + ...inner, + target: { + ...inner.target, + path: [...prefix, ...inner.target.path], + }, + }; + await evaluateToolDefScope(nested, input, toolDefScope, pullPath); + } + }), + ); +} + +// ── Batched tool calls ────────────────────────────────────────────────────── + +/** + * Queue a batched tool call — collects calls within the same microtask tick + * and flushes them as a single array call to the tool function. + */ +function callBatchedTool( + engine: EngineContext, + fn: (...args: unknown[]) => unknown, + input: Record, + toolName: string, + fnName: string, + batchMeta: { maxBatchSize?: number }, + doTrace: boolean, + log: EffectiveToolLog, +): Promise { + let queue = engine.toolBatchQueues.get(fn); + if (!queue) { + queue = { + items: [], + scheduled: false, + toolName, + fnName, + maxBatchSize: batchMeta.maxBatchSize, + doTrace, + log, + }; + engine.toolBatchQueues.set(fn, queue); + } + + return new Promise((resolve, reject) => { + queue.items.push({ input, resolve, reject }); + + if (!queue.scheduled) { + queue.scheduled = true; + queueMicrotask(() => flushBatchedToolQueue(engine, fn, queue)); + } + }); +} + +/** + * Flush a batched tool queue — calls the tool with an array of inputs, + * distributes results back to individual callers. + */ +async function flushBatchedToolQueue( + engine: EngineContext, + fn: (...args: unknown[]) => unknown, + queue: BatchToolQueue, +): Promise { + const items = queue.items.splice(0); + queue.scheduled = false; + + const tracer = engine.tracer; + + // Chunk by maxBatchSize if configured + const maxSize = queue.maxBatchSize ?? items.length; + for (let offset = 0; offset < items.length; offset += maxSize) { + const chunk = items.slice(offset, offset + maxSize); + const batchInput = chunk.map((c) => c.input); + + const toolContext = { + logger: engine.logger, + signal: engine.signal, + }; + + const startMs = tracer?.now(); + const wallStart = performance.now(); + + try { + let result = fn(batchInput, toolContext) as + | unknown[] + | Promise; + if (isPromise(result)) { + if (engine.toolTimeoutMs > 0) { + result = await raceTimeout( + result as Promise, + engine.toolTimeoutMs, + queue.toolName, + ); + } else { + result = await (result as Promise); + } + } + + const durationMs = performance.now() - wallStart; + + // Record a single trace entry for the entire batch + if (tracer && startMs != null && queue.doTrace) { + tracer.record( + tracer.entry({ + tool: queue.toolName, + fn: queue.fnName, + input: batchInput, + output: result, + durationMs, + startedAt: startMs, + }), + ); + } + logToolSuccess( + engine.logger, + queue.log.execution, + queue.toolName, + queue.fnName, + durationMs, + ); + + if (!Array.isArray(result) || result.length !== chunk.length) { + const err = new Error( + `Batch tool "${queue.fnName}" returned ${Array.isArray(result) ? result.length : typeof result} items, expected ${chunk.length}`, + ); + for (const item of chunk) item.reject(err); + continue; + } + + for (let i = 0; i < chunk.length; i++) { + const value = result[i]; + if (value instanceof Error) { + chunk[i]!.reject(value); + } else { + chunk[i]!.resolve(value); + } + } + } catch (err) { + const durationMs = performance.now() - wallStart; + + // Record error trace for the batch + if (tracer && startMs != null && queue.doTrace) { + tracer.record( + tracer.entry({ + tool: queue.toolName, + fn: queue.fnName, + input: batchInput, + error: (err as Error).message, + durationMs, + startedAt: startMs, + }), + ); + } + logToolError( + engine.logger, + queue.log.errors, + queue.toolName, + queue.fnName, + err as Error, + ); + + for (const item of chunk) item.reject(err); + } + } +} + +// ── Statement indexing & pulling ──────────────────────────────────────────── + +/** + * Index phase — walk statements and register tool bindings and input wires. + * Does NOT evaluate anything. Recurses into ScopeStatements (same scope). + */ +function indexStatements( + statements: Statement[], + scope: ExecutionScope, + scopeCtx?: { pathPrefix: string[]; toolTarget?: NodeRef }, +): void { + for (const stmt of statements) { + switch (stmt.kind) { + case "with": + if (stmt.binding.kind === "tool") { + scope.declareToolBinding(stmt.binding.name, stmt.binding.memoize); + } else if (stmt.binding.kind === "define") { + scope.declareDefineBinding(stmt.binding.handle); + } + scope.registerHandle(stmt.binding); + break; + case "spread": + scope.addSpread(stmt, scopeCtx?.pathPrefix ?? []); + break; + case "wire": { + const target = stmt.target; + // Define input wire — wire targeting a __define_* module + if (target.module.startsWith("__define_")) { + scope.addDefineInputWire(stmt); + break; + } + const isToolInput = target.instance != null && !target.element; + if (isToolInput) { + // Direct tool input wire (e.g. a.q <- i.q) + scope.addToolInputWire(stmt); + } else if (scopeCtx?.toolTarget) { + // Wire inside a tool input scope block — remap to tool input + const tt = scopeCtx.toolTarget; + const prefixed = { + ...stmt, + target: { + ...tt, + path: [...scopeCtx.pathPrefix, ...target.path], + }, + }; + scope.addToolInputWire(prefixed); + } else if (scopeCtx) { + // Wire inside an output scope block — prefix the path + const prefixed = { + ...stmt, + target: { + ...target, + path: [...scopeCtx.pathPrefix, ...target.path], + }, + }; + scope.addOutputWire(prefixed); + } else { + scope.addOutputWire(stmt); + } + break; + } + case "alias": + scope.addAlias(stmt); + break; + case "scope": { + const st = stmt.target; + const isScopeOnTool = st.instance != null && !st.element; + const prefix = [...(scopeCtx?.pathPrefix ?? []), ...st.path]; + if (isScopeOnTool) { + // Scope block targeting a tool input (e.g. a.query { ... }) + indexStatements(stmt.body, scope, { + pathPrefix: prefix, + toolTarget: scopeCtx?.toolTarget ?? st, + }); + } else if (scopeCtx?.toolTarget) { + // Nested output scope inside a tool scope — keep tool context + indexStatements(stmt.body, scope, { + pathPrefix: prefix, + toolTarget: scopeCtx.toolTarget, + }); + } else { + // Output scope block (e.g. o.result { ... }) + indexStatements(stmt.body, scope, { pathPrefix: prefix }); + } + break; + } + case "force": + scope.forceStatements.push(stmt); + break; + } + } +} + +/** + * Build a StaticScopeIndex by indexing statements into a temporary scope + * and extracting its maps. The result is shared across all array element + * scopes to avoid re-indexing per element. + */ +function buildStaticIndex( + statements: Statement[], + parentScope: ExecutionScope, +): StaticScopeIndex { + // Use a temporary scope as a builder — it has the same parent chain + // so scope-context resolution during indexing works identically. + const temp = new ExecutionScope( + parentScope, + parentScope.selfInput, + {}, + parentScope.engine, + ); + indexStatements(statements, temp); + return temp.extractStaticIndex(); +} + +/** + * Compute sub-requestedFields for a wire target. + * + * Given a wire at `wireKey` and the parent's `requestedFields`, returns the + * fields that should be forwarded to array expressions within the wire. + * - Root wire (key ""): all requestedFields pass through unchanged + * - Exact match: empty array (unrestricted — resolve all sub-fields) + * - Prefix match: strip the wire key prefix + */ +function computeSubRequestedFields( + wireKey: string, + requestedFields: string[], +): string[] { + if (wireKey === "") return requestedFields; + + const subFields: string[] = []; + for (const field of requestedFields) { + if (field === wireKey) return []; // Exact match → unrestricted + if (field.startsWith(wireKey + ".")) { + subFields.push(field.slice(wireKey.length + 1)); + } + // Handle wildcard: "legs.*" for wireKey "legs" → sub-field "*" + if (field.endsWith(".*") && wireKey === field.slice(0, -2)) { + return []; // Wildcard on this exact level → unrestricted + } + } + return subFields; +} + +/** + * Demand-driven pull — resolve only the requested output fields. + * Evaluates output wires from the index (not by walking the AST). + * Tool calls happen lazily when their output is read during source evaluation. + * + * If no specific fields are requested, all indexed output wires are resolved. + * + * All output wire groups are evaluated concurrently so that tool-referencing + * wires can start their tool calls before input-only wires that may panic. + * This matches v1 eager-evaluation semantics. + * + * Supports overdefinition: when multiple wires target the same output path, + * they are ordered by cost (cheapest first) and evaluated with null-coalescing + * — the first non-null result wins. + */ +async function resolveRequestedFields( + scope: ExecutionScope, + requestedFields: string[], + pullPath: ReadonlySet = EMPTY_PULL_PATH, +): Promise { + // Get wire groups — each group is an array of wires targeting the same path + const wireGroups: WireStatement[][] = + requestedFields.length > 0 + ? scope.collectMatchingOutputWireGroups(requestedFields) + : scope.allOutputFields().map((f) => scope.getOutputWires(f)!); + + // Evaluate all wire groups concurrently + type Signal = LoopControlSignal | typeof BREAK_SYM | typeof CONTINUE_SYM; + + const settled = await Promise.allSettled( + wireGroups.map(async (wires): Promise => { + // Order overdefined wires by cost (cheapest first) + const ordered = + wires.length > 1 ? orderOverdefinedWires(wires, scope.engine) : wires; + + // Compute sub-requestedFields for array expressions within this wire. + let subFields: string[] | undefined; + if (requestedFields.length > 0) { + const wireKey = ordered[0]!.target.path.join("."); + subFields = computeSubRequestedFields(wireKey, requestedFields); + } + + // Null-coalescing across overdefined wires + let value: unknown; + let lastError: unknown; + for (const wire of ordered) { + try { + value = await evaluateSourceChain(wire, scope, subFields, pullPath); + if (isLoopControlSignal(value)) return value; + if (value != null) break; + } catch (err) { + if (isFatalError(err) && !scope.engine.partialSuccess) throw err; + lastError = err; + } + } + + if (value == null && lastError) { + if (scope.engine.partialSuccess) { + writeTarget( + ordered[0]!.target, + lastError instanceof Error + ? lastError + : new Error(String(lastError)), + scope, + ); + return undefined; + } + throw lastError; + } + + writeTarget(ordered[0]!.target, value, scope); + return undefined; + }), + ); + + // Evaluate spread statements concurrently + await Promise.all( + scope.getSpreads().map(async ({ stmt: spread, pathPrefix }) => { + const spreadValue = await evaluateSourceChain( + spread, + scope, + undefined, + pullPath, + ); + if ( + spreadValue != null && + typeof spreadValue === "object" && + !Array.isArray(spreadValue) + ) { + const targetOutput = scope.root().output; + if (pathPrefix.length > 0) { + let nested: Record = targetOutput; + for (const segment of pathPrefix) { + if (UNSAFE_KEYS.has(segment)) + throw new Error(`Unsafe assignment key: ${segment}`); + if ( + nested[segment] == null || + typeof nested[segment] !== "object" || + Array.isArray(nested[segment]) + ) { + nested[segment] = {}; + } + nested = nested[segment] as Record; + } + Object.assign(nested, spreadValue as Record); + } else { + Object.assign(targetOutput, spreadValue as Record); + } + } + }), + ); + + // Process results: collect errors and signals, preserving wire order. + let fatalError: unknown; + let firstError: unknown; + let firstSignal: Signal | undefined; + + for (const result of settled) { + if (result.status === "rejected") { + if (isFatalError(result.reason)) { + if (!fatalError) fatalError = result.reason; + } else { + if (!firstError) firstError = result.reason; + } + } else if (result.value != null) { + if (!firstSignal) firstSignal = result.value; + } + } + + if (fatalError) throw fatalError; + if (firstSignal) return firstSignal; + if (firstError) throw firstError; +} + +/** + * Group a flat array of wires by their target path. + * Used to detect overdefinition and apply short-circuit evaluation. + */ +function groupWiresByPath(wires: WireStatement[]): WireStatement[][] { + const groups = new Map(); + for (const wire of wires) { + const pathKey = wire.target.path.join("."); + let group = groups.get(pathKey); + if (!group) { + group = []; + groups.set(pathKey, group); + } + group.push(wire); + } + return Array.from(groups.values()); +} + +/** + * Order overdefined wires by cost — cheapest source first. + * Input/context/const/element refs are "free" (cost 0), tool refs are expensive. + * Same-cost wires preserve authored order. + */ +function orderOverdefinedWires( + wires: WireStatement[], + engine: EngineContext, +): WireStatement[] { + const ranked = wires.map((wire, index) => ({ + wire, + index, + cost: computeExprCost(wire.sources[0]!.expr, engine, new Set()), + })); + ranked.sort((left, right) => { + if (left.cost !== right.cost) return left.cost - right.cost; + return left.index - right.index; // stable: preserve source order + }); + return ranked.map((entry) => entry.wire); +} + +/** + * Compute the optimistic cost of an expression for overdefinition ordering. + * - literals/control → 0 + * - input/context/const/element refs → 0 + * - tool refs → 2 (or sync tool → 1, or meta.cost if set) + * - ternary/and/or → max of branches + */ +function computeExprCost( + expr: Expression, + engine: EngineContext, + visited: Set, +): number { + switch (expr.type) { + case "literal": + case "control": + return 0; + case "ref": { + const ref = expr.ref; + if (ref.element) return 0; + if (ref.type === "Context" || ref.type === "Const") return 0; + if (ref.module === SELF_MODULE && ref.type === "__local") return 0; + if (ref.module === SELF_MODULE && ref.instance == null) return 0; // input ref + // Tool ref — look up metadata + const toolName = + ref.module === SELF_MODULE ? ref.field : `${ref.module}.${ref.field}`; + const key = toolName; + if (visited.has(key)) return Infinity; + visited.add(key); + const fn = lookupToolFn(engine.tools, toolName); + if (fn) { + const meta = (fn as unknown as Record).bridge as + | Record + | undefined; + if (meta?.cost != null) return meta.cost as number; + return meta?.sync ? 1 : 2; + } + return 2; + } + case "ternary": + return Math.max( + computeExprCost(expr.cond, engine, visited), + computeExprCost(expr.then, engine, visited), + computeExprCost(expr.else, engine, visited), + ); + case "and": + case "or": + return Math.max( + computeExprCost(expr.left, engine, visited), + computeExprCost(expr.right, engine, visited), + ); + case "array": + case "pipe": + return computeExprCost(expr.source, engine, visited); + case "binary": + return Math.max( + computeExprCost(expr.left, engine, visited), + computeExprCost(expr.right, engine, visited), + ); + case "unary": + return computeExprCost(expr.operand, engine, visited); + case "concat": { + let max = 0; + for (const part of expr.parts) { + max = Math.max(max, computeExprCost(part, engine, visited)); + } + return max; + } + } +} + +/** + * Evaluate a source chain (fallback gates: ||, ??). + * Wraps with catch handler if present. Attaches bridgeLoc on error. + * Records execution trace bits when the engine has trace maps configured. + */ +async function evaluateSourceChain( + chain: SourceChain, + scope: ExecutionScope, + requestedFields?: string[], + pullPath: ReadonlySet = EMPTY_PULL_PATH, +): Promise { + const bits = scope.engine.traceBits?.get(chain.sources); + let activeSourceIndex = -1; + let ternaryElsePath = false; + + try { + let value: unknown; + + for (let i = 0; i < chain.sources.length; i++) { + const entry = chain.sources[i]!; + if (entry.gate === "falsy" && value) continue; + if (entry.gate === "nullish" && value != null) continue; + activeSourceIndex = i; + + const expr = entry.expr; + + // Record the trace bit BEFORE evaluating so even if the expression + // throws, the path is marked as visited. + if (bits) { + if (i === 0 && expr.type === "ternary") { + // Ternary primary — defer bit recording until we know which branch + } else if (i === 0) { + recordTraceBit(scope.engine, bits.primary); + } else { + recordTraceBit(scope.engine, bits.fallbacks?.[i - 1]); + } + } + + // Ternary primary — evaluate condition inline to record then/else bits + if (i === 0 && expr.type === "ternary" && bits) { + const cond = await evaluateExpression( + expr.cond, + scope, + undefined, + pullPath, + ); + if (cond) { + recordTraceBit(scope.engine, bits.primary); + value = await evaluateExpression( + expr.then, + scope, + requestedFields, + pullPath, + ); + } else { + ternaryElsePath = true; + recordTraceBit(scope.engine, bits.else); + value = await evaluateExpression( + expr.else, + scope, + requestedFields, + pullPath, + ); + } + } else { + value = await evaluateExpression( + expr, + scope, + requestedFields, + pullPath, + ); + } + } + + return value; + } catch (err) { + const activeEntry = + activeSourceIndex >= 0 ? chain.sources[activeSourceIndex] : undefined; + const errLoc = + activeEntry?.expr.loc ?? + activeEntry?.loc ?? + (chain as { loc?: SourceLocation }).loc; + + if (isFatalError(err)) { + if (errLoc && !(err as { bridgeLoc?: SourceLocation }).bridgeLoc) { + (err as { bridgeLoc?: SourceLocation }).bridgeLoc = errLoc; + } + throw err; + } + if (chain.catch) { + // Record catch bit and delegate to catch handler + recordTraceBit(scope.engine, bits?.catch); + try { + return await applyCatchHandler(chain.catch, scope, pullPath); + } catch (catchErr) { + // Record catchError only for non-control-flow errors from the catch handler + if ( + bits?.catchError != null && + !isFatalError(catchErr) && + catchErr !== BREAK_SYM && + catchErr !== CONTINUE_SYM + ) { + recordTraceBit(scope.engine, bits.catchError); + } + throw catchErr; + } + } + // No catch — record error bit for the active source + if (bits) { + if (activeSourceIndex === 0 && ternaryElsePath) { + recordTraceBit(scope.engine, bits.elseError); + } else if (activeSourceIndex === 0) { + recordTraceBit(scope.engine, bits.primaryError); + } else if (activeSourceIndex > 0) { + recordTraceBit( + scope.engine, + bits.fallbackErrors?.[activeSourceIndex - 1], + ); + } + } + if (errLoc) throw wrapBridgeRuntimeError(err, { bridgeLoc: errLoc }); + throw err; + } +} + +/** + * Apply a catch handler — returns a literal, resolves a ref, or + * executes control flow (throw/panic/continue/break). + */ +async function applyCatchHandler( + c: WireCatch, + scope: ExecutionScope, + pullPath: ReadonlySet = EMPTY_PULL_PATH, +): Promise { + if ("control" in c) { + return applyControlFlow(c.control); + } + if ("expr" in c) { + return evaluateExpression(c.expr, scope, undefined, pullPath); + } + if ("ref" in c) { + return resolveRef(c.ref, scope, undefined, pullPath); + } + // Literal value + return c.value; +} + +/** + * Eagerly schedule force tool calls. + * + * Returns an array of promises for critical (non-catch) force statements. + * Fire-and-forget forces (`catch null`) have errors silently swallowed. + */ +function executeForced(scope: ExecutionScope): Promise[] { + const critical: Promise[] = []; + + for (const stmt of scope.forceStatements) { + const promise = scope.resolveToolResult( + stmt.module, + stmt.field, + stmt.instance, + undefined, + EMPTY_PULL_PATH, + ); + if (stmt.catchError) { + promise.catch(() => {}); + } else { + critical.push(promise); + } + } + + return critical; +} + +/** + * Evaluate an expression safely — swallows non-fatal errors and returns undefined. + * Fatal errors (panic, abort) always propagate. + */ +function catchSafe(err: unknown): undefined { + if (isFatalError(err)) throw err; + return undefined; +} + +/** + * Evaluate an expression tree. + */ +async function evaluateExpression( + expr: Expression, + scope: ExecutionScope, + requestedFields?: string[], + pullPath: ReadonlySet = EMPTY_PULL_PATH, +): Promise { + switch (expr.type) { + case "ref": + if (expr.safe) { + return resolveRef( + expr.ref, + scope, + expr.refLoc ?? expr.loc, + pullPath, + requestedFields, + ).catch(catchSafe); + } + return resolveRef( + expr.ref, + scope, + expr.refLoc ?? expr.loc, + pullPath, + requestedFields, + ); + + case "literal": + return expr.value; + + case "array": + return evaluateArrayExpr(expr, scope, requestedFields, pullPath); + + case "ternary": { + let cond: unknown; + try { + cond = await evaluateExpression(expr.cond, scope, undefined, pullPath); + } catch (err) { + if (isFatalError(err)) throw err; + const loc = expr.condLoc ?? expr.cond.loc ?? expr.loc; + if (loc) throw wrapBridgeRuntimeError(err, { bridgeLoc: loc }); + throw err; + } + const branch = cond ? expr.then : expr.else; + try { + return await evaluateExpression(branch, scope, undefined, pullPath); + } catch (err) { + if (isFatalError(err)) throw err; + const loc = branch.loc ?? expr.loc; + if (loc) throw wrapBridgeRuntimeError(err, { bridgeLoc: loc }); + throw err; + } + } + + case "and": { + const left = expr.leftSafe + ? await evaluateExpression(expr.left, scope, undefined, pullPath).catch( + catchSafe, + ) + : await evaluateExpression(expr.left, scope, undefined, pullPath); + if (!left) return false; + if (expr.right.type === "literal" && expr.right.value === "true") { + return Boolean(left); + } + const right = expr.rightSafe + ? await evaluateExpression( + expr.right, + scope, + undefined, + pullPath, + ).catch(catchSafe) + : await evaluateExpression(expr.right, scope, undefined, pullPath); + return Boolean(right); + } + + case "or": { + const left = expr.leftSafe + ? await evaluateExpression(expr.left, scope, undefined, pullPath).catch( + catchSafe, + ) + : await evaluateExpression(expr.left, scope, undefined, pullPath); + if (left) return true; + if (expr.right.type === "literal" && expr.right.value === "true") { + return Boolean(left); + } + const right = expr.rightSafe + ? await evaluateExpression( + expr.right, + scope, + undefined, + pullPath, + ).catch(catchSafe) + : await evaluateExpression(expr.right, scope, undefined, pullPath); + return Boolean(right); + } + + case "control": { + try { + return applyControlFlow(expr.control); + } catch (err) { + if (isFatalError(err)) { + if (expr.loc && !(err as { bridgeLoc?: SourceLocation }).bridgeLoc) { + (err as { bridgeLoc?: SourceLocation }).bridgeLoc = expr.loc; + } + throw err; + } + throw wrapBridgeRuntimeError(err, { bridgeLoc: expr.loc }); + } + } + + case "binary": { + const [left, right] = await Promise.all([ + evaluateExpression(expr.left, scope, undefined, pullPath), + evaluateExpression(expr.right, scope, undefined, pullPath), + ]); + switch (expr.op) { + case "add": + case "sub": + case "mul": + case "div": + // Propagate null/undefined so that downstream `??` fallbacks can fire. + if (left == null || right == null) return null; + switch (expr.op) { + case "add": + return Number(left) + Number(right); + case "sub": + return Number(left) - Number(right); + case "mul": + return Number(left) * Number(right); + case "div": + return Number(left) / Number(right); + } + break; + case "eq": + return left === right; + case "neq": + return left !== right; + case "gt": + return Number(left) > Number(right); + case "gte": + return Number(left) >= Number(right); + case "lt": + return Number(left) < Number(right); + case "lte": + return Number(left) <= Number(right); + } + break; + } + + case "unary": + return !(await evaluateExpression( + expr.operand, + scope, + undefined, + pullPath, + )); + + case "concat": { + const parts = await Promise.all( + expr.parts.map((p) => + evaluateExpression(p, scope, undefined, pullPath), + ), + ); + return parts.map((v) => (v == null ? "" : String(v))).join(""); + } + + case "pipe": + return evaluatePipeExpression(expr, scope, pullPath); + + default: + throw new Error(`Unknown expression type: ${(expr as Expression).type}`); + } +} + +// ── Array execution plan ────────────────────────────────────────────────── + +/** Pre-computed execution plan for an array body, built once per array map. */ +interface ArrayExecutionPlan { + bodyIndex: StaticScopeIndex; + wireTasks: { ordered: WireStatement[]; subFields?: string[] }[]; + spreads: { stmt: SpreadStatement; pathPrefix: string[] }[]; +} + +/** + * Check if an execution plan can be fully evaluated synchronously. + * + * True when every wire task is a single, non-overdefined element ref + * (no fallbacks, no catch, depth 0) targeting an element output path, + * and there are no spreads. This covers the common pattern of + * `o <- source[] as it { .x <- it.x .y <- it.y }`. + */ +function isPlanSynchronous(plan: ArrayExecutionPlan): boolean { + if (plan.spreads.length > 0) return false; + for (const { ordered } of plan.wireTasks) { + if (ordered.length !== 1) return false; + const wire = ordered[0]!; + if (wire.catch) return false; + if (!wire.target.element) return false; + if (wire.sources.length !== 1) return false; + const src = wire.sources[0]!; + if (src.gate) return false; + const expr = src.expr; + if (expr.type !== "ref") return false; + const ref = expr.ref; + if (!ref.element) return false; + if (ref.elementDepth != null && ref.elementDepth !== 0) return false; + } + return true; +} + +/** Analyse the static array body AST and build a reusable execution plan. */ +function buildArrayExecutionPlan( + expr: Extract, + scope: ExecutionScope, + requestedFields?: string[], +): ArrayExecutionPlan { + const bodyIndex = buildStaticIndex(expr.body, scope); + + // Disposable scope — only used to query the shared index + const planScope = new ExecutionScope( + scope, + scope.selfInput, + {}, + scope.engine, + 0, + bodyIndex, + ); + + const wireGroups = + requestedFields && requestedFields.length > 0 + ? planScope.collectMatchingOutputWireGroups(requestedFields) + : planScope.allOutputFields().map((f) => planScope.getOutputWires(f)!); + + const wireTasks = wireGroups.map((wires) => { + const ordered = + wires.length > 1 ? orderOverdefinedWires(wires, scope.engine) : wires; + let subFields: string[] | undefined; + if (requestedFields && requestedFields.length > 0) { + const wireKey = ordered[0]!.target.path.join("."); + subFields = computeSubRequestedFields(wireKey, requestedFields); + } + return { ordered, subFields }; + }); + + return { bodyIndex, wireTasks, spreads: planScope.getSpreads() }; +} + +type AnySignal = LoopControlSignal | typeof BREAK_SYM | typeof CONTINUE_SYM; + +/** Execute a single array element against a pre-computed plan. */ +async function evaluateArrayElement( + element: unknown, + plan: ArrayExecutionPlan, + parentScope: ExecutionScope, + childDepth: number, + pullPath: ReadonlySet, +): Promise<{ + elementOutput: Record; + signal: AnySignal | undefined; +}> { + const elementOutput: Record = {}; + const childScope = new ExecutionScope( + parentScope, + parentScope.selfInput, + elementOutput, + parentScope.engine, + childDepth, + plan.bodyIndex, + ); + childScope.pushElement(element); + + // Evaluate wires concurrently + const wirePromises = plan.wireTasks.map(async ({ ordered, subFields }) => { + let value: unknown; + let lastError: unknown; + for (const wire of ordered) { + try { + value = await evaluateSourceChain( + wire, + childScope, + subFields, + pullPath, + ); + if (isLoopControlSignal(value)) return value; + if (value != null) break; + } catch (err) { + if (isFatalError(err) && !childScope.engine.partialSuccess) throw err; + lastError = err; + } + } + if (value == null && lastError) { + if (childScope.engine.partialSuccess) { + writeTarget( + ordered[0]!.target, + lastError instanceof Error ? lastError : new Error(String(lastError)), + childScope, + ); + return undefined; + } + throw lastError; + } + writeTarget(ordered[0]!.target, value, childScope); + return undefined; + }); + + // Evaluate spreads concurrently + const spreadPromises = plan.spreads.map( + async ({ stmt: spread, pathPrefix }) => { + const spreadValue = await evaluateSourceChain( + spread, + childScope, + undefined, + pullPath, + ); + if ( + spreadValue != null && + typeof spreadValue === "object" && + !Array.isArray(spreadValue) + ) { + if (pathPrefix.length > 0) { + let nested: Record = elementOutput; + for (const segment of pathPrefix) { + if (UNSAFE_KEYS.has(segment)) + throw new Error(`Unsafe assignment key: ${segment}`); + if ( + nested[segment] == null || + typeof nested[segment] !== "object" || + Array.isArray(nested[segment]) + ) { + nested[segment] = {}; + } + nested = nested[segment] as Record; + } + Object.assign(nested, spreadValue as Record); + } else { + Object.assign(elementOutput, spreadValue as Record); + } + } + }, + ); + + // Await all — inner promises handle non-fatal errors internally + // (via writeTarget sentinels), so only fatal errors reject here. + const results = await Promise.all([...wirePromises, ...spreadPromises]); + const firstSignal = results.find(isLoopControlSignal) as + | AnySignal + | undefined; + return { elementOutput, signal: firstSignal }; +} + +// ── Main array expression evaluator ────────────────────────────────────── + +const ARRAY_CHUNK_SIZE = 2048; + +/** + * Evaluate an array mapping expression. + * + * Delegates static AST analysis to `buildArrayExecutionPlan` (once per array), + * element execution to `evaluateArrayElement` (once per element), and handles + * chunking and break/continue signal propagation here. + */ +async function evaluateArrayExpr( + expr: Extract, + scope: ExecutionScope, + requestedFields?: string[], + pullPath: ReadonlySet = EMPTY_PULL_PATH, +): Promise { + const sourceValue = await evaluateExpression( + expr.source, + scope, + undefined, + pullPath, + ); + + if (sourceValue == null) { + const emptyBit = scope.engine.emptyArrayBits?.get(expr); + if (emptyBit != null) recordTraceBit(scope.engine, emptyBit); + return null; + } + if (!Array.isArray(sourceValue)) return []; + if (sourceValue.length === 0) { + const emptyBit = scope.engine.emptyArrayBits?.get(expr); + if (emptyBit != null) recordTraceBit(scope.engine, emptyBit); + return []; + } + + const childDepth = scope["depth"] + 1; + if (childDepth > scope.engine.maxDepth) { + throw new BridgePanicError( + `Maximum execution depth exceeded (${childDepth}). Check for infinite recursion or circular array mappings.`, + ); + } + + const plan = buildArrayExecutionPlan(expr, scope, requestedFields); + + // ── Synchronous fast path for pure element-ref arrays ────────────────── + // When every wire is a simple element property read (no fallbacks, catches, + // overdefinition, or spreads), skip all async machinery and evaluate the + // entire array in a tight synchronous loop. + if (isPlanSynchronous(plan)) { + const engine = scope.engine; + // Pre-extract ref / target / trace-bit info outside the element loop + const taskMeta = plan.wireTasks.map(({ ordered }) => { + const wire = ordered[0]!; + const ref = ( + wire.sources[0]!.expr as Extract + ).ref; + const bits = engine.traceBits?.get(wire.sources); + return { ref, targetPath: wire.target.path, primaryBit: bits?.primary }; + }); + + const results: unknown[] = new Array(sourceValue.length); + for (let i = 0; i < sourceValue.length; i++) { + const element = sourceValue[i]; + const elementOutput: Record = {}; + for (const { ref, targetPath, primaryBit } of taskMeta) { + if (primaryBit != null) recordTraceBit(engine, primaryBit); + setPath( + elementOutput, + targetPath, + getPath(element, ref.path, ref.rootSafe, ref.pathSafe), + ); + } + results[i] = elementOutput; + } + return results; + } + + // ── General async path ───────────────────────────────────────────────── + const results: unknown[] = []; + let propagate: AnySignal | undefined; + + for ( + let chunkStart = 0; + chunkStart < sourceValue.length; + chunkStart += ARRAY_CHUNK_SIZE + ) { + const chunkEnd = Math.min( + chunkStart + ARRAY_CHUNK_SIZE, + sourceValue.length, + ); + + const chunkResults = await Promise.all( + sourceValue + .slice(chunkStart, chunkEnd) + .map((el) => + evaluateArrayElement(el, plan, scope, childDepth, pullPath), + ), + ); + + for (const { elementOutput, signal } of chunkResults) { + if (isLoopControlSignal(signal)) { + if (signal === CONTINUE_SYM) continue; + if (signal === BREAK_SYM) break; + // Multi-level: consume one boundary, propagate remainder + propagate = decrementLoopControl(signal); + if (signal.__bridgeControl === "break") break; + continue; + } + + results.push(elementOutput); + } + + if ( + propagate === BREAK_SYM || + (propagate && + typeof propagate === "object" && + propagate.__bridgeControl === "break") + ) + break; + } + + if (propagate) return propagate; + return results; +} + +/** + * Evaluate a pipe expression — creates an independent tool call. + * + * Each pipe evaluation is a separate, non-memoized tool call. + * Pipe source goes to `input.in` (default) or `input.` (if path set). + * ToolDef base wires and bridge input wires are merged in. + */ +async function evaluatePipeExpression( + expr: Extract, + scope: ExecutionScope, + pullPath: ReadonlySet = EMPTY_PULL_PATH, +): Promise { + const pipeKey = `pipe:${expr.handle}`; + + // 1. Cycle check + if (pullPath.has(pipeKey)) { + throw new BridgePanicError( + `Circular dependency detected in pipe "${expr.handle}"`, + ); + } + + // 2. Branch the path + const nextPath = new Set(pullPath).add(pipeKey); + + // 3. Evaluate source (use original pullPath — source is outside the pipe) + const sourceValue = await evaluateExpression( + expr.source, + scope, + undefined, + pullPath, + ); + + // 4. Look up handle binding + const binding = scope.getHandleBinding(expr.handle); + if (!binding) + throw new Error(`Pipe handle "${expr.handle}" not found in scope`); + + if (binding.kind !== "tool") + throw new Error( + `Pipe handle "${expr.handle}" must reference a tool, got "${binding.kind}"`, + ); + + // 5. Resolve ToolDef + const toolName = binding.name; + const toolDef = resolveToolDefByName( + scope.engine.instructions, + toolName, + scope.engine.toolDefCache, + ); + const fnName = toolDef?.fn ?? toolName; + const fn = lookupToolFn(scope.engine.tools, fnName); + if (!fn) throw new Error(`No tool found for "${fnName}"`); + const { doTrace } = resolveToolMeta(fn); + + // 6. Build input + const input: Record = {}; + + // 6a. ToolDef body wires (base configuration) + if (toolDef?.body) { + await evaluateToolDefBody(toolDef.body, input, scope, nextPath); + } + + // 6b. Bridge wires for this tool (non-pipe input wires) + const bridgeWires = scope.collectToolInputWiresFor(toolName); + const bridgeWireGroups = groupWiresByPath(bridgeWires); + await Promise.all( + bridgeWireGroups.map(async (group) => { + const ordered = + group.length > 1 ? orderOverdefinedWires(group, scope.engine) : group; + let lastError: unknown; + for (const wire of ordered) { + try { + const value = await evaluateSourceChain( + wire, + scope, + undefined, + nextPath, + ); + setPath(input, wire.target.path, value); + if (value != null) return; // short-circuit: non-nullish wins + lastError = undefined; // reset — wire succeeded (null) + } catch (err) { + if (isFatalError(err) || isLoopControlSignal(err)) throw err; + lastError = err; + } + } + if (lastError) throw lastError; + }), + ); + + // 4c. Pipe source → "in" or named field + const pipePath = expr.path && expr.path.length > 0 ? expr.path : ["in"]; + setPath(input, pipePath, sourceValue); + + // 5. Call tool (not memoized — each pipe is independent) + if (scope.engine.signal?.aborted) throw new BridgeAbortError(); + + const toolContext = { + logger: scope.engine.logger, + signal: scope.engine.signal, + }; + const timeoutMs = scope.engine.toolTimeoutMs; + const startMs = performance.now(); + try { + let result: unknown = fn(input, toolContext); + if (isPromise(result)) { + if (timeoutMs > 0) { + result = await raceTimeout( + result as Promise, + timeoutMs, + toolName, + ); + } else { + result = await result; + } + } + const durationMs = performance.now() - startMs; + + if (scope.engine.tracer && doTrace) { + scope.engine.tracer.record( + scope.engine.tracer.entry({ + tool: toolName, + fn: fnName, + input, + output: result, + durationMs, + startedAt: scope.engine.tracer.now() - durationMs, + }), + ); + } + + return result; + } catch (err) { + if ( + scope.engine.signal?.aborted && + err instanceof DOMException && + err.name === "AbortError" + ) { + throw new BridgeAbortError(); + } + + const durationMs = performance.now() - startMs; + + if (scope.engine.tracer && doTrace) { + scope.engine.tracer.record( + scope.engine.tracer.entry({ + tool: toolName, + fn: fnName, + input, + error: (err as Error).message, + durationMs, + startedAt: scope.engine.tracer.now() - durationMs, + }), + ); + } + + if (isFatalError(err)) throw err; + + if (toolDef?.onError) { + if ("value" in toolDef.onError) return JSON.parse(toolDef.onError.value); + } + + throw err; + } +} + +/** + * Resolve a NodeRef to its value. + */ +async function resolveRef( + ref: NodeRef, + scope: ExecutionScope, + bridgeLoc?: SourceLocation, + pullPath: ReadonlySet = EMPTY_PULL_PATH, + requestedFields?: string[], +): Promise { + // Element reference — reading from array iterator binding + if (ref.element) { + const depth = ref.elementDepth ?? 0; + const elementData = scope.getElement(depth); + return getPath(elementData, ref.path, ref.rootSafe, ref.pathSafe); + } + + // Alias reference — lazy evaluation with caching + if (ref.module === SELF_MODULE && ref.type === "__local") { + const aliasResult = await scope.resolveAlias( + ref.field, + evaluateSourceChain, + pullPath, + ); + return getPath(aliasResult, ref.path, ref.rootSafe, ref.pathSafe); + } + + // Context reference — reading from engine-supplied context + if (ref.type === "Context") { + return getPath(scope.engine.context, ref.path, ref.rootSafe, ref.pathSafe); + } + + // Const reference — reading from const definitions + if (ref.type === "Const") { + return resolveConst(ref, scope); + } + + // Define reference — resolve define subgraph + if (ref.module.startsWith("__define_")) { + // Thread requestedFields as subFields so the define scope can skip tools + // that feed fields the caller doesn't need (lazy define evaluation). + // + // When ref.path is non-empty we are reading a specific output field of the + // define (e.g. `en.enriched`). The define only needs to resolve that one + // field — pass it as the subfield. We must NOT forward the caller's + // requestedFields here because those describe sub-fields of the define's + // eventual output value, not output field names within the define block + // itself. + // + // When ref.path is empty we are reading the define's entire output (or a + // caller-specified subset). Forward the caller's requestedFields directly + // so the define can skip unneeded output wires. + const defineSubFields = + ref.path.length > 0 + ? [ref.path[0]!] + : requestedFields && requestedFields.length > 0 + ? requestedFields + : undefined; + const result = await scope.resolveDefine( + ref.module, + ref.field, + ref.instance, + pullPath, + defineSubFields, + ); + return getPath(result, ref.path, ref.rootSafe, ref.pathSafe); + } + + // Self-module input reference — reading from input args. + // For define scopes with lazy input wires, resolve on first access. + if (ref.module === SELF_MODULE && ref.instance == null) { + const pathKey = ref.path.join("."); + const lazyExact = scope.resolveLazyInput(pathKey); + if (lazyExact !== undefined) { + await lazyExact; + return getPath(scope.selfInput, ref.path, ref.rootSafe, ref.pathSafe); + } + // Check if a parent path has a lazy wire (e.g. reading "a.b" when "a" is + // lazy, or reading "a" when the whole input "" is lazy — passthrough bridges) + for (let len = ref.path.length - 1; len >= 0; len--) { + const parentKey = ref.path.slice(0, len).join("."); + const lazyParent = scope.resolveLazyInput(parentKey); + if (lazyParent !== undefined) { + await lazyParent; + return getPath(scope.selfInput, ref.path, ref.rootSafe, ref.pathSafe); + } + } + return getPath(scope.selfInput, ref.path, ref.rootSafe, ref.pathSafe); + } + + // Tool reference — reading from a tool's output (triggers lazy call) + const toolResult = await scope.resolveToolResult( + ref.module, + ref.field, + ref.instance, + bridgeLoc, + pullPath, + ); + return getPath(toolResult, ref.path, ref.rootSafe, ref.pathSafe); +} + +/** + * Resolve a const reference — looks up the ConstDef by name and traverses path. + */ +function resolveConst(ref: NodeRef, scope: ExecutionScope): unknown { + if (!ref.path.length) return undefined; + + const constName = ref.path[0]!; + const constDef = scope.engine.instructions.find( + (i): i is ConstDef => i.kind === "const" && i.name === constName, + ); + if (!constDef) throw new Error(`Const "${constName}" not found`); + + const parsed: unknown = JSON.parse(constDef.value); + const remaining = ref.path.slice(1); + const remainingPathSafe = ref.pathSafe?.slice(1); + return getPath(parsed, remaining, ref.rootSafe, remainingPathSafe); +} + +/** + * Write a value to the target output location. + * + * Element wires write to the local scope output (the array element object). + * Non-element self-module wires write to the root scope output (the top-level + * GraphQL response), ensuring writes from nested scopes don't get stranded. + */ +function writeTarget( + target: NodeRef, + value: unknown, + scope: ExecutionScope, +): void { + if (target.element) { + // Writing to element output (inside array body) + setPath(scope.output, target.path, value); + } else if (target.module === SELF_MODULE) { + // Non-element self write — always targets root output + setPath(scope.root().output, target.path, value); + } +} + +// ── Public API ────────────────────────────────────────────────────────────── + +/** + * Execute a bridge operation using the v3 scope-based engine. + * + * Pull-based: tools are only called when their output is first read. + * Tool input wires are evaluated lazily at that point, not eagerly. + * Uses `body: Statement[]` directly — no legacy `wires: Wire[]`. + */ +export async function executeBridge( + options: ExecuteBridgeOptions, +): Promise> { + const { document: doc, operation, input = {}, context = {} } = options; + + const parts = operation.split("."); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error( + `Invalid operation "${operation}" — expected "Type.field" (e.g. "Query.myField")`, + ); + } + + const [type, field] = parts as [string, string]; + + // Find the bridge instruction for this operation + const bridge = doc.instructions.find( + (i): i is Bridge => + i.kind === "bridge" && i.type === type && i.field === field, + ); + if (!bridge) { + throw new Error(`Bridge "${operation}" not found in document`); + } + if (!bridge.body) { + throw new Error( + `Bridge "${operation}" has no body — v3 engine requires Statement[] body`, + ); + } + + // Resolve std namespace + const userTools = options.tools ?? {}; + const { namespace: activeStd } = resolveStd( + doc.version, + bundledStd, + BUNDLED_STD_VERSION, + userTools, + ); + const allTools: ToolMap = { std: activeStd, ...userTools }; + + // Set up tracer + const traceLevel = options.trace ?? "off"; + const tracer = + traceLevel !== "off" ? new TraceCollector(traceLevel) : undefined; + + // Build execution trace maps for traversal tracking + const { chainBitsMap, emptyArrayBits } = buildBodyTraversalMaps(bridge); + const traceMask: [bigint] = [0n]; + + // Create engine context + const engine: EngineContext = { + tools: allTools, + instructions: doc.instructions, + type, + field, + context, + logger: options.logger, + tracer, + signal: options.signal, + toolDefCache: new Map(), + toolTimeoutMs: options.toolTimeoutMs ?? 15_000, + toolMemoCache: new Map(), + toolBatchQueues: new Map(), + maxDepth: options.maxDepth ?? MAX_EXECUTION_DEPTH, + partialSuccess: options.partialSuccess ?? false, + traceBits: chainBitsMap, + emptyArrayBits, + traceMask, + }; + + // Create root scope and execute + const output: Record = {}; + const rootScope = new ExecutionScope(null, input, output, engine); + + // Index: register tool bindings, tool input wires, and output wires + indexStatements(bridge.body, rootScope); + + // Schedule force statements — run eagerly alongside output resolution + const forcePromises = executeForced(rootScope); + + // Pull: resolve requested output fields — tool calls happen lazily on demand + try { + await Promise.all([ + resolveRequestedFields(rootScope, options.requestedFields ?? []), + ...forcePromises, + ]); + } catch (err) { + if (isFatalError(err)) { + // Attach collected traces to fatal errors (abort, panic) + if (tracer) { + (err as { traces?: ToolTrace[] }).traces = tracer.traces; + } + (err as { executionTraceId?: bigint }).executionTraceId = traceMask[0]; + throw attachBridgeErrorDocumentContext(err, doc); + } + // Wrap non-fatal errors in BridgeRuntimeError with traces + const wrapped = wrapBridgeRuntimeError(err); + if (tracer) { + wrapped.traces = tracer.traces; + } + wrapped.executionTraceId = traceMask[0]; + throw attachBridgeErrorDocumentContext(wrapped, doc); + } + + // Extract root value if a wire wrote to the output root with a non-object value + const data = + "__rootValue__" in output ? (output.__rootValue__ as T) : (output as T); return { - data: data as T, - traces: tree.getTraces(), - executionTraceId: tree.getExecutionTrace(), + data, + traces: tracer?.traces ?? [], + executionTraceId: traceMask[0], }; } diff --git a/packages/bridge-core/src/index.ts b/packages/bridge-core/src/index.ts index d6402f7d..607e2140 100644 --- a/packages/bridge-core/src/index.ts +++ b/packages/bridge-core/src/index.ts @@ -34,9 +34,8 @@ export { export { mergeBridgeDocuments } from "./merge-documents.ts"; -// ── Execution tree (advanced) ─────────────────────────────────────────────── +// ── Tracing & error formatting ────────────────────────────────────────────── -export { ExecutionTree } from "./ExecutionTree.ts"; export { TraceCollector, boundedClone } from "./tracing.ts"; export type { ToolTrace, TraceLevel } from "./tracing.ts"; export { @@ -53,7 +52,9 @@ export { BridgeRuntimeError, BridgeTimeoutError, MAX_EXECUTION_DEPTH, + isFatalError, isLoopControlSignal, + wrapBridgeRuntimeError, } from "./tree-types.ts"; export type { Logger } from "./tree-types.ts"; @@ -61,6 +62,7 @@ export type { Logger } from "./tree-types.ts"; export { SELF_MODULE } from "./types.ts"; export type { + BinaryOp, Bridge, BridgeDocument, BatchToolCallFn, @@ -70,39 +72,42 @@ export type { ControlFlowInstruction, DefineDef, Expression, + ForceStatement, HandleBinding, Instruction, + JsonValue, NodeRef, + ScopeStatement, SourceLocation, ScalarToolCallFn, ScalarToolFn, + SourceChain, + SpreadStatement, + Statement, ToolCallFn, ToolContext, ToolDef, ToolMap, ToolMetadata, VersionDecl, - Wire, + WireAliasStatement, WireCatch, WireSourceEntry, + WireStatement, + WithStatement, } from "./types.ts"; -// ── Wire resolution ───────────────────────────────────────────────────────── +// ── Expression evaluation ────────────────────────────────────────────────── -export { - evaluateExpression, - resolveSourceEntries, - applyFallbackGates as applyFallbackGatesV2, - applyCatch as applyCatchV2, -} from "./resolveWiresSources.ts"; +export { evaluateExpression } from "./resolveWiresSources.ts"; // ── Traversal enumeration ─────────────────────────────────────────────────── export { - enumerateTraversalIds, buildTraversalManifest, + buildTraversalManifest as enumerateTraversalIds, + buildBodyTraversalMaps, decodeExecutionTrace, - buildTraceBitsMap, buildEmptyArrayBitsMap, } from "./enumerate-traversals.ts"; export type { TraversalEntry, TraceWireBits } from "./enumerate-traversals.ts"; diff --git a/packages/bridge-core/src/materializeShadows.ts b/packages/bridge-core/src/materializeShadows.ts deleted file mode 100644 index fc0f6a86..00000000 --- a/packages/bridge-core/src/materializeShadows.ts +++ /dev/null @@ -1,293 +0,0 @@ -/** - * Shadow-tree materializer — converts shadow trees into plain JS objects. - * - * Extracted from ExecutionTree.ts — Phase 4 of the refactor. - * See docs/execution-tree-refactor.md - * - * The functions operate on a narrow `MaterializerHost` interface (for bridge - * metadata) and concrete `ExecutionTree` instances (for shadow resolution). - */ - -import type { Wire } from "./types.ts"; -import { SELF_MODULE } from "./types.ts"; -import { setNested } from "./tree-utils.ts"; -import { - BREAK_SYM, - CONTINUE_SYM, - decrementLoopControl, - isLoopControlSignal, - isPromise, - type LoopControlSignal, -} from "./tree-types.ts"; -import type { MaybePromise, Trunk } from "./tree-types.ts"; -import { matchesRequestedFields } from "./requested-fields.ts"; - -// ── Context interface ─────────────────────────────────────────────────────── - -/** - * Narrow read-only view into the bridge metadata needed by the materializer. - * - * `ExecutionTree` satisfies this via its existing public fields. - */ -export interface MaterializerHost { - readonly bridge: { readonly wires: readonly Wire[] } | undefined; - readonly trunk: Trunk; - /** Sparse fieldset filter — passed through from ExecutionTree. */ - readonly requestedFields?: string[] | undefined; -} - -// ── Shadow tree duck type ─────────────────────────────────────────────────── - -/** - * Minimal interface for shadow trees consumed by the materializer. - * - * `ExecutionTree` satisfies this via its existing public methods. - */ -export interface MaterializableShadow { - pullOutputField(path: string[], array?: boolean): Promise; - resolvePreGrouped(wires: Wire[]): MaybePromise; -} - -// ── Plan shadow output ────────────────────────────────────────────────────── - -/** - * Scan bridge wires to classify output fields at a given path prefix. - * - * Returns a "plan" describing: - * - `directFields` — leaf fields with wires at exactly `[...prefix, name]` - * - `deepPaths` — fields with wires deeper than prefix+1 (nested arrays/objects) - * - `wireGroupsByPath` — wires pre-grouped by their full path key (#8) - * - * The plan is pure data (no side-effects) and is consumed by - * `materializeShadows` to drive the execution phase. - */ -export function planShadowOutput(host: MaterializerHost, pathPrefix: string[]) { - const wires = host.bridge!.wires; - const { type, field } = host.trunk; - - const directFields = new Set(); - const deepPaths = new Map(); - // #8: Pre-group wires by exact path — eliminates per-element re-filtering. - // Key: wire.to.path joined by \0 (null char is safe — field names are identifiers). - const wireGroupsByPath = new Map(); - - for (const wire of wires) { - const p = wire.to.path; - if ( - wire.to.module !== SELF_MODULE || - wire.to.type !== type || - wire.to.field !== field - ) - continue; - if (p.length <= pathPrefix.length) continue; - if (!pathPrefix.every((seg, i) => p[i] === seg)) continue; - - const name = p[pathPrefix.length]!; - if (p.length === pathPrefix.length + 1) { - directFields.add(name); - const pathKey = p.join("\0"); - let group = wireGroupsByPath.get(pathKey); - if (!group) { - group = []; - wireGroupsByPath.set(pathKey, group); - } - group.push(wire); - } else { - let arr = deepPaths.get(name); - if (!arr) { - arr = []; - deepPaths.set(name, arr); - } - arr.push(p); - } - } - - return { directFields, deepPaths, wireGroupsByPath }; -} - -// ── Materialize shadows ───────────────────────────────────────────────────── - -/** - * Recursively convert shadow trees into plain JS objects. - * - * Wire categories at each level (prefix = P): - * Leaf — `to.path = [...P, name]`, no deeper paths → scalar - * Array — direct wire AND deeper paths → pull as array, recurse - * Nested object — only deeper paths, no direct wire → pull each - * full path and assemble via setNested - */ -export async function materializeShadows( - host: MaterializerHost, - items: MaterializableShadow[], - pathPrefix: string[], -): Promise { - const { directFields, deepPaths, wireGroupsByPath } = planShadowOutput( - host, - pathPrefix, - ); - - // Apply sparse fieldset filter: remove fields not matched by requestedFields. - const { requestedFields } = host; - if (requestedFields && requestedFields.length > 0) { - const prefixStr = pathPrefix.join("."); - for (const name of [...directFields]) { - const fullPath = prefixStr ? `${prefixStr}.${name}` : name; - if (!matchesRequestedFields(fullPath, requestedFields)) { - directFields.delete(name); - const pathKey = [...pathPrefix, name].join("\0"); - wireGroupsByPath.delete(pathKey); - } - } - for (const [name] of [...deepPaths]) { - const fullPath = prefixStr ? `${prefixStr}.${name}` : name; - if (!matchesRequestedFields(fullPath, requestedFields)) { - deepPaths.delete(name); - } - } - } - - // #9/#10: Fast path — no nested arrays, only direct fields. - // Collect all (shadow × field) resolutions. When every value is already in - // state (the hot case for element passthrough), resolvePreGrouped returns - // synchronously and we skip Promise.all entirely. - // See packages/bridge-core/performance.md (#9, #10). - if (deepPaths.size === 0) { - const directFieldArray = [...directFields]; - const nFields = directFieldArray.length; - const nItems = items.length; - // Pre-compute pathKeys and wire groups — only depend on j, not i. - // See packages/bridge-core/performance.md (#11). - const preGroups: Wire[][] = new Array(nFields); - for (let j = 0; j < nFields; j++) { - const pathKey = [...pathPrefix, directFieldArray[j]!].join("\0"); - preGroups[j] = wireGroupsByPath.get(pathKey)!; - } - const rawValues: MaybePromise[] = new Array(nItems * nFields); - let hasAsync = false; - for (let i = 0; i < nItems; i++) { - const shadow = items[i]!; - for (let j = 0; j < nFields; j++) { - const v = shadow.resolvePreGrouped(preGroups[j]!); - rawValues[i * nFields + j] = v; - if (!hasAsync && isPromise(v)) hasAsync = true; - } - } - const flatValues: unknown[] = hasAsync - ? await Promise.all(rawValues) - : (rawValues as unknown[]); - - const finalResults: unknown[] = []; - let propagate: LoopControlSignal | undefined; - for (let i = 0; i < items.length; i++) { - const obj: Record = {}; - let doBreak = false; - let doSkip = false; - for (let j = 0; j < nFields; j++) { - const v = flatValues[i * nFields + j]; - if (isLoopControlSignal(v)) { - if (v === BREAK_SYM) { - doBreak = true; - break; - } - if (v === CONTINUE_SYM) { - doSkip = true; - break; - } - doBreak = v.__bridgeControl === "break"; - doSkip = v.__bridgeControl === "continue"; - propagate = decrementLoopControl(v); - break; - } - obj[directFieldArray[j]!] = v; - } - if (doBreak) break; - if (doSkip) continue; - finalResults.push(obj); - } - if (propagate) return propagate; - return finalResults; - } - - // Slow path: deep paths (nested arrays) present. - // Uses pre-grouped wires for direct fields (#8), original logic for the rest. - const rawResults = await Promise.all( - items.map(async (shadow) => { - const obj: Record = {}; - const tasks: Promise[] = []; - - for (const name of directFields) { - const fullPath = [...pathPrefix, name]; - const hasDeeper = deepPaths.has(name); - tasks.push( - (async () => { - if (hasDeeper) { - const children = await shadow.pullOutputField(fullPath, true); - obj[name] = Array.isArray(children) - ? await materializeShadows( - host, - children as MaterializableShadow[], - fullPath, - ) - : children; - } else { - // #8: wireGroupsByPath is built in the same branch that populates - // directFields, so the group is always present — no fallback needed. - const pathKey = fullPath.join("\0"); - obj[name] = await shadow.resolvePreGrouped( - wireGroupsByPath.get(pathKey)!, - ); - } - })(), - ); - } - - for (const [name, paths] of deepPaths) { - if (directFields.has(name)) continue; - // Filter individual deep paths against requestedFields - const activePaths = - requestedFields && requestedFields.length > 0 - ? paths.filter((fp) => - matchesRequestedFields(fp.join("."), requestedFields), - ) - : paths; - if (activePaths.length === 0) continue; - tasks.push( - (async () => { - const nested: Record = {}; - await Promise.all( - activePaths.map(async (fullPath) => { - const value = await shadow.pullOutputField(fullPath); - setNested(nested, fullPath.slice(pathPrefix.length + 1), value); - }), - ); - obj[name] = nested; - })(), - ); - } - - await Promise.all(tasks); - // Check if any field resolved to a sentinel — propagate it - for (const v of Object.values(obj)) { - if (isLoopControlSignal(v)) return v; - } - return obj; - }), - ); - - // Filter sentinels from the final result - const finalResults: unknown[] = []; - for (const item of rawResults) { - if (isLoopControlSignal(item)) { - if (item === BREAK_SYM) break; - if (item === CONTINUE_SYM) continue; - if (item.__bridgeControl === "break") { - return decrementLoopControl(item); - } - if (item.__bridgeControl === "continue") { - return decrementLoopControl(item); - } - } - finalResults.push(item); - } - return finalResults; -} diff --git a/packages/bridge-core/src/resolveWires.ts b/packages/bridge-core/src/resolveWires.ts deleted file mode 100644 index dfab5d2e..00000000 --- a/packages/bridge-core/src/resolveWires.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Wire resolution — the core data-flow evaluation loop. - * - * Extracted from ExecutionTree.ts — Phase 2 of the refactor. - * See docs/execution-tree-refactor.md - * - * All functions take a `TreeContext` as their first argument so they - * can call back into the tree for `pullSingle` without depending on - * the full `ExecutionTree` class. - */ - -import type { Wire } from "./types.ts"; -import type { MaybePromise, TreeContext } from "./tree-types.ts"; -import { isFatalError, BridgeAbortError } from "./tree-types.ts"; -import { coerceConstant, getSimplePullRef } from "./tree-utils.ts"; -import type { TraceWireBits } from "./enumerate-traversals.ts"; -import { resolveSourceEntries } from "./resolveWiresSources.ts"; - -// ── Public entry point ────────────────────────────────────────────────────── - -/** - * Resolve a set of matched wires. - * - * Architecture: two distinct resolution axes — - * - * **Fallback Gates** (`||` / `??`, within a wire): ordered source entries - * → falsy gates trigger on falsy values (0, "", false, null, undefined) - * → nullish gates trigger only on null/undefined - * → gates are processed left-to-right, allowing mixed `||` and `??` chains - * - * **Overdefinition** (across wires): multiple wires target the same path - * → nullish check — only null/undefined falls through to the next wire. - * - * Resolution is handled by `resolveSourceEntries()` from resolveWiresSources.ts, - * which evaluates source entries in order with their gates and catch handler. - * - * --- - * - * Fast path: single `from` wire with no fallback/catch modifiers, which is - * the common case for element field wires like `.id <- it.id`. Delegates to - * `resolveWiresAsync` for anything more complex. - * See packages/bridge-core/performance.md (#10). - */ -export function resolveWires( - ctx: TreeContext, - wires: Wire[], - pullChain?: Set, -): MaybePromise { - // Abort discipline — honour pre-aborted signal even on the fast path - if (ctx.signal?.aborted) throw new BridgeAbortError(); - - if (wires.length === 1) { - const w = wires[0]!; - // Constant wire — single literal source, no catch - if ( - w.sources.length === 1 && - w.sources[0]!.expr.type === "literal" && - !w.catch - ) { - recordPrimary(ctx, w); - return coerceConstant(w.sources[0]!.expr.value); - } - const ref = getSimplePullRef(w); - if ( - ref && - (ctx.traceBits?.get(w) as TraceWireBits | undefined)?.primaryError == null - ) { - recordPrimary(ctx, w); - const expr = w.sources[0]!.expr; - const refLoc = expr.type === "ref" ? (expr.refLoc ?? w.loc) : w.loc; - return ctx.pullSingle(ref, pullChain, refLoc); - } - } - const orderedWires = orderOverdefinedWires(ctx, wires); - return resolveWiresAsync(ctx, orderedWires, pullChain); -} - -function orderOverdefinedWires(ctx: TreeContext, wires: Wire[]): Wire[] { - if (wires.length < 2 || !ctx.classifyOverdefinitionWire) return wires; - - const ranked = wires.map((wire, index) => ({ - wire, - index, - cost: ctx.classifyOverdefinitionWire!(wire), - })); - - let changed = false; - ranked.sort((left, right) => { - if (left.cost !== right.cost) { - changed = true; - return left.cost - right.cost; - } - return left.index - right.index; - }); - - return changed ? ranked.map((entry) => entry.wire) : wires; -} - -// ── Async resolution loop ─────────────────────────────────────────────────── - -async function resolveWiresAsync( - ctx: TreeContext, - wires: Wire[], - pullChain?: Set, -): Promise { - let lastError: unknown; - - for (const w of wires) { - // Abort discipline — yield immediately if client disconnected - if (ctx.signal?.aborted) throw new BridgeAbortError(); - - // Constant wire — single literal source, no catch - if ( - w.sources.length === 1 && - w.sources[0]!.expr.type === "literal" && - !w.catch - ) { - recordPrimary(ctx, w); - return coerceConstant(w.sources[0]!.expr.value); - } - - // Delegate to the unified source-loop resolver - const bits = ctx.traceBits?.get(w) as TraceWireBits | undefined; - - try { - const value = await resolveSourceEntries(ctx, w, pullChain, bits); - - // Overdefinition Boundary - if (value != null) return value; - } catch (err: unknown) { - if (isFatalError(err)) throw err; - lastError = err; - } - } - - if (lastError) throw lastError; - return undefined; -} - -// ── Trace recording helpers ───────────────────────────────────────────────── -// These are designed for minimal overhead: when `traceBits` is not set on the -// context (tracing disabled), the functions return immediately after a single -// falsy check. When enabled, one Map.get + one bitwise OR is the hot path. -// -// INVARIANT: `traceMask` is always set when `traceBits` is set — both are -// initialised together by `ExecutionTree.enableExecutionTrace()`. - -function recordPrimary(ctx: TreeContext, w: Wire): void { - const bits = ctx.traceBits?.get(w) as TraceWireBits | undefined; - if (bits?.primary != null) ctx.traceMask![0] |= 1n << BigInt(bits.primary); -} diff --git a/packages/bridge-core/src/resolveWiresSources.ts b/packages/bridge-core/src/resolveWiresSources.ts index 52d41174..6adb33fe 100644 --- a/packages/bridge-core/src/resolveWiresSources.ts +++ b/packages/bridge-core/src/resolveWiresSources.ts @@ -1,18 +1,11 @@ /** - * Wire resolution — unified source-loop evaluation. + * Expression evaluation for bridge statements. * - * Evaluates `Wire.sources[]` in order with their fallback gates and - * optional catch handler. Called from `resolveWires.ts` for the - * async resolution path and overdefinition handling. + * Evaluates `Expression` trees recursively. The public entry point is + * `evaluateExpression`, called from `execute-bridge.ts`. */ -import type { - ControlFlowInstruction, - Expression, - NodeRef, - WireCatch, - Wire, -} from "./types.ts"; +import type { ControlFlowInstruction, Expression, NodeRef } from "./types.ts"; import type { LoopControlSignal, MaybePromise, @@ -23,12 +16,10 @@ import { isFatalError, isPromise, applyControlFlow, - BridgeAbortError, BridgePanicError, wrapBridgeRuntimeError, } from "./tree-types.ts"; import { coerceConstant } from "./tree-utils.ts"; -import type { TraceWireBits } from "./enumerate-traversals.ts"; import type { SourceLocation } from "@stackables/bridge-types"; // ── Public entry points ───────────────────────────────────────────────────── @@ -65,179 +56,33 @@ export function evaluateExpression( case "or": return evaluateOr(ctx, expr, pullChain); - } -} -/** - * Resolve a single Wire — evaluate its ordered source entries with - * gate semantics, then apply the catch handler on error. - * - * Returns the resolved value, or throws if all sources fail and no catch - * handler recovers. - * - * @param bits — Optional pre-resolved trace bits for this wire. - */ -export async function resolveSourceEntries( - ctx: TreeContext, - w: Wire, - pullChain?: Set, - bits?: TraceWireBits, -): Promise { - if (ctx.signal?.aborted) throw new BridgeAbortError(); + case "array": + // Array expressions are handled at a higher level (ExecutionTree). + // If we reach here, it means the engine hasn't been updated yet. + throw new Error( + "Array expressions are not yet supported in evaluateExpression", + ); - try { - let value: unknown; - for (let i = 0; i < w.sources.length; i++) { - const entry = w.sources[i]!; + case "pipe": + // Pipe expressions are handled at a higher level (ExecutionTree). + // If we reach here, it means the engine hasn't been updated yet. + throw new Error( + "Pipe expressions are not yet supported in evaluateExpression", + ); - // Gate check: skip this entry if its gate is not open - if (i > 0 && entry.gate) { - const gateOpen = entry.gate === "falsy" ? !value : value == null; - if (!gateOpen) continue; - } + case "binary": + return evaluateBinary(ctx, expr, pullChain); - // Evaluate the expression — ternary at primary position needs - // branch-specific trace recording (then → primary, else → else) - if (i === 0 && entry.expr.type === "ternary" && bits?.else != null) { - try { - value = await evaluateTernaryWithTrace( - ctx, - entry.expr, - pullChain, - bits, - ); - } catch (err: unknown) { - if (isFatalError(err)) throw err; - // Error bit was already recorded by evaluateTernaryWithTrace - throw err; - } - } else { - // Record which source was evaluated - recordSourceBit(ctx, bits, i); - - // Evaluate the expression - try { - value = await evaluateExpression(ctx, entry.expr, pullChain); - } catch (err: unknown) { - if (isFatalError(err)) throw err; - recordSourceErrorBit(ctx, bits, i); - throw err; - } - } - } + case "unary": + return evaluateUnary(ctx, expr, pullChain); - return value; - } catch (err: unknown) { - if (isFatalError(err)) throw err; - - // Try catch handler - if (w.catch) { - const recovered = await applyCatchHandler(ctx, w.catch, pullChain, bits); - if (recovered !== undefined) return recovered; - } - - throw wrapBridgeRuntimeError(err, { bridgeLoc: w.loc }); + case "concat": + return evaluateConcat(ctx, expr, pullChain); } } - -/** - * Apply fallback gates to a pre-evaluated value. - * - * Iterates over `w.sources[1..]`, applying gate checks (falsy `||` or - * nullish `??`). Falls through to the next source entry when the gate opens. - */ -export async function applyFallbackGates( - ctx: TreeContext, - w: Wire, - value: unknown, - pullChain?: Set, - bits?: TraceWireBits, -): Promise { - if (w.sources.length <= 1) return value; - - for (let i = 1; i < w.sources.length; i++) { - const entry = w.sources[i]!; - - // Gate check - const gateOpen = entry.gate === "falsy" ? !value : value == null; - if (!gateOpen) continue; - - // Record fallback — uses the "fallback" index (i - 1) for backward - // compatibility with TraceWireBits.fallbacks[] - const fallbackIndex = i - 1; - recordFallbackBit(ctx, bits, fallbackIndex); - - // Evaluate the expression - if (entry.expr.type === "control") { - return applyControlFlowWithLoc(entry.expr.control, entry.loc ?? w.loc); - } - - if (entry.expr.type === "ref") { - try { - value = await ctx.pullSingle( - entry.expr.ref, - pullChain, - entry.loc ?? w.loc, - ); - } catch (err: any) { - recordFallbackErrorBit(ctx, bits, fallbackIndex); - throw err; - } - } else if (entry.expr.type === "literal") { - value = coerceConstant(entry.expr.value); - } else { - // Complex expression in fallback position - try { - value = await evaluateExpression(ctx, entry.expr, pullChain); - } catch (err: any) { - recordFallbackErrorBit(ctx, bits, fallbackIndex); - throw err; - } - } - } - - return value; -} - -/** - * Apply the wire's catch handler. - * - * Returns the recovered value, or `undefined` if no catch handler is - * configured (indicating the error should propagate). - */ -export async function applyCatch( - ctx: TreeContext, - w: Wire, - pullChain?: Set, - bits?: TraceWireBits, -): Promise { - if (!w.catch) return undefined; - return applyCatchHandler(ctx, w.catch, pullChain, bits); -} - // ── Internal helpers ──────────────────────────────────────────────────────── -async function applyCatchHandler( - ctx: TreeContext, - c: WireCatch, - pullChain?: Set, - bits?: TraceWireBits, -): Promise { - recordCatchBit(ctx, bits); - if ("control" in c) { - return applyControlFlowWithLoc(c.control, c.loc); - } - if ("ref" in c) { - try { - return await ctx.pullSingle(c.ref, pullChain, c.loc); - } catch (err: any) { - recordCatchErrorBit(ctx, bits); - throw err; - } - } - return coerceConstant(c.value); -} - async function evaluateTernary( ctx: TreeContext, expr: Extract, @@ -249,41 +94,6 @@ async function evaluateTernary( } return evaluateExpression(ctx, expr.else, pullChain); } - -/** - * Evaluate a ternary expression with branch-specific trace recording. - * - * Used by `resolveSourceEntries` when the primary source is a ternary and - * the trace bits distinguish then/else branches. - */ -async function evaluateTernaryWithTrace( - ctx: TreeContext, - expr: Extract, - pullChain: Set | undefined, - bits: TraceWireBits, -): Promise { - const condValue = await evaluateExpression(ctx, expr.cond, pullChain); - if (condValue) { - recordSourceBit(ctx, bits, 0); // "then" → primary bit - try { - return await evaluateExpression(ctx, expr.then, pullChain); - } catch (err: unknown) { - if (isFatalError(err)) throw err; - recordSourceErrorBit(ctx, bits, 0); - throw err; - } - } else { - recordElseBit(ctx, bits); - try { - return await evaluateExpression(ctx, expr.else, pullChain); - } catch (err: unknown) { - if (isFatalError(err)) throw err; - recordElseErrorBit(ctx, bits); - throw err; - } - } -} - async function evaluateAnd( ctx: TreeContext, expr: Extract, @@ -332,6 +142,68 @@ async function evaluateOr( return Boolean(rightVal); } +async function evaluateBinary( + ctx: TreeContext, + expr: Extract, + pullChain?: Set, +): Promise { + const left = await evaluateExpression(ctx, expr.left, pullChain); + const right = await evaluateExpression(ctx, expr.right, pullChain); + switch (expr.op) { + case "add": + case "sub": + case "mul": + case "div": + // Propagate null/undefined so that downstream `??` fallbacks can fire. + // Without this, `undefined * N` produces NaN which is not null/undefined + // and therefore does not trigger nullish coalescing. + if (left == null || right == null) return null; + switch (expr.op) { + case "add": + return Number(left) + Number(right); + case "sub": + return Number(left) - Number(right); + case "mul": + return Number(left) * Number(right); + case "div": + return Number(left) / Number(right); + } + break; + case "eq": + return left === right; + case "neq": + return left !== right; + case "gt": + return Number(left) > Number(right); + case "gte": + return Number(left) >= Number(right); + case "lt": + return Number(left) < Number(right); + case "lte": + return Number(left) <= Number(right); + } +} + +async function evaluateUnary( + ctx: TreeContext, + expr: Extract, + pullChain?: Set, +): Promise { + const val = await evaluateExpression(ctx, expr.operand, pullChain); + return !val; +} + +async function evaluateConcat( + ctx: TreeContext, + expr: Extract, + pullChain?: Set, +): Promise { + const parts = await Promise.all( + expr.parts.map((p) => evaluateExpression(ctx, p, pullChain)), + ); + return parts.map((v) => (v == null ? "" : String(v))).join(""); +} + /** * Evaluate an expression with optional safe navigation — catches non-fatal * errors and returns `undefined`. @@ -395,97 +267,3 @@ function pullSafe( return undefined; }); } - -// ── Trace recording helpers ───────────────────────────────────────────────── -// -// Operate on TraceWireBits passed directly by the caller. Minimal overhead: -// when `bits` is undefined (tracing disabled), the functions return after -// a single falsy check. - -function recordSourceBit( - ctx: TreeContext, - bits: TraceWireBits | undefined, - index: number, -): void { - if (!bits || !ctx.traceMask) return; - if (index === 0) { - if (bits.primary != null) ctx.traceMask[0] |= 1n << BigInt(bits.primary); - } else { - const fb = bits.fallbacks; - const fbIndex = index - 1; - if (fb && fb[fbIndex] != null) - ctx.traceMask[0] |= 1n << BigInt(fb[fbIndex]); - } -} - -function recordSourceErrorBit( - ctx: TreeContext, - bits: TraceWireBits | undefined, - index: number, -): void { - if (!bits || !ctx.traceMask) return; - if (index === 0) { - if (bits.primaryError != null) - ctx.traceMask[0] |= 1n << BigInt(bits.primaryError); - } else { - const fb = bits.fallbackErrors; - const fbIndex = index - 1; - if (fb && fb[fbIndex] != null) - ctx.traceMask[0] |= 1n << BigInt(fb[fbIndex]); - } -} - -function recordFallbackBit( - ctx: TreeContext, - bits: TraceWireBits | undefined, - fallbackIndex: number, -): void { - if (!bits || !ctx.traceMask) return; - const fb = bits.fallbacks; - if (fb && fb[fallbackIndex] != null) - ctx.traceMask[0] |= 1n << BigInt(fb[fallbackIndex]); -} - -function recordFallbackErrorBit( - ctx: TreeContext, - bits: TraceWireBits | undefined, - fallbackIndex: number, -): void { - if (!bits || !ctx.traceMask) return; - const fb = bits.fallbackErrors; - if (fb && fb[fallbackIndex] != null) - ctx.traceMask[0] |= 1n << BigInt(fb[fallbackIndex]); -} - -function recordCatchBit( - ctx: TreeContext, - bits: TraceWireBits | undefined, -): void { - if (!bits || !ctx.traceMask) return; - if (bits.catch != null) ctx.traceMask[0] |= 1n << BigInt(bits.catch); -} - -function recordCatchErrorBit( - ctx: TreeContext, - bits: TraceWireBits | undefined, -): void { - if (!bits || !ctx.traceMask) return; - if (bits.catchError != null) - ctx.traceMask[0] |= 1n << BigInt(bits.catchError); -} - -function recordElseBit( - ctx: TreeContext, - bits: TraceWireBits | undefined, -): void { - if (!bits || !ctx.traceMask) return; - if (bits.else != null) ctx.traceMask[0] |= 1n << BigInt(bits.else); -} - -function recordElseErrorBit( - ctx: TreeContext, - bits: TraceWireBits | undefined, -): void { - if (!bits || !ctx.traceMask) return; - if (bits.elseError != null) ctx.traceMask[0] |= 1n << BigInt(bits.elseError); -} diff --git a/packages/bridge-core/src/scheduleTools.ts b/packages/bridge-core/src/scheduleTools.ts deleted file mode 100644 index 6953e712..00000000 --- a/packages/bridge-core/src/scheduleTools.ts +++ /dev/null @@ -1,395 +0,0 @@ -/** - * Tool scheduling — wire grouping, input assembly, and tool dispatch. - * - * Extracted from ExecutionTree.ts — Phase 5 of the refactor. - * See docs/execution-tree-refactor.md - * - * The functions operate on a narrow `SchedulerContext` interface, - * keeping the dependency surface explicit. - */ - -import type { Bridge, Expression, NodeRef, ToolDef, Wire } from "./types.ts"; -import { SELF_MODULE } from "./types.ts"; -import { isPromise, wrapBridgeRuntimeError } from "./tree-types.ts"; -import type { MaybePromise, Trunk } from "./tree-types.ts"; -import { trunkKey, sameTrunk, setNested } from "./tree-utils.ts"; -import { - lookupToolFn, - resolveToolDefByName, - resolveToolWires, - resolveToolSource, - mergeToolDefConstants, - type ToolLookupContext, -} from "./toolLookup.ts"; - -// ── Context interface ─────────────────────────────────────────────────────── - -/** - * Narrow context interface for the scheduling subsystem. - * - * `ExecutionTree` satisfies this via its existing public fields and methods. - * The interface is intentionally wide because scheduling is the central - * dispatch logic that ties wire resolution, tool lookup, and instrumentation - * together — but it is still a strict subset of the full class. - */ -export interface SchedulerContext extends ToolLookupContext { - // ── Scheduler-specific fields ────────────────────────────────────────── - readonly bridge: Bridge | undefined; - /** Parent tree for shadow-tree delegation. `schedule()` recurses via parent. */ - readonly parent?: SchedulerContext | undefined; - /** Pipe fork lookup map — maps fork trunk keys to their base trunk. */ - readonly pipeHandleMap: - | ReadonlyMap - | undefined; - /** Handle version tags (`@version`) for versioned tool lookups. */ - readonly handleVersionMap: ReadonlyMap; - /** Tool trunks marked with `memoize`. */ - readonly memoizedToolKeys: ReadonlySet; - - // ── Methods ──────────────────────────────────────────────────────────── - /** Recursive entry point — parent delegation calls this. */ - schedule(target: Trunk, pullChain?: Set): MaybePromise; - /** Resolve a set of matched wires (delegates to resolveWires.ts). */ - resolveWires(wires: Wire[], pullChain?: Set): MaybePromise; -} - -function getBridgeLocFromGroups(groupEntries: [string, Wire[]][]): Wire["loc"] { - for (const [, wires] of groupEntries) { - for (const wire of wires) { - if (wire.loc) return wire.loc; - } - } - return undefined; -} - -// ── Helpers ───────────────────────────────────────────────────────────────── - -/** Derive tool name from a trunk. */ -function getToolName(target: Trunk): string { - if (target.module === SELF_MODULE) return target.field; - return `${target.module}.${target.field}`; -} - -function refsInWire(wire: Wire): NodeRef[] { - const refs: NodeRef[] = []; - // Collect refs from all source expressions - for (const source of wire.sources) { - collectExprRefs(source.expr, refs); - } - // Collect ref from catch handler - if (wire.catch && "ref" in wire.catch) { - refs.push(wire.catch.ref); - } - return refs; -} - -function collectExprRefs(expr: Expression, refs: NodeRef[]): void { - switch (expr.type) { - case "ref": - refs.push(expr.ref); - break; - case "ternary": - collectExprRefs(expr.cond, refs); - collectExprRefs(expr.then, refs); - collectExprRefs(expr.else, refs); - break; - case "and": - case "or": - collectExprRefs(expr.left, refs); - collectExprRefs(expr.right, refs); - break; - // literal, control — no refs - } -} - -export function trunkDependsOnElement( - bridge: Bridge | undefined, - target: Trunk, - visited = new Set(), -): boolean { - if (!bridge) return false; - - // The current bridge trunk doubles as the input state container. Do not walk - // its incoming output wires when classifying element scope; refs like - // `i.category` would otherwise inherit element scope from unrelated output - // array mappings on the same bridge. - if ( - target.module === "_" && - target.type === bridge.type && - target.field === bridge.field - ) { - return false; - } - - const key = trunkKey(target); - if (visited.has(key)) return false; - visited.add(key); - - const incoming = bridge.wires.filter((wire) => sameTrunk(wire.to, target)); - for (const wire of incoming) { - if (wire.to.element) return true; - - for (const ref of refsInWire(wire)) { - if (ref.element) return true; - const sourceTrunk: Trunk = { - module: ref.module, - type: ref.type, - field: ref.field, - instance: ref.instance, - }; - if (trunkDependsOnElement(bridge, sourceTrunk, visited)) { - return true; - } - } - } - - return false; -} - -// ── Schedule ──────────────────────────────────────────────────────────────── - -/** - * Schedule resolution for a target trunk. - * - * This is the central dispatch method: - * 1. Shadow-tree parent delegation (element-scoped wires stay local) - * 2. Collect and group bridge wires (base + fork) - * 3. Route to `scheduleToolDef` (async, ToolDef-backed) or - * inline sync resolution + `scheduleFinish` (direct tools / passthrough) - */ -export function schedule( - ctx: SchedulerContext, - target: Trunk, - pullChain?: Set, -): MaybePromise { - // Delegate to parent (shadow trees don't schedule directly) unless - // the target fork has bridge wires sourced from element data, - // including transitive sources routed through __local / __define_* trunks. - if (ctx.parent) { - if (!trunkDependsOnElement(ctx.bridge, target)) { - return ctx.parent.schedule(target, pullChain); - } - } - - // ── Sync work: collect and group bridge wires ───────────────── - // If this target is a pipe fork, also apply bridge wires from its base - // handle (non-pipe wires, e.g. `c.currency <- i.currency`) as defaults - // before the fork-specific pipe wires. - const targetKey = trunkKey(target); - const pipeFork = ctx.pipeHandleMap?.get(targetKey); - const baseTrunk = pipeFork?.baseTrunk; - - const baseWires = baseTrunk - ? (ctx.bridge?.wires.filter( - (w) => !("pipe" in w) && sameTrunk(w.to, baseTrunk), - ) ?? []) - : []; - // Fork-specific wires (pipe wires targeting the fork's own instance) - const forkWires = - ctx.bridge?.wires.filter((w) => sameTrunk(w.to, target)) ?? []; - // Merge: base provides defaults, fork overrides - const bridgeWires = [...baseWires, ...forkWires]; - - // Look up ToolDef for this target - const toolName = getToolName(target); - const toolDef = resolveToolDefByName(ctx, toolName); - - // Group wires by target path so that || (null-fallback) and ?? - // (error-fallback) semantics are honoured via resolveWires(). - const wireGroups = new Map(); - for (const w of bridgeWires) { - const key = w.to.path.join("."); - let group = wireGroups.get(key); - if (!group) { - group = []; - wireGroups.set(key, group); - } - group.push(w); - } - - // ── Async path: tool definition requires resolveToolWires + callTool ── - if (toolDef) { - return scheduleToolDef( - ctx, - target, - toolName, - toolDef, - wireGroups, - pullChain, - ); - } - - // ── Sync-capable path: no tool definition ── - // For __local bindings, __define_ pass-throughs, pipe forks backed by - // sync tools, and logic nodes — resolve bridge wires and return - // synchronously when all sources are already in state. - // See packages/bridge-core/performance.md (#12). - const groupEntries = Array.from(wireGroups.entries()); - const nGroups = groupEntries.length; - const values: MaybePromise[] = new Array(nGroups); - let hasAsync = false; - for (let i = 0; i < nGroups; i++) { - const v = ctx.resolveWires(groupEntries[i]![1], pullChain); - values[i] = v; - if (!hasAsync && isPromise(v)) hasAsync = true; - } - - if (!hasAsync) { - return scheduleFinish( - ctx, - target, - toolName, - groupEntries, - values as any[], - baseTrunk, - ); - } - return Promise.all(values).then((resolved) => - scheduleFinish(ctx, target, toolName, groupEntries, resolved, baseTrunk), - ); -} - -// ── Schedule finish ───────────────────────────────────────────────────────── - -/** - * Assemble input from resolved wire values and either invoke a direct tool - * function or return the data for pass-through targets (local/define/logic). - * Returns synchronously when the tool function (if any) returns sync. - * See packages/bridge-core/performance.md (#12). - */ -export function scheduleFinish( - ctx: SchedulerContext, - target: Trunk, - toolName: string, - groupEntries: [string, Wire[]][], - resolvedValues: any[], - baseTrunk: Trunk | undefined, -): MaybePromise { - const input: Record = {}; - const resolved: [string[], any][] = []; - const bridgeLoc = getBridgeLocFromGroups(groupEntries); - for (let i = 0; i < groupEntries.length; i++) { - const path = groupEntries[i]![1][0]!.to.path; - const value = resolvedValues[i]; - resolved.push([path, value]); - if (path.length === 0 && value != null && typeof value === "object") { - Object.assign(input, value); - } else { - setNested(input, path, value); - } - } - - // Direct tool function lookup by name (simple or dotted). - // When the handle carries a @version tag, try the versioned key first - // (e.g. "std.str.toLowerCase@999.1") so user-injected overrides win. - // For pipe forks, fall back to the baseTrunk's version since forks - // use synthetic instance numbers (100000+). - const handleVersion = - ctx.handleVersionMap.get(trunkKey(target)) ?? - (baseTrunk ? ctx.handleVersionMap.get(trunkKey(baseTrunk)) : undefined); - let directFn = handleVersion - ? lookupToolFn(ctx, `${toolName}@${handleVersion}`) - : undefined; - if (!directFn) { - directFn = lookupToolFn(ctx, toolName); - } - if (directFn) { - const memoizeKey = ctx.memoizedToolKeys.has(trunkKey(target)) - ? trunkKey(target) - : undefined; - return ctx.callTool(toolName, toolName, directFn, input, memoizeKey); - } - - // Define pass-through: synthetic trunks created by define inlining - // act as data containers — bridge wires set their values, no tool needed. - if (target.module.startsWith("__define_")) { - return input; - } - - // Local binding or logic node: the wire resolves the source and stores - // the result — no tool call needed. For path=[] wires the resolved - // value may be a primitive (boolean from condAnd/condOr, string from - // a pipe tool like upperCase), so return the resolved value directly. - if ( - target.module === "__local" || - target.field === "__and" || - target.field === "__or" - ) { - for (const [path, value] of resolved) { - if (path.length === 0) return value; - } - return input; - } - - throw wrapBridgeRuntimeError(new Error(`No tool found for "${toolName}"`), { - bridgeLoc, - }); -} - -// ── Schedule ToolDef ──────────────────────────────────────────────────────── - -/** - * Full async schedule path for targets backed by a ToolDef. - * Resolves tool wires, bridge wires, and invokes the tool function - * with error recovery support. - */ -export async function scheduleToolDef( - ctx: SchedulerContext, - target: Trunk, - toolName: string, - toolDef: ToolDef, - wireGroups: Map, - pullChain: Set | undefined, -): Promise { - // Build input object: tool wires first (base), then bridge wires (override) - const input: Record = {}; - await resolveToolWires(ctx, toolDef, input); - - // Resolve bridge wires and apply on top - const groupEntries = Array.from(wireGroups.entries()); - const resolved = await Promise.all( - groupEntries.map(async ([, group]): Promise<[string[], any]> => { - const value = await ctx.resolveWires(group, pullChain); - return [group[0].to.path, value]; - }), - ); - for (const [path, value] of resolved) { - if (path.length === 0 && value != null && typeof value === "object") { - Object.assign(input, value); - } else { - setNested(input, path, value); - } - } - - const bridgeLoc = getBridgeLocFromGroups(groupEntries); - - // Call ToolDef-backed tool function - const fn = lookupToolFn(ctx, toolDef.fn!); - if (!fn) { - throw wrapBridgeRuntimeError( - new Error(`Tool function "${toolDef.fn}" not registered`), - { - bridgeLoc, - }, - ); - } - - // on error: wrap the tool call with fallback - try { - const memoizeKey = ctx.memoizedToolKeys.has(trunkKey(target)) - ? trunkKey(target) - : undefined; - const raw = await ctx.callTool( - toolName, - toolDef.fn!, - fn, - input, - memoizeKey, - ); - return mergeToolDefConstants(toolDef, raw); - } catch (err) { - if (!toolDef.onError) throw err; - if ("value" in toolDef.onError) return JSON.parse(toolDef.onError.value); - return resolveToolSource(ctx, toolDef.onError.source, toolDef); - } -} diff --git a/packages/bridge-core/src/toolLookup.ts b/packages/bridge-core/src/toolLookup.ts deleted file mode 100644 index 97908b8d..00000000 --- a/packages/bridge-core/src/toolLookup.ts +++ /dev/null @@ -1,629 +0,0 @@ -/** - * Tool function lookup, ToolDef resolution, and tool-dependency execution. - * - * Extracted from ExecutionTree.ts — Phase 3 of the refactor. - * See docs/execution-tree-refactor.md - * - * All functions take a `ToolLookupContext` instead of accessing `this`, - * keeping the dependency surface explicit and testable. - */ - -import type { - Instruction, - NodeRef, - ToolCallFn, - ToolDef, - ToolMap, - Wire, -} from "./types.ts"; -import { SELF_MODULE } from "./types.ts"; -import type { MaybePromise } from "./tree-types.ts"; -import { - trunkKey, - setNested, - coerceConstant, - UNSAFE_KEYS, -} from "./tree-utils.ts"; - -// ── Context interface ─────────────────────────────────────────────────────── - -/** - * Narrow context interface for tool lookup operations. - * - * `ExecutionTree` implements this alongside `TreeContext`. Extracted - * functions depend only on this contract, keeping them testable without - * the full engine. - */ -export interface ToolLookupContext { - readonly toolFns?: ToolMap | undefined; - readonly toolDefCache: Map; - readonly toolDepCache: Map>; - readonly instructions: readonly Instruction[]; - readonly context?: Record | undefined; - readonly parent?: ToolLookupContext | undefined; - readonly state: Record; - callTool( - toolName: string, - fnName: string, - fnImpl: (...args: any[]) => any, - input: Record, - memoizeKey?: string, - ): MaybePromise; -} - -// ── Tool function lookup ──────────────────────────────────────────────────── - -/** - * Deep-lookup a tool function by dotted name (e.g. "std.str.toUpperCase"). - * Falls back to a flat key lookup for backward compat (e.g. "hereapi.geocode" - * as literal key). - */ -export function lookupToolFn( - ctx: ToolLookupContext, - name: string, -): ToolCallFn | ((...args: any[]) => any) | undefined { - const toolFns = ctx.toolFns; - if (name.includes(".")) { - // Check flat key first — explicit overrides (e.g. "std.httpCall" as a - // literal property) take precedence over namespace traversal so that - // users can override built-in tools without replacing the whole namespace. - const flat = (toolFns as any)?.[name]; - if (typeof flat === "function") return flat; - - // Namespace traversal (e.g. toolFns.std.httpCall) - const parts = name.split("."); - let current: any = toolFns; - for (const part of parts) { - if (UNSAFE_KEYS.has(part)) return undefined; - if (current == null || typeof current !== "object") { - current = undefined; - break; - } - current = current[part]; - } - if (typeof current === "function") return current; - - // Try versioned namespace keys (e.g. "std.str@999.1" → { toLowerCase }) - // For "std.str.toLowerCase@999.1", check: - // toolFns["std.str@999.1"]?.toLowerCase - // toolFns["std@999.1"]?.str?.toLowerCase - const atIdx = name.lastIndexOf("@"); - if (atIdx > 0) { - const baseName = name.substring(0, atIdx); - const version = name.substring(atIdx + 1); - const nameParts = baseName.split("."); - for (let i = nameParts.length - 1; i >= 1; i--) { - const nsKey = nameParts.slice(0, i).join(".") + "@" + version; - const remainder = nameParts.slice(i); - let ns: any = (toolFns as any)?.[nsKey]; - if (ns != null && typeof ns === "object") { - for (const part of remainder) { - if (ns == null || typeof ns !== "object") { - ns = undefined; - break; - } - ns = ns[part]; - } - if (typeof ns === "function") return ns; - } - } - } - - return undefined; - } - // Try root level first - const fn = (toolFns as any)?.[name]; - if (typeof fn === "function") return fn; - // Fall back to std namespace (builtins are callable without std. prefix) - const stdFn = (toolFns as any)?.std?.[name]; - if (typeof stdFn === "function") return stdFn; - // Fall back to internal namespace (engine-internal tools: math ops, concat, etc.) - const internalFn = (toolFns as any)?.internal?.[name]; - return typeof internalFn === "function" ? internalFn : undefined; -} - -// ── ToolDef resolution ────────────────────────────────────────────────────── - -/** - * Resolve a ToolDef by name, merging the extends chain (cached). - */ -export function resolveToolDefByName( - ctx: ToolLookupContext, - name: string, -): ToolDef | undefined { - if (ctx.toolDefCache.has(name)) - return ctx.toolDefCache.get(name) ?? undefined; - - const toolDefs = ctx.instructions.filter( - (i): i is ToolDef => i.kind === "tool", - ); - const base = toolDefs.find((t) => t.name === name); - if (!base) { - ctx.toolDefCache.set(name, null); - return undefined; - } - - // Build extends chain: root → ... → leaf - const chain: ToolDef[] = [base]; - let current = base; - while (current.extends) { - const parent = toolDefs.find((t) => t.name === current.extends); - if (!parent) - throw new Error( - `Tool "${current.name}" extends unknown tool "${current.extends}"`, - ); - chain.unshift(parent); - current = parent; - } - - // Merge: root provides base, each child overrides - const merged: ToolDef = { - kind: "tool", - name, - fn: chain[0].fn, // fn from root ancestor - handles: [], - wires: [], - }; - - for (const def of chain) { - // Merge handles (dedupe by handle name) - for (const h of def.handles) { - if (!merged.handles.some((mh) => mh.handle === h.handle)) { - merged.handles.push(h); - } - } - // Merge wires (child overrides parent by target path) - for (const wire of def.wires) { - const wireTargetKey = "to" in wire ? wire.to.path.join(".") : undefined; - if (wireTargetKey != null) { - const idx = merged.wires.findIndex( - (w) => "to" in w && w.to.path.join(".") === wireTargetKey, - ); - if (idx >= 0) merged.wires[idx] = wire; - else merged.wires.push(wire); - } else { - merged.wires.push(wire); - } - } - // Last onError wins - if (def.onError) merged.onError = def.onError; - // Merge pipeHandles (dedupe by key, child overrides parent) - if (def.pipeHandles) { - if (!merged.pipeHandles) merged.pipeHandles = []; - for (const ph of def.pipeHandles) { - const idx = merged.pipeHandles.findIndex((m) => m.key === ph.key); - if (idx >= 0) merged.pipeHandles[idx] = ph; - else merged.pipeHandles.push(ph); - } - } - } - - ctx.toolDefCache.set(name, merged); - return merged; -} - -// ── Tool wire resolution ──────────────────────────────────────────────────── - -/** - * Resolve a tool definition's wires into a nested input object. - * Wires use the unified Wire type with sources[] and catch. - */ -export async function resolveToolWires( - ctx: ToolLookupContext, - toolDef: ToolDef, - input: Record, -): Promise { - const forkKeys = new Set(); - if (toolDef.pipeHandles) { - for (const ph of toolDef.pipeHandles) forkKeys.add(ph.key); - } - - const isForkTarget = (w: Wire): boolean => { - const key = trunkKey(w.to); - return forkKeys.has(key); - }; - - const mainConstantWires: Wire[] = []; - const mainPullWires: Wire[] = []; - const mainTernaryWires: Wire[] = []; - const mainComplexWires: Wire[] = []; - const forkWireMap = new Map(); - - for (const wire of toolDef.wires) { - const primary = wire.sources[0]?.expr; - if (!primary) continue; - - if (isForkTarget(wire)) { - const key = trunkKey(wire.to); - let group = forkWireMap.get(key); - if (!group) { - group = { constants: [], pulls: [] }; - forkWireMap.set(key, group); - } - if (primary.type === "literal" && wire.sources.length === 1) { - group.constants.push(wire); - } else if (primary.type === "ref") { - group.pulls.push(wire); - } - } else if (wire.sources.length > 1 || wire.catch) { - mainComplexWires.push(wire); - } else if (primary.type === "ternary") { - mainTernaryWires.push(wire); - } else if (primary.type === "literal") { - mainConstantWires.push(wire); - } else if (primary.type === "ref") { - mainPullWires.push(wire); - } - } - - // Execute pipe forks in instance order - const forkResults = new Map(); - if (forkWireMap.size > 0) { - const sortedForkKeys = [...forkWireMap.keys()].sort((a, b) => { - const instA = parseInt(a.split(":").pop() ?? "0", 10); - const instB = parseInt(b.split(":").pop() ?? "0", 10); - return instA - instB; - }); - - for (const forkKey of sortedForkKeys) { - const group = forkWireMap.get(forkKey)!; - const forkInput: Record = {}; - - for (const wire of group.constants) { - const expr = wire.sources[0]!.expr; - if (expr.type === "literal") { - setNested(forkInput, wire.to.path, coerceConstant(expr.value)); - } - } - - for (const wire of group.pulls) { - const expr = wire.sources[0]!.expr; - if (expr.type !== "ref") continue; - const value = await resolveToolExprRef( - ctx, - expr.ref, - toolDef, - forkResults, - ); - setNested(forkInput, wire.to.path, value); - } - - const forkToolName = forkKey.split(":")[2] ?? ""; - const fn = lookupToolFn(ctx, forkToolName); - if (fn) forkResults.set(forkKey, await fn(forkInput)); - } - } - - // Constants applied synchronously - for (const wire of mainConstantWires) { - const expr = wire.sources[0]!.expr; - if (expr.type === "literal") { - setNested(input, wire.to.path, coerceConstant(expr.value)); - } - } - - // Pull wires resolved in parallel - if (mainPullWires.length > 0) { - const resolved = await Promise.all( - mainPullWires.map(async (wire) => { - const expr = wire.sources[0]!.expr; - if (expr.type !== "ref") return null; - const value = await resolveToolExprRef( - ctx, - expr.ref, - toolDef, - forkResults, - ); - return { path: wire.to.path, value }; - }), - ); - for (const entry of resolved) { - if (entry) setNested(input, entry.path, entry.value); - } - } - - // Ternary wires - for (const wire of mainTernaryWires) { - const expr = wire.sources[0]!.expr; - if (expr.type !== "ternary") continue; - const condRef = expr.cond.type === "ref" ? expr.cond.ref : undefined; - if (!condRef) continue; - const condValue = await resolveToolExprRef( - ctx, - condRef, - toolDef, - forkResults, - ); - const branchExpr = condValue ? expr.then : expr.else; - let value: any; - if (branchExpr.type === "ref") { - value = await resolveToolExprRef( - ctx, - branchExpr.ref, - toolDef, - forkResults, - ); - } else if (branchExpr.type === "literal") { - value = coerceConstant(branchExpr.value); - } - if (value !== undefined) setNested(input, wire.to.path, value); - } - - // Complex wires (with fallbacks and/or catch) - for (const wire of mainComplexWires) { - if (isForkTarget(wire)) continue; - const primary = wire.sources[0]!.expr; - let value: any; - if (primary.type === "ref") { - try { - value = await resolveToolExprRef( - ctx, - primary.ref, - toolDef, - forkResults, - ); - } catch { - value = undefined; - } - } else if (primary.type === "literal") { - value = coerceConstant(primary.value); - } - - // Apply fallback gates - for (let j = 1; j < wire.sources.length; j++) { - const fb = wire.sources[j]!; - const shouldFallback = fb.gate === "nullish" ? value == null : !value; - if (shouldFallback) { - if (fb.expr.type === "literal") { - value = coerceConstant(fb.expr.value); - } else if (fb.expr.type === "ref") { - value = await resolveToolExprRef( - ctx, - fb.expr.ref, - toolDef, - forkResults, - ); - } - } - } - - // Apply catch - if (wire.catch && value == null) { - if ("value" in wire.catch) { - value = coerceConstant(wire.catch.value); - } else if ("ref" in wire.catch) { - value = await resolveToolNodeRef(ctx, wire.catch.ref, toolDef); - } - } - - setNested(input, wire.to.path, value); - } -} - -/** Resolve a NodeRef, checking fork results first. */ -async function resolveToolExprRef( - ctx: ToolLookupContext, - ref: NodeRef, - toolDef: ToolDef, - forkResults: Map, -): Promise { - const fromKey = trunkKey(ref); - if (forkResults.has(fromKey)) { - let value = forkResults.get(fromKey); - for (const seg of ref.path) value = value?.[seg]; - return value; - } - return resolveToolNodeRef(ctx, ref, toolDef); -} - -// ── Tool NodeRef resolution ───────────────────────────────────────────────── - -/** - * Resolve a NodeRef from a tool wire against the tool's handles. - */ -export async function resolveToolNodeRef( - ctx: ToolLookupContext, - ref: NodeRef, - toolDef: ToolDef, -): Promise { - // Find the matching handle by looking at how the ref was built - // The ref's module/type/field encode which handle it came from - const handle = toolDef.handles.find((h) => { - if (h.kind === "context") { - return ( - ref.module === SELF_MODULE && - ref.type === "Context" && - ref.field === "context" - ); - } - if (h.kind === "const") { - return ( - ref.module === SELF_MODULE && - ref.type === "Const" && - ref.field === "const" - ); - } - if (h.kind === "tool") { - // Tool handle: module is the namespace part, field is the tool name part - const lastDot = h.name.lastIndexOf("."); - if (lastDot !== -1) { - return ( - ref.module === h.name.substring(0, lastDot) && - ref.field === h.name.substring(lastDot + 1) - ); - } - return ( - ref.module === SELF_MODULE && - ref.type === "Tools" && - ref.field === h.name - ); - } - return false; - }); - - if (!handle) { - throw new Error( - `Cannot resolve source in tool "${toolDef.name}": no handle matches ref ${ref.module}:${ref.type}:${ref.field}`, - ); - } - - let value: any; - if (handle.kind === "context") { - // Walk the full parent chain for context - let cursor: ToolLookupContext | undefined = ctx; - while (cursor && value === undefined) { - value = cursor.context; - cursor = cursor.parent; - } - } else if (handle.kind === "const") { - // Walk the full parent chain for const state - const constKey = trunkKey({ - module: SELF_MODULE, - type: "Const", - field: "const", - }); - let cursor: ToolLookupContext | undefined = ctx; - while (cursor && value === undefined) { - value = cursor.state[constKey]; - cursor = cursor.parent; - } - } else if (handle.kind === "tool") { - value = await resolveToolDep(ctx, handle.name); - } - - for (const segment of ref.path) { - value = value[segment]; - } - return value; -} - -// ── Tool source resolution (string-based, for onError) ────────────────────── - -/** - * Resolve a dotted source string against the tool's handles. - * Used for onError source references which remain string-based. - */ -export async function resolveToolSource( - ctx: ToolLookupContext, - source: string, - toolDef: ToolDef, -): Promise { - const dotIdx = source.indexOf("."); - const handleName = dotIdx === -1 ? source : source.substring(0, dotIdx); - const restPath = dotIdx === -1 ? [] : source.substring(dotIdx + 1).split("."); - - const handle = toolDef.handles.find((h) => h.handle === handleName); - if (!handle) - throw new Error(`Unknown source "${handleName}" in tool "${toolDef.name}"`); - - let value: any; - if (handle.kind === "context") { - let cursor: ToolLookupContext | undefined = ctx; - while (cursor && value === undefined) { - value = cursor.context; - cursor = cursor.parent; - } - } else if (handle.kind === "const") { - const constKey = trunkKey({ - module: SELF_MODULE, - type: "Const", - field: "const", - }); - let cursor: ToolLookupContext | undefined = ctx; - while (cursor && value === undefined) { - value = cursor.state[constKey]; - cursor = cursor.parent; - } - } else if (handle.kind === "tool") { - value = await resolveToolDep(ctx, handle.name); - } - - for (const segment of restPath) { - if (value == null) return undefined; - value = value[segment]; - } - return value; -} - -// ── Constant wire merging ─────────────────────────────────────────────────── - -/** - * Merge constant self-wires from a ToolDef into the tool's return value, - * so that dependents can read constant fields (e.g. `.token = "x"`) as - * if the tool produced them. Tool-returned fields take precedence. - */ -export function mergeToolDefConstants(toolDef: ToolDef, result: any): any { - if (result == null || typeof result !== "object" || Array.isArray(result)) - return result; - - // Build fork keys to skip fork-targeted constants - const forkKeys = new Set(); - if (toolDef.pipeHandles) { - for (const ph of toolDef.pipeHandles) { - forkKeys.add(ph.key); - } - } - - for (const wire of toolDef.wires) { - // Only simple constant wires: single literal source, no catch - const primary = wire.sources[0]?.expr; - if ( - !primary || - primary.type !== "literal" || - wire.sources.length > 1 || - wire.catch - ) - continue; - if (forkKeys.size > 0 && forkKeys.has(trunkKey(wire.to))) continue; - - const path = wire.to.path; - if (path.length === 0) continue; - - // Only fill in fields the tool didn't already produce - if (!(path[0] in result)) { - setNested(result, path, coerceConstant(primary.value)); - } - } - - return result; -} - -// ── Tool dependency execution ─────────────────────────────────────────────── - -/** - * Call a tool dependency (cached per request). - * Delegates to the root of the parent chain so shadow trees share the cache. - */ -export function resolveToolDep( - ctx: ToolLookupContext, - toolName: string, -): Promise { - // Check parent first (shadow trees delegate) - if (ctx.parent) return resolveToolDep(ctx.parent, toolName); - - if (ctx.toolDepCache.has(toolName)) return ctx.toolDepCache.get(toolName)!; - - const promise = (async () => { - const toolDef = resolveToolDefByName(ctx, toolName); - if (!toolDef) throw new Error(`Tool dependency "${toolName}" not found`); - - const input: Record = {}; - await resolveToolWires(ctx, toolDef, input); - - const fn = lookupToolFn(ctx, toolDef.fn!); - if (!fn) throw new Error(`Tool function "${toolDef.fn}" not registered`); - - // on error: wrap the tool call with fallback - try { - const raw = await ctx.callTool(toolName, toolDef.fn!, fn, input); - return mergeToolDefConstants(toolDef, raw); - } catch (err) { - if (!toolDef.onError) throw err; - if ("value" in toolDef.onError) return JSON.parse(toolDef.onError.value); - return resolveToolSource(ctx, toolDef.onError.source, toolDef); - } - })(); - - ctx.toolDepCache.set(toolName, promise); - return promise; -} diff --git a/packages/bridge-core/src/tree-types.ts b/packages/bridge-core/src/tree-types.ts index c53f6f9e..877ed4f7 100644 --- a/packages/bridge-core/src/tree-types.ts +++ b/packages/bridge-core/src/tree-types.ts @@ -10,10 +10,8 @@ import type { ControlFlowInstruction, NodeRef, SourceLocation, - Wire, } from "./types.ts"; import type { ToolTrace } from "./tracing.ts"; -import type { TraceWireBits } from "./enumerate-traversals.ts"; // ── Error classes ─────────────────────────────────────────────────────────── @@ -136,23 +134,8 @@ export interface TreeContext { pullChain?: Set, bridgeLoc?: SourceLocation, ): MaybePromise; - /** Classify an overdefined wire by marginal execution cost (lower = cheaper). */ - classifyOverdefinitionWire?(wire: Wire): number; /** External abort signal — cancels execution when triggered. */ signal?: AbortSignal; - /** - * Per-wire bit positions for execution trace recording. - * Present only when execution tracing is enabled. Looked up by - * `resolveWires` to flip bits in `traceMask`. - */ - traceBits?: Map; - /** - * Shared mutable trace bitmask — `[mask]`. Boxed in a single-element - * array so shadow trees can share the same mutable reference without - * extra allocation. Present only when execution tracing is enabled. - * Uses `bigint` to support manifests with more than 31 entries. - */ - traceMask?: [bigint]; } /** Returns `true` when `value` is a thenable (Promise or Promise-like). */ diff --git a/packages/bridge-core/src/tree-utils.ts b/packages/bridge-core/src/tree-utils.ts index dba042a5..c9fa48fe 100644 --- a/packages/bridge-core/src/tree-utils.ts +++ b/packages/bridge-core/src/tree-utils.ts @@ -1,60 +1,11 @@ /** - * Pure utility functions for the execution tree — no class dependency. - * - * Extracted from ExecutionTree.ts — Phase 1 of the refactor. - * See docs/execution-tree-refactor.md + * Pure utility functions used by the execution engine. */ -import type { NodeRef, Wire } from "./types.ts"; -import type { Trunk } from "./tree-types.ts"; - -// ── Trunk helpers ─────────────────────────────────────────────────────────── - -/** Stable string key for the state map */ -export function trunkKey(ref: Trunk & { element?: boolean }): string { - if (ref.element) return `${ref.module}:${ref.type}:${ref.field}:*`; - return `${ref.module}:${ref.type}:${ref.field}${ref.instance != null ? `:${ref.instance}` : ""}`; -} - -/** Match two trunks (ignoring path and element) */ -export function sameTrunk(a: Trunk, b: Trunk): boolean { - return ( - a.module === b.module && - a.type === b.type && - a.field === b.field && - (a.instance ?? undefined) === (b.instance ?? undefined) - ); -} - -// ── Path helpers ──────────────────────────────────────────────────────────── - -/** Strict path equality — manual loop avoids `.every()` closure allocation. See packages/bridge-core/performance.md (#7). */ -export function pathEquals(a: string[], b: string[]): boolean { - if (!a || !b) return a === b; - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) return false; - } - return true; -} - // ── Constant coercion ─────────────────────────────────────────────────────── -/** - * Coerce a constant wire value string to its proper JS type. - * - * Uses strict primitive parsing — no `JSON.parse` — to eliminate any - * hypothetical AST-injection gadget chains. Handles boolean, null, - * numeric literals, and JSON-encoded strings (`'"hello"'` → `"hello"`). - * JSON objects/arrays in fallback positions return the raw string. - * - * Results are cached in a module-level Map because the same constant - * strings appear repeatedly across shadow trees. Only safe for - * immutable values (primitives); callers must not mutate the returned - * value. See packages/bridge-core/performance.md (#6). - */ const constantCache = new Map(); -export function coerceConstant(raw: string): unknown { +export function coerceConstant(raw: string | unknown): unknown { if (typeof raw !== "string") return raw; const cached = constantCache.get(raw); if (cached !== undefined) return cached; @@ -152,61 +103,8 @@ export function setNested(obj: any, path: string[], value: any): void { } } -// ── Symbol-keyed engine caches ────────────────────────────────────────────── -// -// Cached values are stored on AST objects using Symbol keys instead of -// string keys. V8 stores Symbol-keyed properties in a separate backing -// store that does not participate in the hidden-class (Shape) system. -// This means the execution engine can safely cache computed values on -// parser-produced objects without triggering shape transitions that would -// degrade the parser's allocation-site throughput. -// See packages/bridge-core/performance.md (#11). - -/** Symbol key for the cached `trunkKey()` result on NodeRef objects. */ -export const TRUNK_KEY_CACHE = Symbol.for("bridge.trunkKey"); - -/** Symbol key for the cached simple-pull ref on Wire objects. */ -export const SIMPLE_PULL_CACHE = Symbol.for("bridge.simplePull"); - -// ── Wire helpers ──────────────────────────────────────────────────────────── - -/** - * Get the primary NodeRef from a wire's first source expression, if it's a ref. - * Unlike `getSimplePullRef`, this works for any wire (including those with - * fallbacks, catch, or safe access). - */ -export function getPrimaryRef(w: Wire): NodeRef | undefined { - const expr = w.sources[0]?.expr; - return expr?.type === "ref" ? expr.ref : undefined; -} - -/** Return true if the wire's primary source is a ref expression. */ -export function isPullWire(w: Wire): boolean { - return w.sources[0]?.expr.type === "ref"; -} - -/** - * Returns the source NodeRef when a wire qualifies for the simple-pull fast - * path: single ref source, not safe, no fallbacks, no catch. Returns - * `null` otherwise. The result is cached on the wire via a Symbol key so - * subsequent calls are a single property read without affecting V8 shapes. - * See packages/bridge-core/performance.md (#11). - */ -export function getSimplePullRef(w: Wire): NodeRef | null { - const cached = (w as any)[SIMPLE_PULL_CACHE]; - if (cached !== undefined) return cached; - let ref: NodeRef | null = null; - if (w.sources.length === 1 && !w.catch) { - const expr = w.sources[0]!.expr; - if (expr.type === "ref" && !expr.safe) ref = expr.ref; - } - (w as any)[SIMPLE_PULL_CACHE] = ref; - return ref; -} - -// ── Misc ──────────────────────────────────────────────────────────────────── +// ── Timing ────────────────────────────────────────────────────────────────── -/** Round milliseconds to 2 decimal places */ export function roundMs(ms: number): number { return Math.round(ms * 100) / 100; } diff --git a/packages/bridge-core/src/types.ts b/packages/bridge-core/src/types.ts index 72b570cb..21356329 100644 --- a/packages/bridge-core/src/types.ts +++ b/packages/bridge-core/src/types.ts @@ -1,5 +1,14 @@ import type { SourceLocation } from "@stackables/bridge-types"; +/** Standard JSON primitive type — the result of JSON.parse(). */ +export type JsonValue = + | string + | number + | boolean + | null + | { [key: string]: JsonValue } + | JsonValue[]; + /** * Structured node reference — identifies a specific data point in the execution graph. * @@ -28,28 +37,6 @@ export type NodeRef = { pathSafe?: boolean[]; }; -/** - * A wire connects a data source (from) to a data sink (to). - * - * Unified shape: every wire has an ordered list of source entries and an - * optional catch handler. The first source entry is always evaluated; subsequent - * entries have a gate (`||` for falsy, `??` for nullish) that determines whether - * to fall through to them. - * - * Constant wires have a single literal source entry. - * Ternary/boolean wires have a single ternary/and/or expression entry. - * Pipe wires (`pipe: true`) route data through declared tool handles. - * Spread wires (`spread: true`) merge source object properties into the target. - */ -export type Wire = { - to: NodeRef; - sources: WireSourceEntry[]; - catch?: WireCatch; - pipe?: true; - spread?: true; - loc?: SourceLocation; -}; - /** * Bridge definition — wires one GraphQL field to its data sources. */ @@ -61,37 +48,13 @@ export type Bridge = { field: string; /** Declared data sources and their wire handles */ handles: HandleBinding[]; - /** Connection wires */ - wires: Wire[]; + /** Nested statement tree — the scoped IR. */ + body: Statement[]; /** * When set, this bridge was declared with the passthrough shorthand: * `bridge Type.field with `. The value is the define/tool name. */ passthrough?: string; - /** Handles to eagerly evaluate (e.g. side-effect tools). - * Critical by default — a forced handle that throws aborts the bridge. - * Add `catchError: true` (written as `force ?? null`) to - * swallow the error for fire-and-forget side-effects. */ - forces?: Array<{ - handle: string; - module: string; - type: string; - field: string; - instance?: number; - /** When true, errors from this forced handle are silently caught (`?? null`). */ - catchError?: true; - }>; - arrayIterators?: Record; - pipeHandles?: Array<{ - key: string; - handle: string; - baseTrunk: { - module: string; - type: string; - field: string; - instance?: number; - }; - }>; }; /** @@ -141,10 +104,8 @@ export type ToolDef = { /** Declared handles — same as Bridge/Define handles (tools, context, const, etc.) * Tools cannot declare `input` or `output` handles. */ handles: HandleBinding[]; - /** Connection wires — same format as Bridge/Define wires */ - wires: Wire[]; - /** Synthetic fork handles for expressions, string interpolation, etc. */ - pipeHandles?: Bridge["pipeHandles"]; + /** Nested statement tree — the scoped IR. */ + body: Statement[]; /** Error fallback for the tool call — replaces the result when the tool throws. */ onError?: { value: string } | { source: string }; }; @@ -185,9 +146,9 @@ export type ControlFlowInstruction = | { kind: "continue"; levels?: number } | { kind: "break"; levels?: number }; -// ── Wire Expression Model ─────────────────────────────────────────────────── +// ── Source Expression Model ────────────────────────────────────────────────── // -// Every wire is an ordered list of source entries + an optional catch handler. +// Every statement has an ordered list of source entries + an optional catch handler. // Source entries contain recursive Expression trees that evaluate to values. /** @@ -212,9 +173,14 @@ export type Expression = loc?: SourceLocation; } | { - /** JSON-encoded constant: "\"hello\"", "42", "true", "null" */ + /** + * A fully parsed, ready-to-use literal value. + * + * The AST builder runs JSON.parse() once during compilation. + * Value is the parsed JsonValue. + */ type: "literal"; - value: string; + value: JsonValue; loc?: SourceLocation; } | { @@ -251,8 +217,102 @@ export type Expression = type: "control"; control: ControlFlowInstruction; loc?: SourceLocation; + } + | { + /** + * Array mapping expression — iterates over a source array and maps each + * element through a scoped statement body. + * + * Syntax: `source[] as iterName { body }` + * + * This is a first-class expression: it can appear anywhere an expression + * is valid, including in fallback chains (`||`, `??`), catch handlers, + * ternary branches, and aliases. + */ + type: "array"; + /** Expression producing the source array to iterate over */ + source: Expression; + /** Iterator binding name (the `as ` part) */ + iteratorName: string; + /** Scoped statement body — wires, withs, nested scopes */ + body: Statement[]; + loc?: SourceLocation; + } + | { + /** + * Pipe expression — passes data through a tool/define handle. + * + * Syntax: `handle:source` (chained: `trim:upper:i.text`) + * + * Replaces the legacy `pipe: true` wire flag + `pipeHandles` registry. + * The engine creates fork instances internally during evaluation. + */ + type: "pipe"; + /** The data being piped into the tool */ + source: Expression; + /** Tool or define handle name to process the data */ + handle: string; + /** Input path within the tool (e.g. `dv.dividend:source` → ["dividend"]) */ + path?: string[]; + loc?: SourceLocation; + } + | { + /** + * Binary operator expression — arithmetic or comparison. + * + * Replaces the legacy desugaring that created synthetic tool forks + * (e.g. `Tools.add`, `Tools.eq`) for operators like `+`, `==`. + */ + type: "binary"; + op: BinaryOp; + left: Expression; + right: Expression; + loc?: SourceLocation; + } + | { + /** + * Unary operator expression — logical NOT. + * + * Replaces the legacy `Tools.not` synthetic fork. + */ + type: "unary"; + op: "not"; + operand: Expression; + loc?: SourceLocation; + } + | { + /** + * String template concatenation — joins parts into a single string. + * + * Replaces the legacy `Tools.concat` synthetic fork with indexed + * `parts.0`, `parts.1` inputs. + * + * Result: `{ value: string }` (matches internal.concat return shape). + */ + type: "concat"; + parts: Expression[]; + loc?: SourceLocation; }; +/** + * Binary operator names for arithmetic and comparison expressions. + * + * Matches the internal tool function names: + * - Arithmetic: add (+), sub (-), mul (*), div (/) + * - Comparison: eq (==), neq (!=), gt (>), gte (>=), lt (<), lte (<=) + */ +export type BinaryOp = + | "add" + | "sub" + | "mul" + | "div" + | "eq" + | "neq" + | "gt" + | "gte" + | "lt" + | "lte"; + /** * One entry in the wire's ordered fallback chain. * @@ -276,13 +336,133 @@ export interface WireSourceEntry { } /** - * Catch handler for a wire — provides error recovery via a ref, literal, or - * control flow instruction. + * Catch handler for a wire — provides error recovery via a ref, literal, + * control flow instruction, or a full expression (e.g. pipe chain). */ export type WireCatch = | { ref: NodeRef; loc?: SourceLocation } - | { value: string; loc?: SourceLocation } - | { control: ControlFlowInstruction; loc?: SourceLocation }; + | { value: JsonValue; loc?: SourceLocation } + | { control: ControlFlowInstruction; loc?: SourceLocation } + | { expr: Expression; loc?: SourceLocation }; + +/** + * The shared right-hand side of any assignment — a fallback chain of source + * entries with an optional catch handler. + * + * Used by both WireStatement (assigns to a graph node) and + * WireAliasStatement (assigns to a local name). + */ +export interface SourceChain { + sources: WireSourceEntry[]; + catch?: WireCatch; +} + +// ── Statement Model (Nested Scoped IR) ────────────────────────────────────── +// +// Statements form a recursive tree that preserves scope boundaries from the +// source code. Each bridge/define/tool body is a Statement[]. +// +// Scopes created by ScopeStatement and ArrayExpression.body support: +// - `with` declarations with lexical shadowing (inner overrides outer) +// - wire targets relative to the scope's path prefix +// - tool registration fallthrough to parent scopes + +/** + * A wire statement — assigns an evaluation chain to a graph target. + * + * Corresponds to: `target <- expression [|| fallback] [catch handler]` + */ +export type WireStatement = SourceChain & { + kind: "wire"; + /** The graph node being assigned to */ + target: NodeRef; + loc?: SourceLocation; +}; + +/** + * A wire alias — assigns an evaluation chain to a local name. + * + * Corresponds to: `alias name <- expression [|| fallback] [catch handler]` + */ +export type WireAliasStatement = SourceChain & { + kind: "alias"; + /** The alias name (used as a local reference in subsequent wires) */ + name: string; + loc?: SourceLocation; +}; + +/** + * A with statement — declares a named data source in the current scope. + * + * Can appear at any scope level (bridge body, scope block, array body). + * Inner scopes shadow outer scopes with the same handle name. + * + * Corresponds to: `with [as ] [memoize]` + */ +export type WithStatement = { + kind: "with"; + binding: HandleBinding; +}; + +/** + * A scope statement — groups statements under a path prefix. + * + * Creates a new scope layer: `with` declarations inside apply only within + * this scope (with fallthrough to parent for missing handles). + * + * Corresponds to: `target { statement* }` + */ +export type ScopeStatement = { + kind: "scope"; + /** Target path prefix — all wires inside are relative to this */ + target: NodeRef; + /** Nested statements within this scope */ + body: Statement[]; + loc?: SourceLocation; +}; + +/** + * A spread statement — merges source properties into the enclosing scope target. + * + * Can only appear inside a ScopeStatement body. The target is implicit — + * it is always the parent scope's target node. + * + * Corresponds to: `... <- source [|| fallback] [catch handler]` + */ +export type SpreadStatement = SourceChain & { + kind: "spread"; + loc?: SourceLocation; +}; + +/** + * A force statement — eagerly evaluates a handle for side effects. + * + * Corresponds to: `force [catch null]` + */ +export type ForceStatement = { + kind: "force"; + handle: string; + module: string; + type: string; + field: string; + instance?: number; + /** When true, errors are silently caught (`catch null`). */ + catchError?: true; + loc?: SourceLocation; +}; + +/** + * Union of all statement types that can appear in a bridge/define/tool body. + * + * This is the recursive building block of the nested IR. + */ +export type Statement = + | WireStatement + | WireAliasStatement + | SpreadStatement + | WithStatement + | ScopeStatement + | ForceStatement; /** * Named constant definition — a reusable value defined in the bridge file. @@ -359,11 +539,7 @@ export type DefineDef = { name: string; /** Declared handles (tools, input, output, etc.) */ handles: HandleBinding[]; - /** Connection wires (same format as Bridge wires) */ - wires: Wire[]; - /** Array iterators (same as Bridge) */ - arrayIterators?: Record; - /** Pipe fork registry (same as Bridge) */ - pipeHandles?: Bridge["pipeHandles"]; + /** Nested statement tree — the scoped IR. */ + body: Statement[]; }; /* c8 ignore stop */ diff --git a/packages/bridge-core/test/enumerate-traversals.test.ts b/packages/bridge-core/test/enumerate-traversals.test.ts index 8cfc76cb..9212e870 100644 --- a/packages/bridge-core/test/enumerate-traversals.test.ts +++ b/packages/bridge-core/test/enumerate-traversals.test.ts @@ -2,16 +2,11 @@ import { describe, test } from "node:test"; import assert from "node:assert/strict"; import { parseBridge } from "@stackables/bridge-parser"; import { - enumerateTraversalIds, buildTraversalManifest, decodeExecutionTrace, executeBridge, } from "@stackables/bridge-core"; -import type { - Bridge, - TraversalEntry, - BridgeDocument, -} from "@stackables/bridge-core"; +import type { Bridge, BridgeDocument } from "@stackables/bridge-core"; import { bridge } from "@stackables/bridge-core"; function getBridge(source: string): Bridge { @@ -21,554 +16,24 @@ function getBridge(source: string): Bridge { return instr; } -function ids(entries: TraversalEntry[]): string[] { - return entries.map((e) => e.id); -} - -// ── Simple wires ──────────────────────────────────────────────────────────── - -describe("enumerateTraversalIds", () => { - test("simple pull wire — 1 traversal (primary)", () => { - const instr = getBridge(bridge` - version 1.5 - bridge Query.demo { - with api - with input as i - with output as o - api.q <- i.q - o.result <- api.label - } - `); - const entries = enumerateTraversalIds(instr); - const primaries = entries.filter((e) => e.kind === "primary"); - assert.ok(primaries.length >= 2, "at least 2 primary wires"); - assert.ok( - entries.every((e) => e.kind === "primary"), - "no fallbacks or catches", - ); - }); - - test("constant wire — 1 traversal (const)", () => { - const instr = getBridge(bridge` - version 1.5 - bridge Query.demo { - with api - with output as o - api.mode = "fast" - o.result <- api.label - } - `); - const entries = enumerateTraversalIds(instr); - const consts = entries.filter((e) => e.kind === "const"); - assert.equal(consts.length, 1); - assert.ok(consts[0].id.endsWith("/const")); - }); - - // ── Fallback chains ─────────────────────────────────────────────────────── - - test("|| fallback — 2 non-error traversals (primary + fallback)", () => { - const instr = getBridge(bridge` - version 1.5 - bridge Query.demo { - with a - with b - with input as i - with output as o - a.q <- i.q - b.q <- i.q - o.label <- a.label || b.label - } - `); - const entries = enumerateTraversalIds(instr); - const labelEntries = entries.filter( - (e) => e.target.includes("label") && e.target.length === 1 && !e.error, - ); - assert.equal(labelEntries.length, 2); - assert.equal(labelEntries[0].kind, "primary"); - assert.equal(labelEntries[1].kind, "fallback"); - assert.equal(labelEntries[1].gateType, "falsy"); - assert.equal(labelEntries[1].fallbackIndex, 0); - }); - - test("?? fallback — 2 non-error traversals (primary + nullish fallback)", () => { - const instr = getBridge(bridge` - version 1.5 - bridge Query.demo { - with api - with input as i - with output as o - api.q <- i.q - o.label <- api.label ?? "default" - } - `); - const entries = enumerateTraversalIds(instr); - const labelEntries = entries.filter( - (e) => e.target.includes("label") && e.target.length === 1 && !e.error, - ); - assert.equal(labelEntries.length, 2); - assert.equal(labelEntries[0].kind, "primary"); - assert.equal(labelEntries[1].kind, "fallback"); - assert.equal(labelEntries[1].gateType, "nullish"); - }); - - test("|| || — 3 non-error traversals (primary + 2 fallbacks)", () => { - const instr = getBridge(bridge` - version 1.5 - bridge Query.demo { - with a - with b - with input as i - with output as o - a.q <- i.q - b.q <- i.q - o.label <- a.label || b.label || "fallback" - } - `); - const entries = enumerateTraversalIds(instr); - const labelEntries = entries.filter( - (e) => e.target.includes("label") && e.target.length === 1 && !e.error, - ); - assert.equal(labelEntries.length, 3); - assert.equal(labelEntries[0].kind, "primary"); - assert.equal(labelEntries[1].kind, "fallback"); - assert.equal(labelEntries[1].fallbackIndex, 0); - assert.equal(labelEntries[2].kind, "fallback"); - assert.equal(labelEntries[2].fallbackIndex, 1); - }); - - // ── Catch ───────────────────────────────────────────────────────────────── - - test("catch — 2 traversals (primary + catch)", () => { - const instr = getBridge(bridge` - version 1.5 - bridge Query.demo { - with api - with input as i - with output as o - api.q <- i.q - o.lat <- api.lat catch 0 - } - `); - const entries = enumerateTraversalIds(instr); - const latEntries = entries.filter( - (e) => e.target.includes("lat") && e.target.length === 1, - ); - assert.equal(latEntries.length, 2); - assert.equal(latEntries[0].kind, "primary"); - assert.equal(latEntries[1].kind, "catch"); - }); - - // ── Problem statement example: || + catch ───────────────────────────────── - - test("o <- i.a || i.b catch i.c — 3 traversals", () => { - const instr = getBridge(bridge` - version 1.5 - bridge Query.demo { - with a - with b - with input as i - with output as o - a.q <- i.q - b.q <- i.q - o.result <- a.value || b.value catch i.fallback - } - `); - const entries = enumerateTraversalIds(instr); - const resultEntries = entries.filter( - (e) => e.target.includes("result") && e.target.length === 1, - ); - assert.equal(resultEntries.length, 3); - assert.equal(resultEntries[0].kind, "primary"); - assert.equal(resultEntries[1].kind, "fallback"); - assert.equal(resultEntries[2].kind, "catch"); - }); - - // ── Error traversal entries ─────────────────────────────────────────────── - - test("a.label || b.label — 4 traversals (primary, fallback, primary/error, fallback/error)", () => { - const instr = getBridge(bridge` - version 1.5 - bridge Query.demo { - with a - with b - with input as i - with output as o - a.q <- i.q - b.q <- i.q - o.label <- a.label || b.label - } - `); - const entries = enumerateTraversalIds(instr); - const labelEntries = entries.filter( - (e) => e.target.includes("label") && e.target.length === 1, - ); - assert.equal(labelEntries.length, 4); - // Non-error entries come first - assert.equal(labelEntries[0].kind, "primary"); - assert.ok(!labelEntries[0].error); - assert.equal(labelEntries[1].kind, "fallback"); - assert.ok(!labelEntries[1].error); - // Error entries come after - assert.equal(labelEntries[2].kind, "primary"); - assert.ok(labelEntries[2].error); - assert.equal(labelEntries[3].kind, "fallback"); - assert.ok(labelEntries[3].error); - }); - - test("a.label || b?.label — 3 traversals (primary, fallback, primary/error)", () => { - const instr = getBridge(bridge` - version 1.5 - bridge Query.demo { - with a - with b - with input as i - with output as o - a.q <- i.q - b.q <- i.q - o.label <- a.label || b?.label - } - `); - const entries = enumerateTraversalIds(instr); - const labelEntries = entries.filter( - (e) => e.target.includes("label") && e.target.length === 1, - ); - assert.equal(labelEntries.length, 3); - // Non-error entries come first - assert.equal(labelEntries[0].kind, "primary"); - assert.ok(!labelEntries[0].error); - assert.equal(labelEntries[1].kind, "fallback"); - assert.ok(!labelEntries[1].error); - // b?.label has rootSafe — no error entry for fallback - assert.equal(labelEntries[2].kind, "primary"); - assert.ok(labelEntries[2].error); - }); - - test("a.label || b.label catch 'whatever' — 3 traversals (primary, fallback, catch)", () => { - const instr = getBridge(bridge` - version 1.5 - bridge Query.demo { - with a - with b - with input as i - with output as o - a.q <- i.q - b.q <- i.q - o.label <- a.label || b.label catch "whatever" - } - `); - const entries = enumerateTraversalIds(instr); - const labelEntries = entries.filter( - (e) => e.target.includes("label") && e.target.length === 1, - ); - // catch absorbs all errors — no error entries for primary or fallback - assert.equal(labelEntries.length, 3); - assert.equal(labelEntries[0].kind, "primary"); - assert.ok(!labelEntries[0].error); - assert.equal(labelEntries[1].kind, "fallback"); - assert.ok(!labelEntries[1].error); - assert.equal(labelEntries[2].kind, "catch"); - assert.ok(!labelEntries[2].error); - }); - - test("catch with tool ref — catch/error entry added", () => { - const instr = getBridge(bridge` - version 1.5 - bridge Query.demo { - with a - with b - with input as i - with output as o - a.q <- i.q - b.q <- i.q - o.label <- a.label catch b.fallback - } - `); - const entries = enumerateTraversalIds(instr); - const labelEntries = entries.filter( - (e) => e.target.includes("label") && e.target.length === 1, - ); - // primary + catch + catch/error - assert.equal(labelEntries.length, 3); - assert.equal(labelEntries[0].kind, "primary"); - assert.ok(!labelEntries[0].error); - assert.equal(labelEntries[1].kind, "catch"); - assert.ok(!labelEntries[1].error); - assert.equal(labelEntries[2].kind, "catch"); - assert.ok(labelEntries[2].error); - }); - - test("simple pull wire — primary + primary/error", () => { - const instr = getBridge(bridge` - version 1.5 - bridge Query.demo { - with api - with input as i - with output as o - api.q <- i.q - o.result <- api.value - } - `); - const entries = enumerateTraversalIds(instr); - const resultEntries = entries.filter( - (e) => e.target.includes("result") && e.target.length === 1, - ); - assert.equal(resultEntries.length, 2); - assert.equal(resultEntries[0].kind, "primary"); - assert.ok(!resultEntries[0].error); - assert.equal(resultEntries[1].kind, "primary"); - assert.ok(resultEntries[1].error); - }); - - test("input ref wire — no error entry (inputs cannot throw)", () => { - const instr = getBridge(bridge` - version 1.5 - bridge Query.demo { - with api - with input as i - with output as o - api.q <- i.q - o.result <- api.value - } - `); - const entries = enumerateTraversalIds(instr); - const qEntries = entries.filter( - (e) => e.target.includes("q") && e.target.length === 1, - ); - // i.q is an input ref — no error entry - assert.equal(qEntries.length, 1); - assert.equal(qEntries[0].kind, "primary"); - assert.ok(!qEntries[0].error); - }); - - test("safe (?.) wire — no primary/error entry", () => { - const instr = getBridge(bridge` - version 1.5 - bridge Query.demo { - with api - with input as i - with output as o - api.q <- i.q - o.result <- api?.value - } - `); - const entries = enumerateTraversalIds(instr); - const resultEntries = entries.filter( - (e) => e.target.includes("result") && e.target.length === 1, - ); - // rootSafe ref — canRefError returns false, no error entry - assert.equal(resultEntries.length, 1); - assert.equal(resultEntries[0].kind, "primary"); - assert.ok(!resultEntries[0].error); - }); - - test("error entries have unique IDs", () => { - const instr = getBridge(bridge` - version 1.5 - bridge Query.demo { - with a - with b - with input as i - with output as o - a.q <- i.q - b.q <- i.q - o.label <- a.label || b.label - } - `); - const entries = enumerateTraversalIds(instr); - const allIds = ids(entries); - const unique = new Set(allIds); - assert.equal( - unique.size, - allIds.length, - `IDs must be unique: ${JSON.stringify(allIds)}`, - ); - }); - - // ── Array iterators ─────────────────────────────────────────────────────── - - test("array block — adds empty-array traversal", () => { - const instr = getBridge(bridge` - version 1.5 - bridge Query.demo { - with api - with output as o - o <- api.items[] as it { - .id <- it.id - .name <- it.name - } - } - `); - const entries = enumerateTraversalIds(instr); - const emptyArr = entries.filter((e) => e.kind === "empty-array"); - assert.equal(emptyArr.length, 1); - assert.equal(emptyArr[0].wireIndex, -1); - }); - - // ── Problem statement example: array + ?? ───────────────────────────────── - - test("o.out <- i.array[] as a { .data <- a.a ?? a.b } — 3 traversals", () => { - const instr = getBridge(bridge` - version 1.5 - bridge Query.demo { - with api - with output as o - o <- api.items[] as a { - .data <- a.a ?? a.b - } - } - `); - const entries = enumerateTraversalIds(instr); - // Should have: empty-array + primary(.data) + fallback(.data) - assert.equal(entries.length, 3); - const emptyArr = entries.filter((e) => e.kind === "empty-array"); - assert.equal(emptyArr.length, 1); - const dataEntries = entries.filter((e) => - e.target.join(".").includes("data"), - ); - assert.equal(dataEntries.length, 2); - assert.equal(dataEntries[0].kind, "primary"); - assert.equal(dataEntries[1].kind, "fallback"); - assert.equal(dataEntries[1].gateType, "nullish"); - }); - - // ── Nested arrays ───────────────────────────────────────────────────────── - - test("nested array blocks — 2 empty-array entries", () => { - const instr = getBridge(bridge` - version 1.5 - bridge Query.demo { - with api - with output as o - o <- api.journeys[] as j { - .label <- j.label - .legs <- j.legs[] as l { - .name <- l.name - } - } - } - `); - const entries = enumerateTraversalIds(instr); - const emptyArr = entries.filter((e) => e.kind === "empty-array"); - assert.equal(emptyArr.length, 2, "two array scopes"); - }); - - // ── IDs are unique ──────────────────────────────────────────────────────── - - test("all IDs within a bridge are unique", () => { - const instr = getBridge(bridge` - version 1.5 - bridge Query.demo { - with a - with b - with input as i - with output as o - a.q <- i.q - b.q <- i.q - o.label <- a.label || b.label catch "none" - o.score <- a.score ?? 0 - } - `); - const entries = enumerateTraversalIds(instr); - const allIds = ids(entries); - const unique = new Set(allIds); - assert.equal( - unique.size, - allIds.length, - `IDs must be unique: ${JSON.stringify(allIds)}`, - ); - }); - - // ── TraversalEntry shape ────────────────────────────────────────────────── - - test("entries have correct structure", () => { - const instr = getBridge(bridge` - version 1.5 - bridge Query.demo { - with api - with input as i - with output as o - api.q <- i.q - o.result <- api.value || "default" catch 0 - } - `); - const entries = enumerateTraversalIds(instr); - for (const entry of entries) { - assert.ok(typeof entry.id === "string", "id is string"); - assert.ok(typeof entry.wireIndex === "number", "wireIndex is number"); - assert.ok(Array.isArray(entry.target), "target is array"); - assert.ok(typeof entry.kind === "string", "kind is string"); - } - const fb = entries.find((e) => e.kind === "fallback"); - assert.ok(fb, "should have a fallback entry"); - assert.equal(fb!.fallbackIndex, 0); - assert.equal(fb!.gateType, "falsy"); - }); - - // ── Conditional wire ────────────────────────────────────────────────────── - - test("conditional (ternary) wire — 2 traversals (then + else)", () => { - const instr = getBridge(bridge` - version 1.5 - bridge Query.demo { - with api - with input as i - with output as o - api.q <- i.q - o.label <- i.flag ? api.a : api.b - } - `); - const entries = enumerateTraversalIds(instr); - const labelEntries = entries.filter( - (e) => e.target.includes("label") && e.target.length === 1, - ); - assert.ok(labelEntries.length >= 2, "at least then + else"); - const then = labelEntries.find((e) => e.kind === "then"); - const els = labelEntries.find((e) => e.kind === "else"); - assert.ok(then, "should have a then entry"); - assert.ok(els, "should have an else entry"); - }); - - // ── Total count is a complexity proxy ───────────────────────────────────── - - test("total traversal count reflects complexity", () => { - const simple = getBridge(bridge` - version 1.5 - bridge Query.simple { - with api - with output as o - o.value <- api.value - } - `); - const complex = getBridge(bridge` - version 1.5 - bridge Query.complex { - with a - with b - with input as i - with output as o - a.q <- i.q - b.q <- i.q - o.x <- a.x || b.x catch "none" - o.y <- a.y ?? b.y - o.items <- a.items[] as it { - .name <- it.name || "anon" - } - } - `); - const simpleCount = enumerateTraversalIds(simple).length; - const complexCount = enumerateTraversalIds(complex).length; - assert.ok( - complexCount > simpleCount, - `complex (${complexCount}) should exceed simple (${simpleCount})`, - ); - }); -}); - // ── buildTraversalManifest ────────────────────────────────────────────────── describe("buildTraversalManifest", () => { - test("is an alias for enumerateTraversalIds", () => { - assert.strictEqual(buildTraversalManifest, enumerateTraversalIds); + test("delegates to body-based traversal for bridges with body", () => { + const src = `version 1.5 +bridge Query.foo { + with input as i + with output as o + o.x <- i.x +}`; + const instr = getBridge(src); + assert.ok(instr.body, "bridge should have body"); + const manifest = buildTraversalManifest(instr); + assert.ok(manifest.length > 0, "manifest should have entries"); + // Body-based entries get wireIndex -1 + for (const e of manifest) { + assert.equal(e.wireIndex, -1, "body entries use wireIndex -1"); + } }); test("entries have sequential bitIndex starting at 0", () => { @@ -665,10 +130,11 @@ describe("decodeExecutionTrace", () => { } const decoded = decodeExecutionTrace(manifest, mask); assert.equal(decoded.length, 3); - assert.deepEqual( - decoded.map((e) => e.kind), - ["primary", "fallback", "catch"], - ); + assert.deepEqual(decoded.map((e) => e.kind).sort(), [ + "catch", + "fallback", + "primary", + ]); }); test("round-trip: build manifest, set bits, decode", () => { diff --git a/packages/bridge-core/test/execution-tree.test.ts b/packages/bridge-core/test/execution-tree.test.ts index bbe3082e..9dcf119e 100644 --- a/packages/bridge-core/test/execution-tree.test.ts +++ b/packages/bridge-core/test/execution-tree.test.ts @@ -1,85 +1,6 @@ import assert from "node:assert/strict"; import { describe, test } from "node:test"; -import { - BridgeAbortError, - BridgePanicError, - BridgeRuntimeError, - ExecutionTree, - MAX_EXECUTION_DEPTH, - type BridgeDocument, - type NodeRef, -} from "../src/index.ts"; - -const DOC: BridgeDocument = { version: "1.5", instructions: [] }; -const TRUNK = { module: "_", type: "Query", field: "test" }; - -function ref(path: string[], rootSafe = false): NodeRef { - return { module: "_", type: "Query", field: "test", path, rootSafe }; -} - -describe("ExecutionTree edge cases", () => { - test("constructor rejects parent depth beyond hard recursion limit", () => { - const parent = { depth: 30 } as unknown as ExecutionTree; - assert.throws( - () => new ExecutionTree(TRUNK, DOC, {}, undefined, parent), - BridgePanicError, - ); - }); - - test("shadow() beyond MAX_EXECUTION_DEPTH throws BridgePanicError", () => { - let tree = new ExecutionTree(TRUNK, DOC); - for (let i = 0; i < MAX_EXECUTION_DEPTH; i++) { - tree = tree.shadow(); - } - assert.throws( - () => tree.shadow(), - (err: any) => { - assert.ok(err instanceof BridgePanicError); - assert.match(err.message, /Maximum execution depth exceeded/); - return true; - }, - ); - }); - - test("createShadowArray aborts when signal is already aborted", () => { - const tree = new ExecutionTree(TRUNK, DOC); - const controller = new AbortController(); - controller.abort(); - tree.signal = controller.signal; - - assert.throws( - () => (tree as any).createShadowArray([{}]), - BridgeAbortError, - ); - }); - - test("applyPath respects rootSafe and throws when not rootSafe", () => { - const tree = new ExecutionTree(TRUNK, DOC); - assert.equal((tree as any).applyPath(null, ref(["x"], true)), undefined); - assert.throws( - () => (tree as any).applyPath(null, ref(["x"])), - (err: unknown) => { - assert.ok(err instanceof BridgeRuntimeError); - assert.ok(err.cause instanceof TypeError); - assert.match( - err.message, - /Cannot read properties of null \(reading 'x'\)/, - ); - return true; - }, - ); - }); - - test("applyPath warns when using object-style access on arrays", () => { - const tree = new ExecutionTree(TRUNK, DOC); - let warning = ""; - tree.logger = { warn: (msg: string) => (warning = msg) }; - - assert.equal((tree as any).applyPath([{ x: 1 }], ref(["x"])), undefined); - assert.equal((tree as any).applyPath([{ x: 1 }], ref(["0", "x"])), 1); - assert.match(warning, /Accessing "\.x" on an array/); - }); -}); +import { BridgeAbortError, BridgePanicError } from "../src/index.ts"; // ═══════════════════════════════════════════════════════════════════════════ // Error class identity diff --git a/packages/bridge-core/test/resolve-wires.test.ts b/packages/bridge-core/test/resolve-wires.test.ts deleted file mode 100644 index e923fbe1..00000000 --- a/packages/bridge-core/test/resolve-wires.test.ts +++ /dev/null @@ -1,401 +0,0 @@ -/** - * Unit tests for wire resolution. - * - * Tests expression evaluation, fallback gates, and catch handlers. - */ -import assert from "node:assert/strict"; -import { describe, test } from "node:test"; -import { - BREAK_SYM, - CONTINUE_SYM, - isLoopControlSignal, -} from "../src/tree-types.ts"; -import type { TreeContext } from "../src/tree-types.ts"; -import type { Expression, NodeRef, Wire } from "../src/types.ts"; -import { - evaluateExpression, - applyFallbackGates, - applyCatch, -} from "../src/resolveWiresSources.ts"; - -// ── Test helpers ───────────────────────────────────────────────────────────── - -const REF: NodeRef = { module: "m", type: "Query", field: "f", path: [] }; - -function ref(field: string): NodeRef { - return { module: "m", type: "Query", field, path: [] }; -} - -function makeCtx(values: Record = {}): TreeContext { - return { - pullSingle(ref) { - const key = `${ref.module}.${ref.field}`; - if (key in values) { - const v = values[key]; - if (v instanceof Error) throw v; - return v as ReturnType; - } - return undefined as ReturnType; - }, - }; -} - -function makeWire(sources: Wire["sources"], opts: Partial = {}): Wire { - return { to: REF, sources, ...opts }; -} - -// ── evaluateExpression ────────────────────────────────────────────────────── - -describe("evaluateExpression", () => { - test("evaluates a ref expression", async () => { - const ctx = makeCtx({ "m.x": "hello" }); - const expr: Expression = { type: "ref", ref: ref("x") }; - assert.equal(await evaluateExpression(ctx, expr), "hello"); - }); - - test("evaluates a literal expression", async () => { - const ctx = makeCtx(); - assert.equal( - await evaluateExpression(ctx, { type: "literal", value: "42" }), - 42, - ); - assert.equal( - await evaluateExpression(ctx, { type: "literal", value: '"hello"' }), - "hello", - ); - assert.equal( - await evaluateExpression(ctx, { type: "literal", value: "true" }), - true, - ); - }); - - test("safe ref returns undefined on error", async () => { - const ctx = makeCtx({ "m.x": new Error("boom") }); - const expr: Expression = { type: "ref", ref: ref("x"), safe: true }; - assert.equal(await evaluateExpression(ctx, expr), undefined); - }); - - test("evaluates a ternary expression — then branch", async () => { - const ctx = makeCtx({ "m.flag": true, "m.a": "yes", "m.b": "no" }); - const expr: Expression = { - type: "ternary", - cond: { type: "ref", ref: ref("flag") }, - then: { type: "ref", ref: ref("a") }, - else: { type: "ref", ref: ref("b") }, - }; - assert.equal(await evaluateExpression(ctx, expr), "yes"); - }); - - test("evaluates a ternary expression — else branch", async () => { - const ctx = makeCtx({ "m.flag": false, "m.a": "yes", "m.b": "no" }); - const expr: Expression = { - type: "ternary", - cond: { type: "ref", ref: ref("flag") }, - then: { type: "ref", ref: ref("a") }, - else: { type: "ref", ref: ref("b") }, - }; - assert.equal(await evaluateExpression(ctx, expr), "no"); - }); - - test("evaluates AND expression — both truthy", async () => { - const ctx = makeCtx({ "m.a": "yes", "m.b": "also" }); - const expr: Expression = { - type: "and", - left: { type: "ref", ref: ref("a") }, - right: { type: "ref", ref: ref("b") }, - }; - assert.equal(await evaluateExpression(ctx, expr), true); - }); - - test("evaluates AND expression — left falsy", async () => { - const ctx = makeCtx({ "m.a": false, "m.b": "yes" }); - const expr: Expression = { - type: "and", - left: { type: "ref", ref: ref("a") }, - right: { type: "ref", ref: ref("b") }, - }; - assert.equal(await evaluateExpression(ctx, expr), false); - }); - - test("evaluates OR expression — left truthy", async () => { - const ctx = makeCtx({ "m.a": "yes", "m.b": false }); - const expr: Expression = { - type: "or", - left: { type: "ref", ref: ref("a") }, - right: { type: "ref", ref: ref("b") }, - }; - assert.equal(await evaluateExpression(ctx, expr), true); - }); - - test("evaluates OR expression — both falsy", async () => { - const ctx = makeCtx({ "m.a": false, "m.b": false }); - const expr: Expression = { - type: "or", - left: { type: "ref", ref: ref("a") }, - right: { type: "ref", ref: ref("b") }, - }; - assert.equal(await evaluateExpression(ctx, expr), false); - }); - - test("evaluates control — continue", async () => { - const ctx = makeCtx(); - const expr: Expression = { - type: "control", - control: { kind: "continue" }, - }; - assert.equal(await evaluateExpression(ctx, expr), CONTINUE_SYM); - }); - - test("evaluates control — break", async () => { - const ctx = makeCtx(); - const expr: Expression = { - type: "control", - control: { kind: "break" }, - }; - assert.equal(await evaluateExpression(ctx, expr), BREAK_SYM); - }); - - test("evaluates control — throw", () => { - const ctx = makeCtx(); - const expr: Expression = { - type: "control", - control: { kind: "throw", message: "boom" }, - }; - assert.throws(() => evaluateExpression(ctx, expr), { message: "boom" }); - }); -}); - -// ── applyFallbackGates ────────────────────────────────────────────────── - -describe("applyFallbackGates — falsy (||)", () => { - test("passes through a truthy value unchanged", async () => { - const ctx = makeCtx(); - const w = makeWire([{ expr: { type: "ref", ref: REF } }]); - assert.equal(await applyFallbackGates(ctx, w, "hello"), "hello"); - assert.equal(await applyFallbackGates(ctx, w, 42), 42); - }); - - test("returns falsy value when no fallback entries exist", async () => { - const ctx = makeCtx(); - const w = makeWire([{ expr: { type: "ref", ref: REF } }]); - assert.equal(await applyFallbackGates(ctx, w, 0), 0); - assert.equal(await applyFallbackGates(ctx, w, null), null); - }); - - test("returns first truthy ref from falsy fallback refs", async () => { - const ctx = makeCtx({ "m.a": null, "m.b": "found" }); - const w = makeWire([ - { expr: { type: "ref", ref: REF } }, - { expr: { type: "ref", ref: ref("a") }, gate: "falsy" }, - { expr: { type: "ref", ref: ref("b") }, gate: "falsy" }, - ]); - assert.equal(await applyFallbackGates(ctx, w, null), "found"); - }); - - test("skips falsy refs and falls through to falsy constant", async () => { - const ctx = makeCtx({ "m.a": 0 }); - const w = makeWire([ - { expr: { type: "ref", ref: REF } }, - { expr: { type: "ref", ref: ref("a") }, gate: "falsy" }, - { expr: { type: "literal", value: "42" }, gate: "falsy" }, - ]); - assert.equal(await applyFallbackGates(ctx, w, null), 42); - }); - - test("applies falsy constant when value is falsy", async () => { - const ctx = makeCtx(); - const w = makeWire([ - { expr: { type: "ref", ref: REF } }, - { expr: { type: "literal", value: "default" }, gate: "falsy" }, - ]); - assert.equal(await applyFallbackGates(ctx, w, null), "default"); - assert.equal(await applyFallbackGates(ctx, w, false), "default"); - assert.equal(await applyFallbackGates(ctx, w, ""), "default"); - }); - - test("applies falsy control — continue", async () => { - const ctx = makeCtx(); - const w = makeWire([ - { expr: { type: "ref", ref: REF } }, - { - expr: { type: "control", control: { kind: "continue" } }, - gate: "falsy", - }, - ]); - assert.equal(await applyFallbackGates(ctx, w, 0), CONTINUE_SYM); - }); - - test("applies falsy control — break", async () => { - const ctx = makeCtx(); - const w = makeWire([ - { expr: { type: "ref", ref: REF } }, - { - expr: { type: "control", control: { kind: "break" } }, - gate: "falsy", - }, - ]); - assert.equal(await applyFallbackGates(ctx, w, false), BREAK_SYM); - }); - - test("applies falsy control — break level 2", async () => { - const ctx = makeCtx(); - const w = makeWire([ - { expr: { type: "ref", ref: REF } }, - { - expr: { type: "control", control: { kind: "break", levels: 2 } }, - gate: "falsy", - }, - ]); - const out = await applyFallbackGates(ctx, w, false); - assert.ok(isLoopControlSignal(out)); - assert.deepStrictEqual(out, { __bridgeControl: "break", levels: 2 }); - }); - - test("applies falsy control — throw", async () => { - const ctx = makeCtx(); - const w = makeWire([ - { expr: { type: "ref", ref: REF } }, - { - expr: { type: "control", control: { kind: "throw", message: "boom" } }, - gate: "falsy", - }, - ]); - await assert.rejects(() => applyFallbackGates(ctx, w, null), /boom/); - }); -}); - -describe("applyFallbackGates — nullish (??)", () => { - test("passes through a non-nullish value unchanged", async () => { - const ctx = makeCtx(); - const w = makeWire([ - { expr: { type: "ref", ref: REF } }, - { expr: { type: "literal", value: "99" }, gate: "nullish" }, - ]); - assert.equal(await applyFallbackGates(ctx, w, "hello"), "hello"); - assert.equal(await applyFallbackGates(ctx, w, 0), 0); - assert.equal(await applyFallbackGates(ctx, w, false), false); - assert.equal(await applyFallbackGates(ctx, w, ""), ""); - }); - - test("resolves nullish ref when value is null", async () => { - const ctx = makeCtx({ "m.fallback": "resolved" }); - const w = makeWire([ - { expr: { type: "ref", ref: REF } }, - { expr: { type: "ref", ref: ref("fallback") }, gate: "nullish" }, - ]); - assert.equal(await applyFallbackGates(ctx, w, null), "resolved"); - }); - - test("applies nullish constant when value is null", async () => { - const ctx = makeCtx(); - const w = makeWire([ - { expr: { type: "ref", ref: REF } }, - { expr: { type: "literal", value: "99" }, gate: "nullish" }, - ]); - assert.equal(await applyFallbackGates(ctx, w, null), 99); - assert.equal(await applyFallbackGates(ctx, w, undefined), 99); - }); -}); - -describe("applyFallbackGates — mixed || and ??", () => { - test("A ?? B || C — nullish then falsy", async () => { - const ctx = makeCtx({ "m.b": 0, "m.c": "found" }); - const w = makeWire([ - { expr: { type: "ref", ref: REF } }, - { expr: { type: "ref", ref: ref("b") }, gate: "nullish" }, - { expr: { type: "ref", ref: ref("c") }, gate: "falsy" }, - ]); - assert.equal(await applyFallbackGates(ctx, w, null), "found"); - }); - - test("A || B ?? C — falsy then nullish", async () => { - const ctx = makeCtx({ "m.b": null, "m.c": "fallback" }); - const w = makeWire([ - { expr: { type: "ref", ref: REF } }, - { expr: { type: "ref", ref: ref("b") }, gate: "falsy" }, - { expr: { type: "ref", ref: ref("c") }, gate: "nullish" }, - ]); - assert.equal(await applyFallbackGates(ctx, w, ""), "fallback"); - }); - - test("four-item chain", async () => { - const ctx = makeCtx({ "m.b": null, "m.c": null }); - const w = makeWire([ - { expr: { type: "ref", ref: REF } }, - { expr: { type: "ref", ref: ref("b") }, gate: "nullish" }, - { expr: { type: "ref", ref: ref("c") }, gate: "falsy" }, - { expr: { type: "literal", value: "final" }, gate: "nullish" }, - ]); - assert.equal(await applyFallbackGates(ctx, w, null), "final"); - }); - - test("mixed chain stops when value becomes truthy", async () => { - const ctx = makeCtx({ "m.b": "good" }); - const w = makeWire([ - { expr: { type: "ref", ref: REF } }, - { expr: { type: "ref", ref: ref("b") }, gate: "nullish" }, - { expr: { type: "literal", value: "unused" }, gate: "falsy" }, - ]); - assert.equal(await applyFallbackGates(ctx, w, null), "good"); - }); - - test("falsy gate open but nullish gate closed for 0", async () => { - const ctx = makeCtx(); - const w = makeWire([ - { expr: { type: "ref", ref: REF } }, - { expr: { type: "literal", value: "unused" }, gate: "nullish" }, - { expr: { type: "literal", value: "fallback" }, gate: "falsy" }, - ]); - assert.equal(await applyFallbackGates(ctx, w, 0), "fallback"); - }); -}); - -// ── applyCatch ────────────────────────────────────────────────────────── - -describe("applyCatch", () => { - test("returns undefined when no catch handler", async () => { - const ctx = makeCtx(); - const w = makeWire([{ expr: { type: "ref", ref: REF } }]); - assert.equal(await applyCatch(ctx, w), undefined); - }); - - test("applies catch value constant", async () => { - const ctx = makeCtx(); - const w = makeWire([{ expr: { type: "ref", ref: REF } }], { - catch: { value: "fallback" }, - }); - assert.equal(await applyCatch(ctx, w), "fallback"); - }); - - test("resolves catch ref", async () => { - const ctx = makeCtx({ "m.backup": "backup-value" }); - const w = makeWire([{ expr: { type: "ref", ref: REF } }], { - catch: { ref: ref("backup") }, - }); - assert.equal(await applyCatch(ctx, w), "backup-value"); - }); - - test("applies catch control — continue", async () => { - const ctx = makeCtx(); - const w = makeWire([{ expr: { type: "ref", ref: REF } }], { - catch: { control: { kind: "continue" } }, - }); - assert.equal(await applyCatch(ctx, w), CONTINUE_SYM); - }); - - test("applies catch control — break", async () => { - const ctx = makeCtx(); - const w = makeWire([{ expr: { type: "ref", ref: REF } }], { - catch: { control: { kind: "break" } }, - }); - assert.equal(await applyCatch(ctx, w), BREAK_SYM); - }); - - test("catch control — throw", async () => { - const ctx = makeCtx(); - const w = makeWire([{ expr: { type: "ref", ref: REF } }], { - catch: { control: { kind: "throw", message: "catch-throw" } }, - }); - await assert.rejects(() => applyCatch(ctx, w), /catch-throw/); - }); -}); diff --git a/packages/bridge-core/test/traversal-manifest-locations.test.ts b/packages/bridge-core/test/traversal-manifest-locations.test.ts index af963c41..586e4550 100644 --- a/packages/bridge-core/test/traversal-manifest-locations.test.ts +++ b/packages/bridge-core/test/traversal-manifest-locations.test.ts @@ -7,7 +7,7 @@ import { type Expression, type SourceLocation, type TraversalEntry, - type Wire, + type Statement, } from "../src/index.ts"; import { bridge } from "@stackables/bridge-core"; @@ -28,14 +28,6 @@ function assertLoc( assert.deepEqual(entry.loc, expected); } -function isPullWire(wire: Wire): boolean { - return wire.sources.length >= 1 && wire.sources[0]!.expr.type === "ref"; -} - -function isTernaryWire(wire: Wire): boolean { - return wire.sources.length >= 1 && wire.sources[0]!.expr.type === "ternary"; -} - describe("buildTraversalManifest source locations", () => { it("maps pull, fallback, and catch entries to granular source spans", () => { const instr = getBridge(bridge` @@ -48,43 +40,43 @@ describe("buildTraversalManifest source locations", () => { } `); - const pullWires = instr.wires.filter(isPullWire); - const aliasWire = pullWires.find((wire) => wire.to.field === "clean"); - const messageWire = pullWires.find( - (wire) => wire.to.path.join(".") === "message", + assert.ok(instr.body, "bridge should have body"); + const aliasStmt = instr.body!.find( + (s): s is Extract => + s.kind === "alias" && s.name === "clean", + ); + const messageStmt = instr.body!.find( + (s): s is Extract => + s.kind === "wire" && s.target.path.join(".") === "message", ); - assert.ok(aliasWire); - assert.ok(messageWire); + assert.ok(aliasStmt); + assert.ok(messageStmt); const manifest = buildTraversalManifest(instr); - const msgExpr = messageWire.sources[0]!.expr as Extract< - Expression, - { type: "ref" } - >; + + // Primary entries now use the full wire/alias statement loc (chainLoc), + // not the narrower RHS expression loc, so inactive primaries gray the + // whole wire line consistently regardless of expression type. assertLoc( manifest.find((entry) => entry.id === "message/primary"), - msgExpr.refLoc, + messageStmt.loc, ); assertLoc( manifest.find((entry) => entry.id === "message/fallback:0"), - messageWire.sources[1]?.loc, + messageStmt.sources[1]?.loc, ); assertLoc( manifest.find((entry) => entry.id === "message/catch"), - messageWire.catch?.loc, + messageStmt.catch?.loc, ); - const aliasExpr = aliasWire.sources[0]!.expr as Extract< - Expression, - { type: "ref" } - >; assertLoc( manifest.find((entry) => entry.id === "clean/primary"), - aliasExpr.refLoc, + aliasStmt.loc, ); assertLoc( manifest.find((entry) => entry.id === "clean/catch"), - aliasWire.catch?.loc, + aliasStmt.catch?.loc, ); }); @@ -98,25 +90,32 @@ describe("buildTraversalManifest source locations", () => { } `); - const ternaryWire = instr.wires.find(isTernaryWire); - assert.ok(ternaryWire); + assert.ok(instr.body, "bridge should have body"); + const nameStmt = instr.body!.find( + (s): s is Extract => + s.kind === "wire" && s.target.path.join(".") === "name", + ); + assert.ok(nameStmt); - const ternaryExpr = ternaryWire.sources[0]!.expr as Extract< + const ternaryExpr = nameStmt.sources[0]!.expr as Extract< Expression, { type: "ternary" } >; + assert.equal(ternaryExpr.type, "ternary"); + const manifest = buildTraversalManifest(instr); + // Body ternary: thenLoc/elseLoc may not be set, so we fall back to branch expr.loc assertLoc( manifest.find((entry) => entry.id === "name/then"), - ternaryExpr.thenLoc, + ternaryExpr.thenLoc ?? ternaryExpr.then.loc, ); assertLoc( manifest.find((entry) => entry.id === "name/else"), - ternaryExpr.elseLoc, + ternaryExpr.elseLoc ?? ternaryExpr.else.loc, ); }); - it("maps constant entries to the wire span", () => { + it("maps constant entries to the statement span", () => { const instr = getBridge(bridge` version 1.5 bridge Query.test { @@ -125,10 +124,17 @@ describe("buildTraversalManifest source locations", () => { } `); + assert.ok(instr.body, "bridge should have body"); + const nameStmt = instr.body!.find( + (s): s is Extract => + s.kind === "wire" && s.target.path.join(".") === "name", + ); + assert.ok(nameStmt); + const manifest = buildTraversalManifest(instr); assertLoc( manifest.find((entry) => entry.id === "name/const"), - instr.wires[0]?.loc, + (nameStmt as any).loc, ); }); }); diff --git a/packages/bridge-graphql/src/bridge-asserts.ts b/packages/bridge-graphql/src/bridge-asserts.ts index f97c9f39..f631508b 100644 --- a/packages/bridge-graphql/src/bridge-asserts.ts +++ b/packages/bridge-graphql/src/bridge-asserts.ts @@ -1,4 +1,4 @@ -import type { Bridge } from "@stackables/bridge-core"; +import type { Bridge, Statement, Expression } from "@stackables/bridge-core"; /** * Thrown when a bridge operation cannot be executed correctly using the @@ -22,6 +22,143 @@ export class BridgeGraphQLIncompatibleError extends Error { } } +/** + * Check whether an expression tree contains break/continue control flow. + */ +function exprHasLoopControl(expr: Expression): boolean { + switch (expr.type) { + case "control": + return expr.control.kind === "break" || expr.control.kind === "continue"; + case "ternary": + return ( + exprHasLoopControl(expr.cond) || + exprHasLoopControl(expr.then) || + exprHasLoopControl(expr.else) + ); + case "and": + case "or": + case "binary": + return exprHasLoopControl(expr.left) || exprHasLoopControl(expr.right); + case "unary": + return exprHasLoopControl(expr.operand); + case "pipe": + return exprHasLoopControl(expr.source); + case "concat": + return expr.parts.some(exprHasLoopControl); + case "array": + return exprHasLoopControl(expr.source); + default: + return false; + } +} + +/** + * Walk statements inside an array body to find break/continue in + * element sub-field wires. Returns the offending path or undefined. + */ +function findLoopControlInArrayBody(body: Statement[]): string | undefined { + for (const stmt of body) { + switch (stmt.kind) { + case "wire": { + const hasControl = + stmt.sources.some((s) => exprHasLoopControl(s.expr)) || + (stmt.catch && + "control" in stmt.catch && + (stmt.catch.control.kind === "break" || + stmt.catch.control.kind === "continue")); + if (hasControl) { + return stmt.target.path.join("."); + } + break; + } + case "alias": { + const hasControl = stmt.sources.some((s) => exprHasLoopControl(s.expr)); + if (hasControl) return stmt.name; + break; + } + case "scope": { + const found = findLoopControlInArrayBody(stmt.body); + if (found) return found; + break; + } + } + } + return undefined; +} + +/** + * Walk a statement tree and check for break/continue inside array element + * sub-field wires (which are incompatible with field-by-field GraphQL). + */ +function checkBodyForArrayLoopControl( + statements: Statement[], + op: string, +): void { + for (const stmt of statements) { + switch (stmt.kind) { + case "wire": { + // Check for array expressions in sources + for (const source of stmt.sources) { + checkExprForArrayLoopControl(source.expr, op); + } + break; + } + case "alias": { + for (const source of stmt.sources) { + checkExprForArrayLoopControl(source.expr, op); + } + break; + } + case "scope": + checkBodyForArrayLoopControl(stmt.body, op); + break; + } + } +} + +function checkExprForArrayLoopControl(expr: Expression, op: string): void { + switch (expr.type) { + case "array": { + const path = findLoopControlInArrayBody(expr.body); + if (path !== undefined) { + throw new BridgeGraphQLIncompatibleError( + op, + `[bridge] ${op}: 'break' / 'continue' inside an array element ` + + `sub-field (path: ${path}) is not supported in field-by-field ` + + `GraphQL execution.`, + ); + } + // Recurse into the array body for nested arrays + checkBodyForArrayLoopControl(expr.body, op); + // Recurse into the array source expression + checkExprForArrayLoopControl(expr.source, op); + break; + } + case "ternary": + checkExprForArrayLoopControl(expr.cond, op); + checkExprForArrayLoopControl(expr.then, op); + checkExprForArrayLoopControl(expr.else, op); + break; + case "and": + case "or": + case "binary": + checkExprForArrayLoopControl(expr.left, op); + checkExprForArrayLoopControl(expr.right, op); + break; + case "unary": + checkExprForArrayLoopControl(expr.operand, op); + break; + case "pipe": + checkExprForArrayLoopControl(expr.source, op); + break; + case "concat": + for (const part of expr.parts) { + checkExprForArrayLoopControl(part, op); + } + break; + } +} + /** * Assert that a bridge operation is compatible with field-by-field GraphQL * execution. Throws {@link BridgeGraphQLIncompatibleError} for each detected @@ -37,49 +174,9 @@ export class BridgeGraphQLIncompatibleError extends Error { * resolves array elements field-by-field through independent resolver * callbacks. A control-flow signal emitted from a sub-field resolver * cannot remove or skip the already-committed parent array element. - * Standalone mode uses `materializeShadows` which handles these correctly. + * Standalone mode handles these correctly. */ export function assertBridgeGraphQLCompatible(bridge: Bridge): void { const op = `${bridge.type}.${bridge.field}`; - const arrayPaths = new Set(Object.keys(bridge.arrayIterators ?? {})); - - for (const wire of bridge.wires) { - // Check if this wire targets a sub-field inside an array element. - // Array iterators map output-path prefixes (e.g. "list" for o.list, - // "" for root o) to their iterator variable. A wire whose to.path - // starts with one of those prefixes + at least one more segment is - // an element sub-field wire. - const toPath = wire.to.path; - const isElementSubfield = - (arrayPaths.has("") && toPath.length >= 1) || - toPath.some( - (_, i) => i > 0 && arrayPaths.has(toPath.slice(0, i).join(".")), - ); - - if (!isElementSubfield) continue; - - // Check sources for break/continue control flow in fallback gates - const hasControlFlowInSources = wire.sources.some( - (s) => - s.expr.type === "control" && - (s.expr.control.kind === "break" || s.expr.control.kind === "continue"), - ); - - // Check catch handler for break/continue control flow - const catchHasControlFlow = - wire.catch && - "control" in wire.catch && - (wire.catch.control.kind === "break" || - wire.catch.control.kind === "continue"); - - if (hasControlFlowInSources || catchHasControlFlow) { - const path = wire.to.path.join("."); - throw new BridgeGraphQLIncompatibleError( - op, - `[bridge] ${op}: 'break' / 'continue' inside an array element ` + - `sub-field (path: ${path}) is not supported in field-by-field ` + - `GraphQL execution.`, - ); - } - } + checkBodyForArrayLoopControl(bridge.body, op); } diff --git a/packages/bridge-graphql/src/bridge-transform.ts b/packages/bridge-graphql/src/bridge-transform.ts index cea83285..9698a3c0 100644 --- a/packages/bridge-graphql/src/bridge-transform.ts +++ b/packages/bridge-graphql/src/bridge-transform.ts @@ -1,23 +1,18 @@ import { MapperKind, mapSchema } from "@graphql-tools/utils"; import { - GraphQLList, - GraphQLNonNull, type GraphQLSchema, type GraphQLResolveInfo, type SelectionNode, Kind, defaultFieldResolver, getNamedType, - isScalarType, + isObjectType, } from "graphql"; import { - ExecutionTree, - TraceCollector, executeBridge as executeBridgeDefault, formatBridgeError, resolveStd, checkHandleVersions, - isLoopControlSignal, type Logger, type ToolTrace, type TraceLevel, @@ -29,11 +24,6 @@ import { STD_VERSION as BUNDLED_STD_VERSION, } from "@stackables/bridge-stdlib"; import type { Bridge, BridgeDocument, ToolMap } from "@stackables/bridge-core"; -import { SELF_MODULE } from "@stackables/bridge-core"; -import { - assertBridgeGraphQLCompatible, - BridgeGraphQLIncompatibleError, -} from "./bridge-asserts.ts"; export type { Logger }; export { BridgeGraphQLIncompatibleError } from "./bridge-asserts.ts"; @@ -70,6 +60,21 @@ function collectRequestedFields(info: GraphQLResolveInfo): string[] { return paths; } +/** + * Recursively scan a value for Error Sentinel objects planted by the engine. + * Returns the first Error found, or null if none. + */ +function findErrorSentinel(data: unknown): Error | null { + if (data instanceof Error) return data; + if (data != null && typeof data === "object" && !Array.isArray(data)) { + for (const v of Object.values(data)) { + const found = findErrorSentinel(v); + if (found) return found; + } + } + return null; +} + const noop = () => {}; const defaultLogger: Logger = { debug: noop, @@ -131,12 +136,7 @@ export type BridgeOptions = { */ signalMapper?: (context: any) => AbortSignal | undefined; /** - * Override the standalone execution function. - * - * When provided, **all** bridge operations are executed through this function - * instead of the field-by-field GraphQL resolver. Operations that are - * incompatible with GraphQL execution (e.g. nested multilevel `break` / - * `continue`) also use this function as an automatic fallback. + * Override the execution function. * * This allows plugging in the AOT compiler as the execution engine: * ```ts @@ -148,6 +148,16 @@ export type BridgeOptions = { executeBridge?: ( options: ExecuteBridgeOptions, ) => Promise; + /** + * Enable partial success (Error Sentinels). + * + * When `true`, non-fatal errors on individual output fields are delivered as + * per-field GraphQL errors while sibling fields still resolve successfully. + * The affected field becomes `null` and an entry appears in the `errors` array. + * + * When `false` (default), any error causes the entire root field to fail. + */ + partialSuccess?: boolean; }; /** Document can be a static BridgeDocument or a function that selects per-request */ @@ -165,358 +175,121 @@ export function bridgeTransform( const traceLevel = options?.trace ?? "off"; const logger = options?.logger ?? defaultLogger; const executeBridgeFn = options?.executeBridge ?? executeBridgeDefault; - // When an explicit executeBridge is provided, all operations use standalone mode. - const forceStandalone = !!options?.executeBridge; - - // Cache for standalone-op detection on dynamic documents (keyed by doc instance). - const standaloneOpsCache = new WeakMap>(); + const partialSuccess = options?.partialSuccess ?? false; + + // Detect actual root type names from the schema (handles custom root types + // like `schema { query: Chained }` in addition to the standard names). + const rootTypeNames = new Set( + [ + schema.getQueryType()?.name, + schema.getMutationType()?.name, + schema.getSubscriptionType()?.name, + ].filter((n): n is string => n != null), + ); return mapSchema(schema, { [MapperKind.OBJECT_FIELD]: (fieldConfig, fieldName, typeName) => { - let array = false; - if (fieldConfig.type instanceof GraphQLNonNull) { - if (fieldConfig.type.ofType instanceof GraphQLList) { - array = true; - } - } - if (fieldConfig.type instanceof GraphQLList) { - array = true; - } - - // Detect scalar return types (e.g. JSON, JSONObject) — GraphQL won't - // call sub-field resolvers for scalars, so the engine must eagerly - // materialise the full output object instead of returning itself. - const scalar = isScalarType(getNamedType(fieldConfig.type)); - - const trunk = { module: SELF_MODULE, type: typeName, field: fieldName }; const { resolve = defaultFieldResolver } = fieldConfig; - - // For static documents (or forceStandalone), the standalone decision is fully - // known at setup time — precompute it as a plain boolean so the resolver just - // reads a variable. For dynamic documents (document is a function) the actual - // doc instance isn't available until request time; detectForDynamic() handles - // that path with a per-doc-instance WeakMap cache. - function precomputeStandalone() { - if (forceStandalone) return true; - if (typeof document === "function") return null; // deferred to request time - const bridge = document.instructions.find( - (i) => - i.kind === "bridge" && - (i as Bridge).type === typeName && - (i as Bridge).field === fieldName, - ) as Bridge | undefined; - if (!bridge) return false; - try { - assertBridgeGraphQLCompatible(bridge); - return false; - } catch (e) { - if (e instanceof BridgeGraphQLIncompatibleError) { - logger.warn?.( - `${e.message} ` + - `Falling back to standalone execution mode. ` + - `In standalone mode errors affect the entire field result ` + - `rather than individual sub-fields.`, - ); - return true; - } - throw e; - } - } - - // Only used for dynamic documents (standalonePrecomputed === null). - function detectForDynamic(doc: BridgeDocument): boolean { - let ops = standaloneOpsCache.get(doc); - if (!ops) { - ops = new Set(); - for (const instr of doc.instructions) { - if (instr.kind !== "bridge") continue; - try { - assertBridgeGraphQLCompatible(instr as Bridge); - } catch (e) { - if (e instanceof BridgeGraphQLIncompatibleError) { - ops.add(e.operation); - logger.warn?.( - `${e.message} ` + - `Falling back to standalone execution mode. ` + - `In standalone mode errors affect the entire field result ` + - `rather than individual sub-fields.`, - ); - } else { - throw e; - } - } - } - standaloneOpsCache.set(doc, ops); - } - return ops.has(`${typeName}.${fieldName}`); - } - - // Standalone execution: runs the full bridge through executeBridge and - // returns the resolved data directly. GraphQL sub-field resolvers receive - // plain objects and fall through to the default field resolver. - // All errors surface as a single top-level field error rather than - // per-sub-field GraphQL errors. - async function resolveAsStandalone( - activeDoc: BridgeDocument, - bridgeContext: Record, - args: Record, - context: any, - info: GraphQLResolveInfo, - ): Promise { - const requestedFields = collectRequestedFields(info); - const signal = options?.signalMapper?.(context); - try { - const { data, traces } = await executeBridgeFn({ - document: activeDoc, - operation: `${typeName}.${fieldName}`, - input: args, - context: bridgeContext, - tools: userTools, - ...(traceLevel !== "off" ? { trace: traceLevel } : {}), - logger, - ...(signal ? { signal } : {}), - ...(options?.toolTimeoutMs !== undefined - ? { toolTimeoutMs: options.toolTimeoutMs } - : {}), - ...(options?.maxDepth !== undefined - ? { maxDepth: options.maxDepth } - : {}), - ...(requestedFields.length > 0 ? { requestedFields } : {}), - }); - if (traceLevel !== "off") { - context.__bridgeTracer = { traces }; - } - return data; - } catch (err) { - throw new Error( - formatBridgeError(err, { - source: activeDoc.source, - filename: activeDoc.filename, - }), - { cause: err }, - ); - } - } - - const standalonePrecomputed = precomputeStandalone(); + const isRoot = rootTypeNames.has(typeName); return { ...fieldConfig, - resolve: async function ( - source: ExecutionTree | undefined, - args, - context: any, - info, - ) { - // Start execution tree at query/mutation root - if (!source && !info.path.prev) { - const activeDoc = - typeof document === "function" ? document(context) : document; - - // Resolve which std to use: bundled, or a versioned namespace from tools - const { namespace: activeStd, version: activeStdVersion } = - resolveStd( - activeDoc.version, - bundledStd, - BUNDLED_STD_VERSION, - userTools, - ); - - // std is always included; user tools merge on top (shallow) - // internal tools are injected automatically by ExecutionTree - const allTools: ToolMap = { - std: activeStd, - ...userTools, - }; - - // Verify all @version-tagged handles can be satisfied - checkHandleVersions( - activeDoc.instructions, - allTools, - activeStdVersion, - ); - - // Only intercept fields that have a matching bridge instruction. - // Fields without one fall through to their original resolver, - // allowing hand-coded resolvers to coexist with bridge-powered ones. - const hasBridge = activeDoc.instructions.some( - (i) => - i.kind === "bridge" && - i.type === typeName && - i.field === fieldName, - ); - if (!hasBridge) { - return resolve(source, args, context, info); - } - - const bridgeContext = contextMapper - ? contextMapper(context) - : (context ?? {}); - - // Standalone execution path — used when the operation is incompatible - // with field-by-field GraphQL resolution, or when an explicit - // executeBridge override has been provided. - if (standalonePrecomputed ?? detectForDynamic(activeDoc)) { - return resolveAsStandalone( - activeDoc, - bridgeContext, - args ?? {}, - context, - info, - ); - } - - // GraphQL field-by-field execution path via ExecutionTree. - source = new ExecutionTree( - trunk, - activeDoc, - allTools, - bridgeContext, - ); - - source.logger = logger; - source.source = activeDoc.source; - source.filename = activeDoc.filename; - if ( - options?.toolTimeoutMs !== undefined && - Number.isFinite(options.toolTimeoutMs) && - options.toolTimeoutMs >= 0 - ) { - source.toolTimeoutMs = Math.floor(options.toolTimeoutMs); - } - if ( - options?.maxDepth !== undefined && - Number.isFinite(options.maxDepth) && - options.maxDepth >= 0 - ) { - source.maxDepth = Math.floor(options.maxDepth); - } - - const signal = options?.signalMapper?.(context); - if (signal) { - source.signal = signal; - } - - if (traceLevel !== "off") { - source.tracer = new TraceCollector(traceLevel); - // Stash tracer on GQL context so the tracing plugin can read it - context.__bridgeTracer = source.tracer; - } - } - - if ( - source instanceof ExecutionTree && - args && - Object.keys(args).length > 0 - ) { - source.push(args); + resolve: async function (source, args, context: any, info) { + // Sub-field: intercept Error Sentinels planted by the bridge engine + // (only active when partialSuccess is enabled — sentinels are only + // planted when the engine was called with partialSuccess: true) + if (partialSuccess && source !== undefined) { + const value = (source as Record)[info.fieldName]; + if (value instanceof Error) throw value; } - // Kick off forced handles (force ) at the root entry point - if (source instanceof ExecutionTree && !info.path.prev) { - // Ensure input state exists even with no args (prevents - // recursive scheduling of the input trunk → stack overflow). - if (!args || Object.keys(args).length === 0) { - source.push({}); - } - const criticalForces = source.executeForced(); - if (criticalForces.length > 0) { - source.setForcedExecution( - Promise.all(criticalForces).then(() => {}), - ); - } + // Non-root fields: delegate to the original resolver so that + // hand-coded sub-field resolvers are preserved and not overwritten. + if (!isRoot || source !== undefined) { + return resolve(source, args, context, info); } - if (source instanceof ExecutionTree) { - let result; - try { - result = await source.response(info.path, array, scalar); - } catch (err) { - throw new Error( - formatBridgeError(err, { - source: source.source, - filename: source.filename, - }), - { cause: err }, - ); - } - - // Safety net: loop control signals (break/continue) must never - // reach GraphQL resolvers. Normally, bridges that use - // break/continue inside array element sub-fields fall back to - // standalone mode (via assertBridgeGraphQLCompatible), but if - // a signal leaks through, coerce it to null rather than - // crashing GraphQL serialisation with a Symbol value. - if (isLoopControlSignal(result)) { - result = null; - } + // Root bridge field: run holistic standalone execution + const activeDoc = + typeof document === "function" ? document(context) : document; + + // Only intercept fields that have a matching bridge instruction. + // Fields without one fall through to the original resolver. + const hasBridge = activeDoc.instructions.some( + (i) => + i.kind === "bridge" && + (i as Bridge).type === typeName && + (i as Bridge).field === fieldName, + ); + if (!hasBridge) return resolve(source, args, context, info); + + const { namespace: activeStd, version: activeStdVersion } = + resolveStd( + activeDoc.version, + bundledStd, + BUNDLED_STD_VERSION, + userTools, + ); + const allTools: ToolMap = { std: activeStd, ...userTools }; + checkHandleVersions( + activeDoc.instructions, + allTools, + activeStdVersion, + ); - // Scalar return types (JSON, JSONObject, etc.) won't trigger - // sub-field resolvers, so if response() deferred resolution by - // returning the tree itself, eagerly materialise the output. - if (scalar) { - if (result instanceof ExecutionTree) { - try { - const data = result.collectOutput(); - const forced = result.getForcedExecution(); - if (forced) await forced; - return data; - } catch (err) { - throw new Error( - formatBridgeError(err, { - source: result.source, - filename: result.filename, - }), - { cause: err }, - ); - } - } - if (Array.isArray(result) && result[0] instanceof ExecutionTree) { - try { - const firstTree = result[0] as ExecutionTree; - const forced = firstTree.getForcedExecution(); - const collected = await Promise.all( - result.map((shadow: ExecutionTree) => - shadow.collectOutput(), - ), - ); - if (forced) await forced; - return collected; - } catch (err) { - throw new Error( - formatBridgeError(err, { - source: source.source, - filename: source.filename, - }), - { cause: err }, - ); - } + const bridgeContext = contextMapper + ? contextMapper(context) + : (context ?? {}); + const requestedFields = collectRequestedFields(info); + const signal = options?.signalMapper?.(context); + + try { + const { data, traces } = await executeBridgeFn({ + document: activeDoc, + operation: `${typeName}.${fieldName}`, + input: args ?? {}, + context: bridgeContext, + tools: userTools, + ...(traceLevel !== "off" ? { trace: traceLevel } : {}), + logger, + ...(signal ? { signal } : {}), + ...(options?.toolTimeoutMs !== undefined + ? { toolTimeoutMs: options.toolTimeoutMs } + : {}), + ...(options?.maxDepth !== undefined + ? { maxDepth: options.maxDepth } + : {}), + ...(requestedFields.length > 0 ? { requestedFields } : {}), + partialSuccess, + }); + if (traceLevel !== "off") context.__bridgeTracer = { traces }; + // When partialSuccess is enabled and the return type is a scalar + // (e.g. JSONObject fallback), sub-field resolvers won't fire to + // re-throw Error Sentinels. Scan the data and surface the first + // one found as a root-field error so it reaches result.errors. + if (partialSuccess) { + const namedReturnType = getNamedType(info.returnType); + if (!isObjectType(namedReturnType)) { + const sentinel = findErrorSentinel(data); + if (sentinel) throw sentinel; } } - - // At the leaf level (not root), race data pull with critical - // force promises so errors propagate into GraphQL `errors[]` - // while still allowing parallel execution. - if (info.path.prev && source.getForcedExecution()) { - try { - return await Promise.all([ - result, - source.getForcedExecution(), - ]).then(([data]) => data); - } catch (err) { - throw new Error( - formatBridgeError(err, { - source: source.source, - filename: source.filename, - }), - { cause: err }, - ); - } + return data; + } catch (err) { + // Capture traces from the error before rethrowing so tracing + // plugins can still read them even when execution fails. + if (traceLevel !== "off") { + const errTraces = (err as { traces?: ToolTrace[] })?.traces; + if (errTraces) context.__bridgeTracer = { traces: errTraces }; } - return result; + throw new Error( + formatBridgeError(err, { + source: activeDoc.source, + filename: activeDoc.filename, + }), + { cause: err }, + ); } - - return resolve(source, args, context, info); }, }; }, @@ -529,7 +302,10 @@ export function bridgeTransform( * disabled or no traces were recorded. */ export function getBridgeTraces(context: any): ToolTrace[] { - return (context?.__bridgeTracer as TraceCollector | undefined)?.traces ?? []; + return ( + (context?.__bridgeTracer as { traces: ToolTrace[] } | undefined)?.traces ?? + [] + ); } /** diff --git a/packages/bridge-graphql/test/executeGraph.test.ts b/packages/bridge-graphql/test/executeGraph.test.ts index a390c028..ec08e2b7 100644 --- a/packages/bridge-graphql/test/executeGraph.test.ts +++ b/packages/bridge-graphql/test/executeGraph.test.ts @@ -720,32 +720,12 @@ describe("executeGraph: multilevel break/continue in nested arrays", () => { { name: "Autumn", items: [{ sku: "A4", price: 20.0 }] }, ]; - test("falls back to standalone execution mode with a warning", async () => { - const warnings: string[] = []; - const mockLogger = { - debug: () => {}, - info: () => {}, - warn: (msg: string) => warnings.push(msg), - error: () => {}, - }; - + test("uses standalone execution mode for multilevel break/continue", async () => { const instructions = parseBridge(catalogBridge); - // Must NOT throw at setup time — fallback mode is used instead const gateway = createGateway(catalogTypeDefs, instructions, { - logger: mockLogger, context: { catalog }, }); - // Warning must be logged at setup time - assert.ok( - warnings.some((w) => w.includes("Query.processCatalog")), - `Expected a warning about Query.processCatalog, got: ${JSON.stringify(warnings)}`, - ); - assert.ok( - warnings.some((w) => w.includes("standalone")), - `Expected warning to mention standalone mode, got: ${JSON.stringify(warnings)}`, - ); - const executor = buildHTTPExecutor({ fetch: gateway.fetch as any }); const result: any = await executor({ document: parse(`{ diff --git a/packages/bridge-graphql/test/logging.test.ts b/packages/bridge-graphql/test/logging.test.ts index d9f5ecd2..73a4e7bf 100644 --- a/packages/bridge-graphql/test/logging.test.ts +++ b/packages/bridge-graphql/test/logging.test.ts @@ -121,9 +121,8 @@ describe("logging: basics", () => { assert.equal(result.data.lookup.label, "X"); }); - test("logger.warn is called when accessing a named field on an array result", async () => { + test("accessing a named field on an array result does not warn", async () => { // Bridge accesses .firstName on items[] (an array) without using array mapping. - // This should trigger the array-access warning path. const arrayBridge = bridge` version 1.5 bridge Query.lookup { @@ -154,9 +153,11 @@ describe("logging: basics", () => { const executor = buildHTTPExecutor({ fetch: yoga.fetch as any }); await executor({ document: parse(`{ lookup(q: "x") { label } }`) }); - assert.ok( - warnMessages.some((m) => m.includes("firstName") && m.includes("array")), - `expected a warn message about array field access, got: ${JSON.stringify(warnMessages)}`, + // Standalone mode does not emit compatibility warnings + assert.equal( + warnMessages.length, + 0, + `expected no warn messages, got: ${JSON.stringify(warnMessages)}`, ); }); }); diff --git a/packages/bridge-parser/src/bridge-format.ts b/packages/bridge-parser/src/bridge-format.ts index a21e0376..6e307441 100644 --- a/packages/bridge-parser/src/bridge-format.ts +++ b/packages/bridge-parser/src/bridge-format.ts @@ -5,9 +5,12 @@ import type { ControlFlowInstruction, DefineDef, Expression, + HandleBinding, NodeRef, + SourceChain, + Statement, ToolDef, - Wire, + WireCatch, } from "@stackables/bridge-core"; import { SELF_MODULE } from "@stackables/bridge-core"; import { @@ -16,31 +19,6 @@ import { } from "./parser/index.ts"; export { parsePath } from "@stackables/bridge-core"; -// ── Wire shape helpers ────────────────────────────────────────────── -type RefExpr = Extract; -type LitExpr = Extract; -type TernExpr = Extract; -type AndOrExpr = - | Extract - | Extract; - -const isPull = (w: Wire): boolean => w.sources[0]?.expr.type === "ref"; -const isLit = (w: Wire): boolean => w.sources[0]?.expr.type === "literal"; -const isTern = (w: Wire): boolean => w.sources[0]?.expr.type === "ternary"; -const isAndW = (w: Wire): boolean => w.sources[0]?.expr.type === "and"; -const isOrW = (w: Wire): boolean => w.sources[0]?.expr.type === "or"; - -const wRef = (w: Wire): NodeRef => (w.sources[0].expr as RefExpr).ref; -const wVal = (w: Wire): string => (w.sources[0].expr as LitExpr).value; -const wSafe = (w: Wire): true | undefined => { - const e = w.sources[0].expr; - return e.type === "ref" ? e.safe : undefined; -}; -const wTern = (w: Wire): TernExpr => w.sources[0].expr as TernExpr; -const wAndOr = (w: Wire): AndOrExpr => w.sources[0].expr as AndOrExpr; -const eRef = (e: Expression): NodeRef => (e as RefExpr).ref; -const eVal = (e: Expression): string => (e as LitExpr).value; - /** * Parse .bridge text — delegates to the Chevrotain parser. */ @@ -53,37 +31,6 @@ export function parseBridge( const BRIDGE_VERSION = "1.5"; -const RESERVED_BARE_VALUE_KEYWORDS = new Set([ - // Declaration keywords - "version", - "bridge", - "tool", - "define", - "with", - "input", - "output", - "context", - "const", - "from", - "as", - "alias", - "on", - "error", - "force", - "catch", - // Control flow - "continue", - "break", - "throw", - "panic", - "if", - "pipe", - // Boolean/logic operators - "and", - "or", - "not", -]); - /** Serialize a ControlFlowInstruction to its textual form. */ function serializeControl(ctrl: ControlFlowInstruction): string { if (ctrl.kind === "throw") return `throw ${JSON.stringify(ctrl.message)}`; @@ -96,2580 +43,679 @@ function serializeControl(ctrl: ControlFlowInstruction): string { return ctrl.levels && ctrl.levels > 1 ? `break ${ctrl.levels}` : "break"; } +// ── Body-based serializer (Statement[] IR) ─────────────────────────────────── + +const BINARY_OP_SYMBOL: Record = { + add: "+", + sub: "-", + mul: "*", + div: "/", + eq: "==", + neq: "!=", + gt: ">", + gte: ">=", + lt: "<", + lte: "<=", +}; +const BINARY_OP_PREC: Record = { + "*": 4, + "/": 4, + "+": 3, + "-": 3, + "==": 2, + "!=": 2, + ">": 2, + ">=": 2, + "<": 2, + "<=": 2, + and: 1, + or: 0, +}; + /** - * Serialize fallback entries (sources after the first) as `|| val` / `?? val`. - * `refFn` renders NodeRef→string; `valFn` renders literal value→string. + * Context for the body-based serializer. Carries handle bindings collected + * from WithStatements so that NodeRef can be resolved back to user-facing names. */ -function serFallbacks( - w: Wire, - refFn: (ref: NodeRef) => string, - valFn: (v: string) => string = (v) => v, -): string { - if (w.sources.length <= 1) return ""; - return w.sources - .slice(1) - .map((s) => { - const op = s.gate === "nullish" ? "??" : "||"; - const e = s.expr; - if (e.type === "control") return ` ${op} ${serializeControl(e.control)}`; - if (e.type === "ref") return ` ${op} ${refFn(e.ref)}`; - if (e.type === "literal") return ` ${op} ${valFn(e.value)}`; - return ""; - }) - .join(""); -} - -/** Serialize catch handler as ` catch `. */ -function serCatch( - w: Wire, - refFn: (ref: NodeRef) => string, - valFn: (v: string) => string = (v) => v, -): string { - if (!w.catch) return ""; - if ("control" in w.catch) - return ` catch ${serializeControl(w.catch.control)}`; - if ("ref" in w.catch) return ` catch ${refFn(w.catch.ref)}`; - return ` catch ${valFn(w.catch.value)}`; +interface BodySerContext { + /** Bridge or define type+field for matching self-module refs */ + type: string; + field: string; + /** Handle map: trunk key → handle alias */ + handleMap: Map; + /** Input handle alias (e.g. "i") */ + inputHandle?: string; + /** Output handle alias (e.g. "o") */ + outputHandle?: string; + /** Current element iterator name (inside array body) */ + iteratorName?: string; + /** Stack of iterator names for nested arrays (innermost last) */ + iteratorStack: string[]; } -// ── Serializer ─────────────────────────────────────────────────────────────── - -export function serializeBridge(doc: BridgeDocument): string { - const version = doc.version ?? BRIDGE_VERSION; - const { instructions } = doc; - if (instructions.length === 0) return ""; - - const blocks: string[] = []; - - // Group consecutive const declarations into a single block - let i = 0; - while (i < instructions.length) { - const instr = instructions[i]!; - if (instr.kind === "const") { - const constLines: string[] = []; - while (i < instructions.length && instructions[i]!.kind === "const") { - const c = instructions[i] as ConstDef; - constLines.push(`const ${c.name} = ${c.value}`); - i++; +function buildBodySerContext( + type: string, + field: string, + handles: HandleBinding[], +): BodySerContext { + const handleMap = new Map(); + const instanceCounters = new Map(); + let inputHandle: string | undefined; + let outputHandle: string | undefined; + for (const h of handles) { + switch (h.kind) { + case "tool": { + const lastDot = h.name.lastIndexOf("."); + if (lastDot !== -1) { + const mod = h.name.substring(0, lastDot); + const fld = h.name.substring(lastDot + 1); + const ik = `${mod}:${fld}`; + const inst = (instanceCounters.get(ik) ?? 0) + 1; + instanceCounters.set(ik, inst); + handleMap.set(`${mod}:${type}:${fld}:${inst}`, h.handle); + } else { + const ik = `Tools:${h.name}`; + const inst = (instanceCounters.get(ik) ?? 0) + 1; + instanceCounters.set(ik, inst); + handleMap.set(`${SELF_MODULE}:Tools:${h.name}:${inst}`, h.handle); + } + break; } - blocks.push(constLines.join("\n")); - } else if (instr.kind === "tool") { - blocks.push(serializeToolBlock(instr as ToolDef)); - i++; - } else if (instr.kind === "define") { - blocks.push(serializeDefineBlock(instr as DefineDef)); - i++; - } else { - blocks.push(serializeBridgeBlock(instr as Bridge)); - i++; + case "input": + inputHandle = h.handle; + break; + case "output": + outputHandle = h.handle; + break; + case "context": + handleMap.set(`${SELF_MODULE}:Context:context`, h.handle); + break; + case "const": + handleMap.set(`${SELF_MODULE}:Const:const`, h.handle); + break; + case "define": + handleMap.set(`__define_${h.handle}:${type}:${field}`, h.handle); + handleMap.set(`__define_in_${h.handle}:${type}:${field}`, h.handle); + handleMap.set(`__define_out_${h.handle}:${type}:${field}`, h.handle); + break; } } - - return `version ${version}\n\n` + blocks.join("\n\n") + "\n"; + return { + type, + field, + handleMap, + inputHandle, + outputHandle, + iteratorStack: [], + }; } /** - * Whether a value string needs quoting to be re-parseable as a bare value. - * Safe unquoted: number, boolean, null, /path, simple-identifier, keyword. - * Already-quoted JSON strings (produced by the updated parser) are also safe. + * Resolve a NodeRef to its user-facing handle + path string. + * `isFrom` indicates whether this ref is on the source (RHS) side of a wire. */ -function needsQuoting(v: string): boolean { - if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) return false; // JSON string literal - if (v === "true" || v === "false" || v === "null") return false; - if (/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(v)) return false; // number - if (/^\/[\w./-]+$/.test(v)) return false; // /path - if (/^[a-zA-Z_][\w-]*$/.test(v)) { - return RESERVED_BARE_VALUE_KEYWORDS.has(v); +function serBodyRef( + ref: NodeRef, + ctx: BodySerContext, + isFrom: boolean, +): string { + // Element refs use the iterator name (elementDepth selects parent iterators) + if (ref.element && ctx.iteratorName) { + const depth = (ref as any).elementDepth ?? 0; + const stack = ctx.iteratorStack; + const name = + depth > 0 && stack.length > depth + ? stack[stack.length - 1 - depth] + : ctx.iteratorName; + const p = serPath(ref.path, ref.rootSafe, ref.pathSafe); + return p ? `${name}.${p}` : name; + } + + // Alias (local) refs: type "__local" → just alias name + path + if (ref.type === "__local") { + const p = serPath(ref.path, ref.rootSafe, ref.pathSafe); + if (!p) return ref.field; + const sep = ref.rootSafe ? "?." : "."; + return `${ref.field}${sep}${p}`; } - return true; -} - -/** - * Format a bare-value string for output. - * Pre-quoted JSON strings are emitted as-is; everything else goes through - * the same quoting logic as needsQuoting. - */ -function formatBareValue(v: string): string { - if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) return v; - return needsQuoting(v) ? `"${v}"` : v; -} - -/** - * Format a value that appears as an operand in an expression context. - * Identifier-like strings must be quoted because bare identifiers in - * expressions are parsed as source references, not string literals. - */ -function formatExprValue(v: string): string { - if (/^[a-zA-Z_][\w-]*$/.test(v)) return `"${v}"`; - return formatBareValue(v); -} - -function serializeToolBlock(tool: ToolDef): string { - const toolWires: Wire[] = tool.wires; - const lines: string[] = []; - const hasBody = - tool.handles.length > 0 || toolWires.length > 0 || !!tool.onError; - // Declaration line — use `tool from ` format - const source = tool.extends ?? tool.fn; - lines.push( - hasBody - ? `tool ${tool.name} from ${source} {` - : `tool ${tool.name} from ${source}`, - ); + const hasSafe = ref.rootSafe || ref.pathSafe?.some((s) => s); + const firstSep = hasSafe && ref.rootSafe ? "?." : "."; - // Handles (context, const, tool deps) - for (const h of tool.handles) { - if (h.kind === "context") { - if (h.handle === "context") { - lines.push(` with context`); - } else { - lines.push(` with context as ${h.handle}`); - } - } else if (h.kind === "const") { - if (h.handle === "const") { - lines.push(` with const`); - } else { - lines.push(` with const as ${h.handle}`); - } - } else if (h.kind === "tool") { - const vTag = h.version ? `@${h.version}` : ""; - const memoize = h.memoize ? " memoize" : ""; - // Short form when handle == last segment of name - const lastDot = h.name.lastIndexOf("."); - const defaultHandle = - lastDot !== -1 ? h.name.substring(lastDot + 1) : h.name; - if (h.handle === defaultHandle && !vTag) { - lines.push(` with ${h.name}${memoize}`); - } else { - lines.push(` with ${h.name}${vTag} as ${h.handle}${memoize}`); - } - } + /** Join prefix + serialized path, respecting bracket indices */ + function joinHP(prefix: string, sep: string, pathStr: string): string { + if (pathStr.startsWith("[")) return prefix + pathStr; + return prefix + sep + pathStr; } - // ── Build internal-fork registries for expressions and concat ────── - const TOOL_FN_TO_OP: Record = { - multiply: "*", - divide: "/", - add: "+", - subtract: "-", - eq: "==", - neq: "!=", - gt: ">", - gte: ">=", - lt: "<", - lte: "<=", - }; - - const refTk = (ref: NodeRef): string => - ref.instance != null - ? `${ref.module}:${ref.type}:${ref.field}:${ref.instance}` - : `${ref.module}:${ref.type}:${ref.field}`; - - // Expression fork info - type ToolExprForkInfo = { - op: string; - aWire: Wire | undefined; - bWire: Wire | undefined; - }; - const exprForks = new Map(); - const exprInternalWires = new Set(); - - // Concat fork info - type ToolConcatForkInfo = { - parts: ({ kind: "text"; value: string } | { kind: "ref"; ref: NodeRef })[]; - }; - const concatForks = new Map(); - const concatInternalWires = new Set(); - - // Pipe handle keys for detecting pipe wires - const pipeHandleTrunkKeys = new Set(); - - for (const ph of tool.pipeHandles ?? []) { - pipeHandleTrunkKeys.add(ph.key); + // Bridge/define's own trunk (input/output) + const isSelfTrunk = + ref.module === SELF_MODULE && + ref.type === ctx.type && + ref.field === ctx.field && + !ref.instance && + !ref.element; - // Expression forks: __expr_N with known operator base trunk - if (ph.handle.startsWith("__expr_")) { - const op = TOOL_FN_TO_OP[ph.baseTrunk.field]; - if (!op) continue; - let aWire: Wire | undefined; - let bWire: Wire | undefined; - for (const w of toolWires) { - const wTo = w.to; - if (refTk(wTo) !== ph.key || wTo.path.length !== 1) continue; - if (wTo.path[0] === "a" && isPull(w)) aWire = w as Wire; - else if (wTo.path[0] === "b") bWire = w; - } - exprForks.set(ph.key, { op, aWire, bWire }); - if (aWire) exprInternalWires.add(aWire); - if (bWire) exprInternalWires.add(bWire); + if (isSelfTrunk) { + if (isFrom && ctx.inputHandle) { + return ref.path.length > 0 + ? joinHP( + ctx.inputHandle, + firstSep, + serPath(ref.path, ref.rootSafe, ref.pathSafe), + ) + : ctx.inputHandle; } - - // Concat forks: __concat_N with baseTrunk.field === "concat" - if (ph.handle.startsWith("__concat_") && ph.baseTrunk.field === "concat") { - const partsMap = new Map< - number, - { kind: "text"; value: string } | { kind: "ref"; ref: NodeRef } - >(); - for (const w of toolWires) { - const wTo = w.to; - if (refTk(wTo) !== ph.key) continue; - if (wTo.path.length !== 2 || wTo.path[0] !== "parts") continue; - const idx = parseInt(wTo.path[1], 10); - if (isNaN(idx)) continue; - if (isLit(w) && !isPull(w)) { - partsMap.set(idx, { kind: "text", value: wVal(w) }); - } else if (isPull(w)) { - partsMap.set(idx, { - kind: "ref", - ref: wRef(w), - }); - } - concatInternalWires.add(w); - } - const maxIdx = Math.max(...partsMap.keys(), -1); - const parts: ToolConcatForkInfo["parts"] = []; - for (let i = 0; i <= maxIdx; i++) { - const part = partsMap.get(i); - if (part) parts.push(part); - } - concatForks.set(ph.key, { parts }); + if (isFrom && !ctx.inputHandle && ctx.outputHandle) { + return ref.path.length > 0 + ? joinHP( + ctx.outputHandle, + firstSep, + serPath(ref.path, ref.rootSafe, ref.pathSafe), + ) + : ctx.outputHandle; } - } - - // Mark output wires from expression/concat forks as internal - for (const w of toolWires) { - if (!isPull(w)) continue; - const fromTk = refTk(wRef(w)); - if ( - wRef(w).path.length === 0 && - (exprForks.has(fromTk) || concatForks.has(fromTk)) - ) { - // This is the output wire from a fork to the tool's self-wire target. - // We'll emit this as the main wire with the reconstructed expression. - // Don't mark it as internal — we still process it, but with special logic. + if (!isFrom && ctx.outputHandle) { + return ref.path.length > 0 + ? joinHP(ctx.outputHandle, ".", serPath(ref.path)) + : ctx.outputHandle; } + return serPath(ref.path, ref.rootSafe, ref.pathSafe); } - /** Serialize a ref using the tool's handle map. */ - function serToolRef(ref: NodeRef): string { - return serializeToolWireSource(ref, tool); + // Lookup by trunk key + const tk = + ref.instance != null + ? `${ref.module}:${ref.type}:${ref.field}:${ref.instance}` + : `${ref.module}:${ref.type}:${ref.field}`; + const handle = ctx.handleMap.get(tk); + if (handle) { + if (ref.path.length === 0) return handle; + return joinHP( + handle, + firstSep, + serPath(ref.path, ref.rootSafe, ref.pathSafe), + ); } + return serPath(ref.path, ref.rootSafe, ref.pathSafe); +} - /** - * Recursively reconstruct an expression string from a fork chain. - * E.g. for `const.one + 1` returns "const.one + 1". - */ - function reconstructExpr(forkTk: string, parentPrec?: number): string { - const info = exprForks.get(forkTk); - if (!info) return forkTk; - - // Reconstruct left operand - let left: string; - if (info.aWire) { - const aFromTk = refTk(wRef(info.aWire!)); - if (exprForks.has(aFromTk)) { - left = reconstructExpr( - aFromTk, - TOOL_PREC[info.op as keyof typeof TOOL_PREC], - ); - } else { - left = serToolRef(wRef(info.aWire!)); - } - } else { - left = "?"; - } - - // Reconstruct right operand - let right: string; - if (info.bWire) { - if (isPull(info.bWire)) { - const bFromTk = refTk(wRef(info.bWire!)); - if (exprForks.has(bFromTk)) { - right = reconstructExpr( - bFromTk, - TOOL_PREC[info.op as keyof typeof TOOL_PREC], - ); +/** Serialize an Expression to source text. */ +function serBodyExpr( + expr: Expression, + ctx: BodySerContext, + parentPrec?: number, + indent?: string, +): string { + switch (expr.type) { + case "ref": + return serBodyRef(expr.ref, ctx, true); + case "literal": + return JSON.stringify(expr.value); + case "ternary": { + const c = serBodyExpr(expr.cond, ctx); + const t = serBodyExpr(expr.then, ctx); + const e = serBodyExpr(expr.else, ctx); + return `${c} ? ${t} : ${e}`; + } + case "and": { + const l = serBodyExpr(expr.left, ctx, BINARY_OP_PREC["and"]); + const r = serBodyExpr(expr.right, ctx, BINARY_OP_PREC["and"]); + const s = `${l} and ${r}`; + if (parentPrec != null && BINARY_OP_PREC["and"]! < parentPrec) + return `(${s})`; + return s; + } + case "or": { + const l = serBodyExpr(expr.left, ctx, BINARY_OP_PREC["or"]); + const r = serBodyExpr(expr.right, ctx, BINARY_OP_PREC["or"]); + const s = `${l} or ${r}`; + if (parentPrec != null && BINARY_OP_PREC["or"]! < parentPrec) + return `(${s})`; + return s; + } + case "control": + return serializeControl(expr.control); + case "binary": { + const sym = BINARY_OP_SYMBOL[expr.op]!; + const myPrec = BINARY_OP_PREC[sym]!; + const l = serBodyExpr(expr.left, ctx, myPrec); + const r = serBodyExpr(expr.right, ctx, myPrec); + const s = `${l} ${sym} ${r}`; + if (parentPrec != null && myPrec < parentPrec) return `(${s})`; + return s; + } + case "unary": + return `not ${serBodyExpr(expr.operand, ctx)}`; + case "concat": { + let result = ""; + for (const part of expr.parts) { + if (part.type === "literal" && typeof part.value === "string") { + result += (part.value as string) + .replace(/\\/g, "\\\\") + .replace(/\{/g, "\\{"); } else { - right = serToolRef(wRef(info.bWire!)); - } - } else if (isLit(info.bWire)) { - right = formatExprValue(wVal(info.bWire!)); - } else { - right = "?"; + result += `{${serBodyExpr(part, ctx)}}`; + } + } + return `"${result}"`; + } + case "pipe": { + const source = serBodyExpr(expr.source, ctx); + const handle = expr.path + ? `${expr.handle}.${expr.path.join(".")}` + : expr.handle; + return `${handle}:${source}`; + } + case "array": { + const source = serBodyExpr(expr.source, ctx); + const innerCtx: BodySerContext = { + ...ctx, + iteratorName: expr.iteratorName, + iteratorStack: [...ctx.iteratorStack, expr.iteratorName], + }; + // Collect with statements from the array body for handle registration + for (const s of expr.body) { + if (s.kind === "with") registerWithBinding(s.binding, innerCtx); + } + const innerIndent = (indent ?? " ") + " "; + const closingIndent = indent ?? " "; + const bodyLines = serializeBodyStatements( + expr.body, + innerCtx, + true, + innerIndent, + ); + if (bodyLines.length === 0) { + return `${source}[] as ${expr.iteratorName} {}`; } - } else { - right = "?"; + return `${source}[] as ${expr.iteratorName} {\n${bodyLines.join("\n")}\n${closingIndent}}`; } - - const expr = `${left} ${info.op} ${right}`; - const myPrec = TOOL_PREC[info.op as keyof typeof TOOL_PREC] ?? 0; - if (parentPrec != null && myPrec < parentPrec) return `(${expr})`; - return expr; - } - const TOOL_PREC: Record = { - "*": 4, - "/": 4, - "+": 3, - "-": 3, - "==": 2, - "!=": 2, - ">": 2, - ">=": 2, - "<": 2, - "<=": 2, - }; - - /** - * Reconstruct a template string from a concat fork. - */ - function reconstructTemplateStr(forkTk: string): string | null { - const info = concatForks.get(forkTk); - if (!info || info.parts.length === 0) return null; - let result = ""; - for (const part of info.parts) { - if (part.kind === "text") { - result += part.value.replace(/\\/g, "\\\\").replace(/\{/g, "\\{"); - } else { - result += `{${serToolRef(part.ref)}}`; - } + default: { + const _: never = expr; + return ``; } - return `"${result}"`; } +} - // Wires — self-wires (targeting the tool's own trunk) get `.` prefix; - // handle-targeted wires (targeting declared handles) use bare target names - for (const wire of toolWires) { - // Skip internal expression/concat wires - if (exprInternalWires.has(wire) || concatInternalWires.has(wire)) continue; - - const isSelfWire = - wire.to.module === SELF_MODULE && - wire.to.type === "Tools" && - wire.to.field === tool.name; - const prefix = isSelfWire ? "." : ""; - - // Check if this wire's source is an expression or concat fork - if (isPull(wire)) { - const fromTk = refTk(wRef(wire)); - - // Expression fork output wire - if (wRef(wire).path.length === 0 && exprForks.has(fromTk)) { - const target = wire.to.path.join("."); - const exprStr = reconstructExpr(fromTk); - // Check for ternary, coalesce, fallbacks, catch on the wire - let suffix = ""; - if (isTern(wire)) { - const tern = wTern(wire); - const trueVal = - tern.then.type === "literal" - ? formatBareValue(eVal(tern.then)) - : serToolRef(eRef(tern.then)); - const falseVal = - tern.else.type === "literal" - ? formatBareValue(eVal(tern.else)) - : serToolRef(eRef(tern.else)); - lines.push( - ` ${prefix}${target} <- ${exprStr} ? ${trueVal} : ${falseVal}`, - ); - continue; - } - suffix += serFallbacks(wire, serToolRef, formatBareValue); - suffix += serCatch(wire, serToolRef, formatBareValue); - lines.push(` ${prefix}${target} <- ${exprStr}${suffix}`); - continue; - } - - // Concat fork output wire (template string) - if ( - wRef(wire).path.length <= 1 && - concatForks.has( - wRef(wire).path.length === 0 - ? fromTk - : refTk({ ...wRef(wire), path: [] }), +/** Register a HandleBinding into the context's handle map. */ +function registerWithBinding( + binding: HandleBinding, + ctx: BodySerContext, +): void { + switch (binding.kind) { + case "tool": { + const lastDot = binding.name.lastIndexOf("."); + if (lastDot !== -1) { + const mod = binding.name.substring(0, lastDot); + const fld = binding.name.substring(lastDot + 1); + // Find next available instance + let inst = 1; + while (ctx.handleMap.has(`${mod}:${ctx.type}:${fld}:${inst}`)) inst++; + ctx.handleMap.set(`${mod}:${ctx.type}:${fld}:${inst}`, binding.handle); + } else { + let inst = 1; + while ( + ctx.handleMap.has(`${SELF_MODULE}:Tools:${binding.name}:${inst}`) ) - ) { - const concatTk = - wRef(wire).path.length === 0 - ? fromTk - : refTk({ ...wRef(wire), path: [] }); - // Only handle .value path (standard concat output) - if ( - wRef(wire).path.length === 0 || - (wRef(wire).path.length === 1 && wRef(wire).path[0] === "value") - ) { - const target = wire.to.path.join("."); - const tmpl = reconstructTemplateStr(concatTk); - if (tmpl) { - lines.push(` ${prefix}${target} <- ${tmpl}`); - continue; - } - } - } - - // Skip internal pipe wires (targeting fork inputs) - if (wire.pipe && pipeHandleTrunkKeys.has(refTk(wire.to))) { - continue; + inst++; + ctx.handleMap.set( + `${SELF_MODULE}:Tools:${binding.name}:${inst}`, + binding.handle, + ); } - } - - // Ternary wire: has `cond` (condition ref), `thenValue`/`thenRef`, `elseValue`/`elseRef` - if (isTern(wire)) { - const tern = wTern(wire); - const target = wire.to.path.join("."); - const condStr = serToolRef(eRef(tern.cond)); - const thenVal = - tern.then.type === "literal" - ? formatBareValue(eVal(tern.then)) - : serToolRef(eRef(tern.then)); - const elseVal = - tern.else.type === "literal" - ? formatBareValue(eVal(tern.else)) - : serToolRef(eRef(tern.else)); - lines.push( - ` ${prefix}${target} <- ${condStr} ? ${thenVal} : ${elseVal}`, + break; + } + case "input": + ctx.inputHandle = binding.handle; + break; + case "output": + ctx.outputHandle = binding.handle; + break; + case "context": + ctx.handleMap.set(`${SELF_MODULE}:Context:context`, binding.handle); + break; + case "const": + ctx.handleMap.set(`${SELF_MODULE}:Const:const`, binding.handle); + break; + case "define": + ctx.handleMap.set( + `__define_${binding.handle}:${ctx.type}:${ctx.field}`, + binding.handle, ); - continue; - } - - if (isLit(wire) && !isTern(wire)) { - // Constant wire - const target = wire.to.path.join("."); - if (needsQuoting(wVal(wire))) { - lines.push(` ${prefix}${target} = "${wVal(wire)}"`); - } else { - lines.push(` ${prefix}${target} = ${formatBareValue(wVal(wire))}`); - } - } else if (isPull(wire)) { - // Pull wire — reconstruct source from handle map - const sourceStr = serializeToolWireSource(wRef(wire), tool); - const target = wire.to.path.join("."); - let suffix = ""; - suffix += serFallbacks(wire, serToolRef, formatBareValue); - suffix += serCatch(wire, serToolRef, formatBareValue); - lines.push(` ${prefix}${target} <- ${sourceStr}${suffix}`); - } + ctx.handleMap.set( + `__define_in_${binding.handle}:${ctx.type}:${ctx.field}`, + binding.handle, + ); + ctx.handleMap.set( + `__define_out_${binding.handle}:${ctx.type}:${ctx.field}`, + binding.handle, + ); + break; } +} - // onError - if (tool.onError) { - if ("value" in tool.onError) { - lines.push(` on error = ${tool.onError.value}`); - } else { - lines.push(` on error <- ${tool.onError.source}`); +/** Serialize a WireCatch to ` catch ` using the body context. */ +function serBodyCatch(c: WireCatch | undefined, ctx: BodySerContext): string { + if (!c) return ""; + if ("control" in c) return ` catch ${serializeControl(c.control)}`; + if ("expr" in c) return ` catch ${serBodyExpr(c.expr, ctx)}`; + if ("ref" in c) return ` catch ${serBodyRef(c.ref, ctx, true)}`; + return ` catch ${JSON.stringify(c.value)}`; +} + +/** Serialize a source chain (sources + catch) to the RHS of a wire. */ +function serBodySourceChain( + chain: SourceChain, + ctx: BodySerContext, + indent?: string, +): string { + const parts: string[] = []; + for (let i = 0; i < chain.sources.length; i++) { + const s = chain.sources[i]!; + let prefix = ""; + if (i > 0) { + prefix = s.gate === "nullish" ? " ?? " : " || "; } + parts.push(prefix + serBodyExpr(s.expr, ctx, undefined, indent)); } + return parts.join("") + serBodyCatch(chain.catch, ctx); +} - if (hasBody) lines.push(`}`); +/** + * Serialize a with statement to its textual form. + */ +function serWithStatement(binding: HandleBinding): string { + switch (binding.kind) { + case "tool": { + const lastDot = binding.name.lastIndexOf("."); + const defaultHandle = + lastDot !== -1 ? binding.name.substring(lastDot + 1) : binding.name; + const vTag = binding.version ? `@${binding.version}` : ""; + const memoize = binding.memoize ? " memoize" : ""; + if (binding.handle === defaultHandle && !vTag) { + return `with ${binding.name}${memoize}`; + } + return `with ${binding.name}${vTag} as ${binding.handle}${memoize}`; + } + case "input": + return binding.handle === "input" + ? "with input" + : `with input as ${binding.handle}`; + case "output": + return binding.handle === "output" + ? "with output" + : `with output as ${binding.handle}`; + case "context": + return binding.handle === "context" + ? "with context" + : `with context as ${binding.handle}`; + case "const": + return binding.handle === "const" + ? "with const" + : `with const as ${binding.handle}`; + case "define": + return `with ${binding.name} as ${binding.handle}`; + } +} - return lines.join("\n"); +/** + * Serialize a wire target to its textual form. + * Within scopes and array bodies, targets are emitted as relative + * (dot-prefixed) paths. + */ +function serBodyTarget( + target: NodeRef, + ctx: BodySerContext, + isElementScope: boolean, +): string { + // Element-scoped targets (inside array body) + if (target.element) { + const p = serPath(target.path); + return p ? `.${p}` : "."; + } + // Inside scope/array bodies, self-trunk refs are relative (dot-prefixed) + if (isElementScope) { + const isSelfTrunk = + target.module === SELF_MODULE && + target.type === ctx.type && + target.field === ctx.field && + !target.instance; + if (isSelfTrunk) { + const p = serPath(target.path); + return p ? `.${p}` : "."; + } + } + return serBodyRef(target, ctx, false); } /** - * Reconstruct a pull wire source into a readable string for tool block serialization. - * Maps NodeRef back to handle.path format. + * Serialize a Statement[] body to indented lines. + * `isElementScope` is true inside array body blocks. */ -function serializeToolWireSource(ref: NodeRef, tool: ToolDef): string { - for (const h of tool.handles) { - if (h.kind === "context") { - if ( - ref.module === SELF_MODULE && - ref.type === "Context" && - ref.field === "context" - ) { - return ref.path.length > 0 - ? `${h.handle}.${ref.path.join(".")}` - : h.handle; - } - } else if (h.kind === "const") { - if ( - ref.module === SELF_MODULE && - ref.type === "Const" && - ref.field === "const" - ) { - return ref.path.length > 0 - ? `${h.handle}.${ref.path.join(".")}` - : h.handle; +function serializeBodyStatements( + stmts: Statement[], + ctx: BodySerContext, + isElementScope: boolean, + indent: string = " ", +): string[] { + const lines: string[] = []; + for (const stmt of stmts) { + switch (stmt.kind) { + case "with": { + lines.push(`${indent}${serWithStatement(stmt.binding)}`); + break; } - } else if (h.kind === "tool") { - const lastDot = h.name.lastIndexOf("."); - if (lastDot !== -1) { + case "wire": { + const target = serBodyTarget(stmt.target, ctx, isElementScope); + // Detect constant assignment: single literal source, no catch, no gate if ( - ref.module === h.name.substring(0, lastDot) && - ref.field === h.name.substring(lastDot + 1) + stmt.sources.length === 1 && + !stmt.catch && + stmt.sources[0]!.expr.type === "literal" ) { - return ref.path.length > 0 - ? `${h.handle}.${ref.path.join(".")}` - : h.handle; + lines.push( + `${indent}${target} = ${JSON.stringify(stmt.sources[0]!.expr.value)}`, + ); + } else { + const rhs = serBodySourceChain(stmt, ctx, indent); + lines.push(`${indent}${target} <- ${rhs}`); + } + break; + } + case "alias": { + const rhs = serBodySourceChain(stmt, ctx, indent); + lines.push(`${indent}alias ${stmt.name} <- ${rhs}`); + break; + } + case "spread": { + const rhs = serBodySourceChain(stmt, ctx, indent); + lines.push(`${indent}... <- ${rhs}`); + break; + } + case "scope": { + const target = serBodyTarget(stmt.target, ctx, isElementScope); + const inner = serializeBodyStatements( + stmt.body, + ctx, + true, + indent + " ", + ); + if (inner.length === 0) { + lines.push(`${indent}${target} {}`); + } else { + lines.push(`${indent}${target} {`); + lines.push(...inner); + lines.push(`${indent}}`); } - } else if ( - ref.module === SELF_MODULE && - ref.type === "Tools" && - ref.field === h.name - ) { - return ref.path.length > 0 - ? `${h.handle}.${ref.path.join(".")}` - : h.handle; + break; } + case "force": { + const handleName = + ctx.handleMap.get( + stmt.instance != null + ? `${stmt.module}:${stmt.type}:${stmt.field}:${stmt.instance}` + : `${stmt.module}:${stmt.type}:${stmt.field}`, + ) ?? stmt.handle; + const catchStr = stmt.catchError ? " catch null" : ""; + lines.push(`${indent}force ${handleName}${catchStr}`); + break; + } + default: + stmt satisfies never; + break; } } - // Fallback: use raw ref path - return ref.path.join("."); + return lines; } /** - * Serialize a fallback NodeRef as a human-readable source string. - * - * If the ref is a pipe-fork root, reconstructs the pipe chain by walking - * the `toInMap` backward (same logic as the main pipe serializer). - * Otherwise delegates to `serializeRef`. - * - * This is used to emit `catch handle.path` or `catch pipe:source` for wire - * `catchFallbackRef` values, or `|| ref` / `?? ref` for `fallbacks`. + * Serialize a bridge block from its `body: Statement[]` IR. + * Returns the full bridge block text including header and closing brace. */ -function serializePipeOrRef( - ref: NodeRef, - pipeHandleTrunkKeys: Set, - toInMap: Map, - handleMap: Map, - bridge: Bridge, - inputHandle: string | undefined, - outputHandle: string | undefined, -): string { - const refTk = - ref.instance != null - ? `${ref.module}:${ref.type}:${ref.field}:${ref.instance}` - : `${ref.module}:${ref.type}:${ref.field}`; +function serializeBridgeBlock(bridge: Bridge): string { + if (bridge.passthrough) { + return `bridge ${bridge.type}.${bridge.field} with ${bridge.passthrough}`; + } - if (ref.path.length === 0 && pipeHandleTrunkKeys.has(refTk)) { - // Pipe-fork root — walk the chain to reconstruct `pipe:source` notation - const handleChain: string[] = []; - let currentTk = refTk; - let actualSourceRef: NodeRef | null = null; + const ctx = buildBodySerContext(bridge.type, bridge.field, bridge.handles); - for (;;) { - const handleName = handleMap.get(currentTk); - if (!handleName) break; - const inWire = toInMap.get(currentTk); - const fieldName = inWire?.to.path[0] ?? "in"; - const token = - fieldName === "in" ? handleName : `${handleName}.${fieldName}`; - handleChain.push(token); - if (!inWire) break; - const fromTk = - wRef(inWire).instance != null - ? `${wRef(inWire).module}:${wRef(inWire).type}:${wRef(inWire).field}:${wRef(inWire).instance}` - : `${wRef(inWire).module}:${wRef(inWire).type}:${wRef(inWire).field}`; - if (wRef(inWire).path.length === 0 && pipeHandleTrunkKeys.has(fromTk)) { - currentTk = fromTk; - } else { - actualSourceRef = wRef(inWire); - break; - } - } + // Register handles from with statements in the body (may include + // inner-scope tools that aren't in the top-level handles array) + for (const s of bridge.body!) { + if (s.kind === "with") registerWithBinding(s.binding, ctx); + } - if (actualSourceRef && handleChain.length > 0) { - const sourceStr = serializeRef( - actualSourceRef, - bridge, - handleMap, - inputHandle, - outputHandle, - true, - ); - return `${handleChain.join(":")}:${sourceStr}`; - } + const bodyLines = serializeBodyStatements(bridge.body!, ctx, false); + + // Separate with declarations from wire lines with a blank line + let lastWithIdx = -1; + for (let i = 0; i < bodyLines.length; i++) { + if (bodyLines[i]!.trimStart().startsWith("with ")) lastWithIdx = i; + else break; + } + if (lastWithIdx >= 0 && lastWithIdx < bodyLines.length - 1) { + bodyLines.splice(lastWithIdx + 1, 0, ""); } - return serializeRef(ref, bridge, handleMap, inputHandle, outputHandle, true); + const lines: string[] = []; + lines.push(`bridge ${bridge.type}.${bridge.field} {`); + lines.push(...bodyLines); + lines.push(`}`); + return lines.join("\n"); } /** - * Serialize a DefineDef into its textual form. - * - * Delegates to serializeBridgeBlock with a synthetic Bridge, then replaces - * the `bridge Define.` header with `define `. + * Serialize a define block from its `body: Statement[]` IR. */ function serializeDefineBlock(def: DefineDef): string { - const syntheticBridge: Bridge = { - kind: "bridge", - type: "Define", - field: def.name, - handles: def.handles, - wires: def.wires, - arrayIterators: def.arrayIterators, - pipeHandles: def.pipeHandles, - }; - const bridgeText = serializeBridgeBlock(syntheticBridge); - // Replace "bridge Define." → "define " - return bridgeText.replace(/^bridge Define\.(\w+)/, "define $1"); -} + const ctx = buildBodySerContext("Define", def.name, def.handles); -function serializeBridgeBlock(bridge: Bridge): string { - const bridgeWires: Wire[] = bridge.wires; - - // ── Passthrough shorthand ─────────────────────────────────────────── - if (bridge.passthrough) { - return `bridge ${bridge.type}.${bridge.field} with ${bridge.passthrough}`; + // Register handles from with statements in the body + for (const s of def.body!) { + if (s.kind === "with") registerWithBinding(s.binding, ctx); } - const lines: string[] = []; - - // ── Header ────────────────────────────────────────────────────────── - lines.push(`bridge ${bridge.type}.${bridge.field} {`); - - // Collect trunk keys of define-inlined tools (handle contains $) - const defineInlinedTrunkKeys = new Set(); - for (const h of bridge.handles) { - if (h.kind === "tool" && h.handle.includes("$")) { - const lastDot = h.name.lastIndexOf("."); - if (lastDot !== -1) { - const mod = h.name.substring(0, lastDot); - const fld = h.name.substring(lastDot + 1); - // Count instances to match trunk key - let inst = 0; - for (const h2 of bridge.handles) { - if (h2.kind !== "tool") continue; - const ld2 = h2.name.lastIndexOf("."); - if ( - ld2 !== -1 && - h2.name.substring(0, ld2) === mod && - h2.name.substring(ld2 + 1) === fld - ) - inst++; - if (h2 === h) break; - } - defineInlinedTrunkKeys.add(`${mod}:${bridge.type}:${fld}:${inst}`); - } else { - // Tool name without module prefix (e.g. "userApi") - let inst = 0; - for (const h2 of bridge.handles) { - if (h2.kind !== "tool") continue; - if (h2.name.lastIndexOf(".") === -1 && h2.name === h.name) inst++; - if (h2 === h) break; - } - defineInlinedTrunkKeys.add(`${SELF_MODULE}:Tools:${h.name}:${inst}`); - } - } - } - - // Detect element-scoped define handles: defines whose __define_in_ wires - // originate from element scope (i.e., the define is used inside an array block) - const elementScopedDefines = new Set(); - for (const w of bridgeWires) { - if ( - isPull(w) && - wRef(w).element && - w.to.module.startsWith("__define_in_") - ) { - const defineHandle = w.to.module.substring("__define_in_".length); - elementScopedDefines.add(defineHandle); - } - } - - for (const h of bridge.handles) { - // Element-scoped tool handles are emitted inside their array block - if (h.kind === "tool" && h.element) continue; - // Define-inlined tool handles are part of the define block, not the bridge - if (h.kind === "tool" && h.handle.includes("$")) continue; - switch (h.kind) { - case "tool": { - // Short form `with ` when handle == last segment of name - const lastDot = h.name.lastIndexOf("."); - const defaultHandle = - lastDot !== -1 ? h.name.substring(lastDot + 1) : h.name; - const vTag = h.version ? `@${h.version}` : ""; - const memoize = h.memoize ? " memoize" : ""; - if (h.handle === defaultHandle && !vTag) { - lines.push(` with ${h.name}${memoize}`); - } else { - lines.push(` with ${h.name}${vTag} as ${h.handle}${memoize}`); - } - break; - } - case "input": - if (h.handle === "input") { - lines.push(` with input`); - } else { - lines.push(` with input as ${h.handle}`); - } - break; - case "output": - if (h.handle === "output") { - lines.push(` with output`); - } else { - lines.push(` with output as ${h.handle}`); - } - break; - case "context": - lines.push(` with context as ${h.handle}`); - break; - case "const": - if (h.handle === "const") { - lines.push(` with const`); - } else { - lines.push(` with const as ${h.handle}`); - } - break; - case "define": - if (!elementScopedDefines.has(h.handle)) { - lines.push(` with ${h.name} as ${h.handle}`); - } - break; - } - } - - lines.push(""); - - // Mark where the wire body starts — everything after this gets 2-space indent - const wireBodyStart = lines.length; - - // ── Build handle map for reverse resolution ───────────────────────── - const { handleMap, inputHandle, outputHandle } = buildHandleMap(bridge); - - // ── Element-scoped tool trunk keys ────────────────────────────────── - const elementToolTrunkKeys = new Set(); - { - const localCounters = new Map(); - for (const h of bridge.handles) { - if (h.kind !== "tool") continue; - const lastDot = h.name.lastIndexOf("."); - if (lastDot !== -1) { - const mod = h.name.substring(0, lastDot); - const fld = h.name.substring(lastDot + 1); - const ik = `${mod}:${fld}`; - const inst = (localCounters.get(ik) ?? 0) + 1; - localCounters.set(ik, inst); - if (h.element) { - elementToolTrunkKeys.add(`${mod}:${bridge.type}:${fld}:${inst}`); - } - } else { - const ik = `Tools:${h.name}`; - const inst = (localCounters.get(ik) ?? 0) + 1; - localCounters.set(ik, inst); - if (h.element) { - elementToolTrunkKeys.add(`${SELF_MODULE}:Tools:${h.name}:${inst}`); - } - } - } - } - - // ── Pipe fork registry ────────────────────────────────────────────── - const pipeHandleTrunkKeys = new Set(); - for (const ph of bridge.pipeHandles ?? []) { - handleMap.set(ph.key, ph.handle); - pipeHandleTrunkKeys.add(ph.key); - } - - // ── Pipe wire detection ───────────────────────────────────────────── - const refTrunkKey = (ref: NodeRef): string => - ref.instance != null - ? `${ref.module}:${ref.type}:${ref.field}:${ref.instance}` - : `${ref.module}:${ref.type}:${ref.field}`; - - type FW = Wire; - const toInMap = new Map(); - const fromOutMap = new Map(); - const pipeWireSet = new Set(); + const bodyLines = serializeBodyStatements(def.body!, ctx, false); - for (const w of bridgeWires) { - if (!isPull(w) || !w.pipe) continue; - const fw = w as FW; - pipeWireSet.add(w); - const toTk = refTrunkKey(fw.to); - if (fw.to.path.length === 1 && pipeHandleTrunkKeys.has(toTk)) { - toInMap.set(toTk, fw); - } - const fromTk = refTrunkKey(wRef(fw)); - if (wRef(fw).path.length === 0 && pipeHandleTrunkKeys.has(fromTk)) { - fromOutMap.set(fromTk, fw); - } - // Concat fork output: from.path=["value"], target is not a pipe handle - if ( - wRef(fw).path.length === 1 && - wRef(fw).path[0] === "value" && - pipeHandleTrunkKeys.has(fromTk) && - !pipeHandleTrunkKeys.has(toTk) - ) { - fromOutMap.set(fromTk, fw); - } - } - - // ── Expression fork detection ────────────────────────────────────────── - // Operator tool name → infix operator symbol - const FN_TO_OP: Record = { - multiply: "*", - divide: "/", - add: "+", - subtract: "-", - eq: "==", - neq: "!=", - gt: ">", - gte: ">=", - lt: "<", - lte: "<=", - __and: "and", - __or: "or", - not: "not", - }; - const OP_PREC_SER: Record = { - "*": 4, - "/": 4, - "+": 3, - "-": 3, - "==": 2, - "!=": 2, - ">": 2, - ">=": 2, - "<": 2, - "<=": 2, - and: 1, - or: 0, - not: -1, - }; - // Collect expression fork metadata: forkTk → { op, bWire, aWire } - type ExprForkInfo = { - op: string; - bWire: Wire | undefined; - aWire: FW | undefined; - /** For condAnd/condOr wires: the logic wire itself */ - logicWire?: Wire | Wire; - }; - const exprForks = new Map(); - const exprPipeWireSet = new Set(); // wires that belong to expression forks - - for (const ph of bridge.pipeHandles ?? []) { - if (!ph.handle.startsWith("__expr_")) continue; - const op = FN_TO_OP[ph.baseTrunk.field]; - if (!op) continue; - - // For condAnd/condOr wires (field === "__and" or "__or") - if (ph.baseTrunk.field === "__and" || ph.baseTrunk.field === "__or") { - const isAndField = ph.baseTrunk.field === "__and"; - const logicWire = bridgeWires.find( - (w) => - (isAndField ? isAndW(w) : isOrW(w)) && refTrunkKey(w.to) === ph.key, - ) as Wire | undefined; - - if (logicWire) { - exprForks.set(ph.key, { - op, - bWire: undefined, - aWire: undefined, - logicWire, - }); - exprPipeWireSet.add(logicWire); - } - continue; - } - - // Find the .a and .b wires for this fork - let aWire: FW | undefined; - let bWire: Wire | undefined; - for (const w of bridgeWires) { - const wTo = w.to as NodeRef; - if (!wTo || refTrunkKey(wTo) !== ph.key || wTo.path.length !== 1) - continue; - if (wTo.path[0] === "a" && isPull(w)) aWire = w as FW; - else if (wTo.path[0] === "b") bWire = w; - } - exprForks.set(ph.key, { op, bWire, aWire }); - if (bWire) exprPipeWireSet.add(bWire); - if (aWire) exprPipeWireSet.add(aWire); - } - - // ── Concat (template string) fork detection ──────────────────────────── - // Detect __concat_* forks and collect their ordered parts wires. - type ConcatForkInfo = { - /** Ordered parts: either { kind: "text", value } or { kind: "ref", ref } */ - parts: ({ kind: "text"; value: string } | { kind: "ref"; ref: NodeRef })[]; - }; - const concatForks = new Map(); - const concatPipeWireSet = new Set(); // wires that belong to concat forks - - for (const ph of bridge.pipeHandles ?? []) { - if (!ph.handle.startsWith("__concat_")) continue; - if (ph.baseTrunk.field !== "concat") continue; - - // Collect parts.N wires (constant or pull) - const partsMap = new Map< - number, - { kind: "text"; value: string } | { kind: "ref"; ref: NodeRef } - >(); - for (const w of bridgeWires) { - const wTo = w.to as NodeRef; - if (!wTo || refTrunkKey(wTo) !== ph.key) continue; - if (wTo.path.length !== 2 || wTo.path[0] !== "parts") continue; - const idx = parseInt(wTo.path[1], 10); - if (isNaN(idx)) continue; - if (isLit(w) && !isPull(w)) { - partsMap.set(idx, { kind: "text", value: wVal(w) }); - } else if (isPull(w)) { - partsMap.set(idx, { kind: "ref", ref: wRef(w) }); - } - concatPipeWireSet.add(w); - } - - // Build ordered parts array - const maxIdx = Math.max(...partsMap.keys(), -1); - const parts: ConcatForkInfo["parts"] = []; - for (let i = 0; i <= maxIdx; i++) { - const part = partsMap.get(i); - if (part) parts.push(part); - } - concatForks.set(ph.key, { parts }); - } - - /** - * Reconstruct a template string from a concat fork. - * Returns `"literal{ref}literal"` notation. - */ - function reconstructTemplateString(forkTk: string): string | null { - const info = concatForks.get(forkTk); - if (!info || info.parts.length === 0) return null; - - let result = ""; - for (const part of info.parts) { - if (part.kind === "text") { - // Escape backslashes before braces first, then escape literal braces - result += part.value.replace(/\\/g, "\\\\").replace(/\{/g, "\\{"); - } else { - const refStr = part.ref.element - ? "ITER." + serPath(part.ref.path) - : sRef(part.ref, true); - result += `{${refStr}}`; - } - } - return `"${result}"`; + let lastWithIdx = -1; + for (let i = 0; i < bodyLines.length; i++) { + if (bodyLines[i]!.trimStart().startsWith("with ")) lastWithIdx = i; + else break; } - - // ── Group element wires by array-destination field ────────────────── - // Pull wires: from.element=true OR involving element-scoped tools - // OR define-output wires targeting an array-scoped bridge path - const isElementToolWire = (w: Wire): boolean => { - if (!isPull(w)) return false; - if (elementToolTrunkKeys.has(refTrunkKey(wRef(w)))) return true; - if (elementToolTrunkKeys.has(refTrunkKey(w.to))) return true; - return false; - }; - const isDefineOutElementWire = (w: Wire): boolean => { - if (!isPull(w)) return false; - if (!wRef(w).module.startsWith("__define_out_")) return false; - // Check if target is a bridge trunk path under any array iterator - const to = w.to; - if ( - to.module !== SELF_MODULE || - to.type !== bridge.type || - to.field !== bridge.field - ) - return false; - const ai = bridge.arrayIterators ?? {}; - const p = to.path.join("."); - for (const iterPath of Object.keys(ai)) { - if (iterPath === "" || p.startsWith(iterPath + ".")) return true; - } - return false; - }; - const elementPullWires = bridgeWires.filter( - (w): w is Wire => - isPull(w) && - (!!wRef(w).element || isElementToolWire(w) || isDefineOutElementWire(w)), - ); - // Constant wires: isLit(w) && to.element=true - const elementConstWires = bridgeWires.filter( - (w): w is Wire => isLit(w) && !!w.to.element, - ); - - // Build grouped maps keyed by the full array-destination path (to.path joined) - // For a 1-level array o.items <- src[], element paths are like ["items", "name"] - // For a root-level array o <- src[], element paths are like ["name"] - // For nested arrays, inner element paths are like ["items", "legs", "trainName"] - const elementPullAll = elementPullWires.filter( - (w) => - !exprPipeWireSet.has(w) && - !pipeWireSet.has(w) && - !concatPipeWireSet.has(w), - ); - const elementConstAll = elementConstWires.filter( - (w) => !exprPipeWireSet.has(w) && !concatPipeWireSet.has(w), - ); - - // Collect element-targeting expression output wires (from expression fork → element) - type ElementExprInfo = { - toPath: string[]; - sourceStr: string; // fully serialized expression string - }; - const elementExprWires: ElementExprInfo[] = []; - - // Collect element-targeting pipe chain wires - // These use ITER. as a placeholder for element refs, replaced in serializeArrayElements - type ElementPipeInfo = { - toPath: string[]; - sourceStr: string; // "handle:ITER.field" or "h1:h2:ITER.field" - fallbackStr: string; - errStr: string; - }; - const elementPipeWires: ElementPipeInfo[] = []; - - // Detect array source wires: a regular wire whose to.path (joined) matches - // a key in arrayIterators. This includes root-level arrays (path=[]). - const arrayIterators = bridge.arrayIterators ?? {}; - - /** Check if a NodeRef targets a path under an array iterator scope. */ - function isUnderArrayScope(ref: NodeRef): boolean { - if ( - ref.module !== SELF_MODULE || - ref.type !== bridge.type || - ref.field !== bridge.field - ) - return false; - const p = ref.path.join("."); - for (const iterPath of Object.keys(arrayIterators)) { - if (iterPath === "" || p.startsWith(iterPath + ".")) return true; - } - return false; - } - - // ── Determine array scope for each element-scoped tool ────────────── - // Maps element tool trunk key → array iterator key (e.g. "g" or "g.b") - const elementToolScope = new Map(); - // Also maps handle index → array iterator key for the declaration loop - const elementHandleScope = new Map(); - { - // Build trunk key for each handle (mirrors elementToolTrunkKeys logic) - const localCounters = new Map(); - const handleTrunkKeys: (string | undefined)[] = []; - for (const h of bridge.handles) { - if (h.kind !== "tool") { - handleTrunkKeys.push(undefined); - continue; - } - const lastDot = h.name.lastIndexOf("."); - let tk: string; - if (lastDot !== -1) { - const mod = h.name.substring(0, lastDot); - const fld = h.name.substring(lastDot + 1); - const ik = `${mod}:${fld}`; - const inst = (localCounters.get(ik) ?? 0) + 1; - localCounters.set(ik, inst); - tk = `${mod}:${bridge.type}:${fld}:${inst}`; - } else { - const ik = `Tools:${h.name}`; - const inst = (localCounters.get(ik) ?? 0) + 1; - localCounters.set(ik, inst); - tk = `${SELF_MODULE}:Tools:${h.name}:${inst}`; - } - handleTrunkKeys.push(h.element ? tk : undefined); - } - - // Sort iterator keys by path depth (deepest first) for matching - const iterKeys = Object.keys(arrayIterators).sort( - (a, b) => b.length - a.length, - ); - - // For each element tool, find its output wire to determine scope - for (const w of bridgeWires) { - if (!isPull(w)) continue; - const fromTk = refTrunkKey(wRef(w)); - if (!elementToolTrunkKeys.has(fromTk)) continue; - if (elementToolScope.has(fromTk)) continue; - // Output wire: from=tool → to=bridge output - const toRef = w.to; - if ( - toRef.module !== SELF_MODULE || - toRef.type !== bridge.type || - toRef.field !== bridge.field - ) - continue; - const toPath = toRef.path.join("."); - for (const ik of iterKeys) { - if (ik === "" || toPath.startsWith(ik + ".") || toPath === ik) { - elementToolScope.set(fromTk, ik); - break; - } - } - } - - // Map handle indices using the trunk keys - for (let i = 0; i < bridge.handles.length; i++) { - const tk = handleTrunkKeys[i]; - if (tk && elementToolScope.has(tk)) { - elementHandleScope.set(i, elementToolScope.get(tk)!); - } - } - } - - // ── Helper: is a wire endpoint a define-inlined tool? ───────────── - const isDefineInlinedRef = (ref: NodeRef): boolean => { - const tk = - ref.instance != null - ? `${ref.module}:${ref.type}:${ref.field}:${ref.instance}` - : `${ref.module}:${ref.type}:${ref.field}`; - return defineInlinedTrunkKeys.has(tk); - }; - - // ── Helper: is a module a define-boundary internal? ──────────────── - const isDefineBoundaryModule = (mod: string): boolean => - mod.startsWith("__define_in_") || mod.startsWith("__define_out_"); - - // ── Helper: is a wire fully internal to define expansion? ────────── - // User-authored wires have one define-boundary endpoint + one regular endpoint. - // Internal expansion wires have both endpoints in define-boundary/inlined-tool space. - const isDefineInternalWire = (w: Wire): boolean => { - const toIsDefine = - isDefineBoundaryModule(w.to.module) || isDefineInlinedRef(w.to); - if (!toIsDefine) return false; - if (!isPull(w)) return false; - const fromRef = wRef(w) as NodeRef; - return ( - isDefineBoundaryModule(fromRef.module) || isDefineInlinedRef(fromRef) - ); - }; - - // ── Exclude pipe, element-pull, element-const, expression-internal, concat-internal, __local, define-internal, and element-scoped ternary wires from main loop - const regularWires = bridgeWires.filter( - (w) => - !pipeWireSet.has(w) && - !exprPipeWireSet.has(w) && - !concatPipeWireSet.has(w) && - (!isPull(w) || !wRef(w).element) && - !isElementToolWire(w) && - (!isLit(w) || !w.to.element) && - w.to.module !== "__local" && - (!isPull(w) || (wRef(w) as NodeRef).module !== "__local") && - (!isTern(w) || !isUnderArrayScope(w.to)) && - (!isPull(w) || !isDefineInlinedRef(wRef(w))) && - !isDefineInlinedRef(w.to) && - !isDefineOutElementWire(w) && - !isDefineInternalWire(w), - ); - - // ── Collect __local binding wires for array-scoped `with` declarations ── - type LocalBindingInfo = { - alias: string; - sourceWire?: Wire; - ternaryWire?: Wire; - }; - const localBindingsByAlias = new Map(); - const localReadWires: Wire[] = []; - for (const w of bridgeWires) { - if (w.to.module === "__local" && isPull(w)) { - localBindingsByAlias.set(w.to.field, { - alias: w.to.field, - sourceWire: w as Wire, - }); - } - if (w.to.module === "__local" && isTern(w)) { - localBindingsByAlias.set(w.to.field, { - alias: w.to.field, - ternaryWire: w as Wire, - }); - } - if (isPull(w) && (wRef(w) as NodeRef).module === "__local") { - localReadWires.push(w as Wire); - } + if (lastWithIdx >= 0 && lastWithIdx < bodyLines.length - 1) { + bodyLines.splice(lastWithIdx + 1, 0, ""); } - // ── Collect element-scoped ternary wires ──────────────────────────── - const elementTernaryWires = bridgeWires.filter( - (w): w is Wire => isTern(w) && isUnderArrayScope(w.to), - ); - - const serializedArrays = new Set(); - - // ── Helper: serialize a reference (forward outputHandle) ───────────── - const sRef = (ref: NodeRef, isFrom: boolean) => - serializeRef(ref, bridge, handleMap, inputHandle, outputHandle, isFrom); - const sPipeOrRef = (ref: NodeRef) => - serializePipeOrRef( - ref, - pipeHandleTrunkKeys, - toInMap, - handleMap, - bridge, - inputHandle, - outputHandle, - ); - - // ── Pre-compute element expression wires ──────────────────────────── - // Walk expression trees from fromOutMap that target element refs - for (const [tk, outWire] of fromOutMap.entries()) { - if (!exprForks.has(tk) || !isUnderArrayScope(outWire.to)) continue; - - // Recursively serialize expression fork tree - function serializeElemExprTree( - forkTk: string, - parentPrec?: number, - ): string | null { - const info = exprForks.get(forkTk); - if (!info) return null; - - // condAnd/condOr logic wire — reconstruct from leftRef/rightRef - if (info.logicWire) { - const logic = wAndOr(info.logicWire!); - let leftStr: string; - const leftTk = refTrunkKey(eRef(logic.left)); - if (eRef(logic.left).path.length === 0 && exprForks.has(leftTk)) { - leftStr = - serializeElemExprTree(leftTk, OP_PREC_SER[info.op] ?? 0) ?? - sRef(eRef(logic.left), true); - } else { - leftStr = eRef(logic.left).element - ? "ITER." + serPath(eRef(logic.left).path) - : sRef(eRef(logic.left), true); - } - - let rightStr: string; - if (logic.right.type === "ref") { - const rightTk = refTrunkKey(eRef(logic.right)); - if (eRef(logic.right).path.length === 0 && exprForks.has(rightTk)) { - rightStr = - serializeElemExprTree(rightTk, OP_PREC_SER[info.op] ?? 0) ?? - sRef(eRef(logic.right), true); - } else { - rightStr = eRef(logic.right).element - ? "ITER." + serPath(eRef(logic.right).path) - : sRef(eRef(logic.right), true); - } - } else if (logic.right.type === "literal") { - rightStr = formatExprValue(eVal(logic.right)); - } else { - rightStr = "0"; - } - - let result = `${leftStr} ${info.op} ${rightStr}`; - const myPrec = OP_PREC_SER[info.op] ?? 0; - if (parentPrec != null && myPrec < parentPrec) result = `(${result})`; - return result; - } - - let leftStr: string | null = null; - if (info.aWire) { - const fromTk = refTrunkKey(wRef(info.aWire!)); - if (wRef(info.aWire!).path.length === 0 && exprForks.has(fromTk)) { - leftStr = serializeElemExprTree(fromTk, OP_PREC_SER[info.op] ?? 0); - } else { - leftStr = wRef(info.aWire!).element - ? "ITER." + serPath(wRef(info.aWire!).path) - : sRef(wRef(info.aWire!), true); - } - } - - let rightStr: string; - if (info.bWire && isLit(info.bWire)) { - rightStr = formatExprValue(wVal(info.bWire!)); - } else if (info.bWire && isPull(info.bWire)) { - const bFrom = wRef(info.bWire!); - const bTk = refTrunkKey(bFrom); - if (bFrom.path.length === 0 && exprForks.has(bTk)) { - rightStr = - serializeElemExprTree(bTk, OP_PREC_SER[info.op] ?? 0) ?? - sRef(bFrom, true); - } else { - rightStr = bFrom.element - ? "ITER." + serPath(bFrom.path) - : sRef(bFrom, true); - } - } else { - rightStr = "0"; - } - - if (leftStr == null) return rightStr; - if (info.op === "not") return `not ${leftStr}`; - let result = `${leftStr} ${info.op} ${rightStr}`; - const myPrec = OP_PREC_SER[info.op] ?? 0; - if (parentPrec != null && myPrec < parentPrec) result = `(${result})`; - return result; - } + const lines: string[] = []; + lines.push(`define ${def.name} {`); + lines.push(...bodyLines); + lines.push(`}`); + return lines.join("\n"); +} - const exprStr = serializeElemExprTree(tk); - if (exprStr) { - elementExprWires.push({ - toPath: outWire.to.path, - sourceStr: exprStr, - }); - } - } +/** + * Serialize a tool block from its `body: Statement[]` IR. + * In tool bodies, all targets reference the tool itself so they are dot-prefixed. + */ +function serializeToolBlock(tool: ToolDef): string { + // Tool context: type=Tools, field=tool.name + const ctx = buildBodySerContext("Tools", tool.name, tool.handles); - // Pre-compute element-targeting concat (template string) wires - for (const [tk, outWire] of fromOutMap.entries()) { - if (!concatForks.has(tk) || !outWire.to.element) continue; - const templateStr = reconstructTemplateString(tk); - if (templateStr) { - elementExprWires.push({ - toPath: outWire.to.path, - sourceStr: templateStr, - }); - } + // Register handles from with statements in the body + for (const s of tool.body!) { + if (s.kind === "with") registerWithBinding(s.binding, ctx); } - // Pre-compute element-targeting normal pipe chain wires - for (const [tk, outWire] of fromOutMap.entries()) { - if (exprForks.has(tk) || concatForks.has(tk)) continue; - if (!isUnderArrayScope(outWire.to)) continue; + // In tool bodies, everything is scope-relative (dot-prefixed) + const bodyLines = serializeBodyStatements(tool.body!, ctx, true); - // Walk the pipe chain backward to reconstruct handle:source - const handleChain: string[] = []; - let currentTk = tk; - let sourceStr: string | null = null; - for (;;) { - const handleName = handleMap.get(currentTk); - if (!handleName) break; - const inWire = toInMap.get(currentTk); - const fieldName = inWire?.to.path[0] ?? "in"; - const token = - fieldName === "in" ? handleName : `${handleName}.${fieldName}`; - handleChain.push(token); - if (!inWire) break; - if (wRef(inWire).element) { - sourceStr = - wRef(inWire).path.length > 0 - ? "ITER." + serPath(wRef(inWire).path) - : "ITER"; - break; - } - const fromTk = refTrunkKey(wRef(inWire)); - if (wRef(inWire).path.length === 0 && pipeHandleTrunkKeys.has(fromTk)) { - currentTk = fromTk; - } else { - sourceStr = sRef(wRef(inWire), true); - break; - } - } - if (sourceStr && handleChain.length > 0) { - const fallbackStr = serFallbacks(outWire, sPipeOrRef); - const errf = serCatch(outWire, sPipeOrRef); - elementPipeWires.push({ - toPath: outWire.to.path, - sourceStr: `${handleChain.join(":")}:${sourceStr}`, - fallbackStr, - errStr: errf, - }); - } + // Separate with declarations from body + let lastWithIdx = -1; + for (let i = 0; i < bodyLines.length; i++) { + if (bodyLines[i]!.trimStart().startsWith("with ")) lastWithIdx = i; + else break; } - - /** Serialize a ref in element context, resolving element refs to iterator name. */ - function serializeElemRef( - ref: NodeRef, - parentIterName: string, - ancestorIterNames: string[], - ): string { - if (ref.element) { - let resolvedIterName = parentIterName; - if (ref.elementDepth) { - const stack = [...ancestorIterNames, parentIterName]; - const idx = stack.length - 1 - ref.elementDepth; - if (idx >= 0) resolvedIterName = stack[idx]; - } - return ref.path.length > 0 - ? resolvedIterName + "." + serPath(ref.path, ref.rootSafe, ref.pathSafe) - : resolvedIterName; - } - // Expression fork — serialize and replace ITER. placeholder - const tk = refTrunkKey(ref); - if (ref.path.length === 0 && exprForks.has(tk)) { - const exprStr = serializeElemExprTreeFn( - tk, - parentIterName, - ancestorIterNames, - ); - if (exprStr) return exprStr; - } - return sRef(ref, true); + if (lastWithIdx >= 0 && lastWithIdx < bodyLines.length - 1) { + bodyLines.splice(lastWithIdx + 1, 0, ""); } - /** Recursively serialize an expression fork tree in element context. */ - function serializeElemExprTreeFn( - forkTk: string, - parentIterName: string, - ancestorIterNames: string[], - parentPrec?: number, - ): string | null { - const info = exprForks.get(forkTk); - if (!info) return null; - - if (info.logicWire) { - const logic = wAndOr(info.logicWire!); - let leftStr: string; - const leftTk = refTrunkKey(eRef(logic.left)); - if (eRef(logic.left).path.length === 0 && exprForks.has(leftTk)) { - leftStr = - serializeElemExprTreeFn( - leftTk, - parentIterName, - ancestorIterNames, - OP_PREC_SER[info.op] ?? 0, - ) ?? - serializeElemRef(eRef(logic.left), parentIterName, ancestorIterNames); - } else { - leftStr = serializeElemRef( - eRef(logic.left), - parentIterName, - ancestorIterNames, - ); - } - - let rightStr: string; - if (logic.right.type === "ref") { - const rightTk = refTrunkKey(eRef(logic.right)); - if (eRef(logic.right).path.length === 0 && exprForks.has(rightTk)) { - rightStr = - serializeElemExprTreeFn( - rightTk, - parentIterName, - ancestorIterNames, - OP_PREC_SER[info.op] ?? 0, - ) ?? - serializeElemRef( - eRef(logic.right), - parentIterName, - ancestorIterNames, - ); - } else { - rightStr = serializeElemRef( - eRef(logic.right), - parentIterName, - ancestorIterNames, - ); - } - } else if (logic.right.type === "literal") { - rightStr = formatExprValue(eVal(logic.right)); - } else { - rightStr = "0"; - } - - let result = `${leftStr} ${info.op} ${rightStr}`; - const myPrec = OP_PREC_SER[info.op] ?? 0; - if (parentPrec != null && myPrec < parentPrec) result = `(${result})`; - return result; - } - - let leftStr: string | null = null; - if (info.aWire) { - const fromTk = refTrunkKey(wRef(info.aWire!)); - if (wRef(info.aWire!).path.length === 0 && exprForks.has(fromTk)) { - leftStr = serializeElemExprTreeFn( - fromTk, - parentIterName, - ancestorIterNames, - OP_PREC_SER[info.op] ?? 0, - ); - } else { - leftStr = serializeElemRef( - wRef(info.aWire!), - parentIterName, - ancestorIterNames, - ); - } - } - - let rightStr: string; - if (info.bWire && isLit(info.bWire)) { - rightStr = formatExprValue(wVal(info.bWire!)); - } else if (info.bWire && isPull(info.bWire)) { - const bFrom = wRef(info.bWire!); - const bTk = refTrunkKey(bFrom); - if (bFrom.path.length === 0 && exprForks.has(bTk)) { - rightStr = - serializeElemExprTreeFn( - bTk, - parentIterName, - ancestorIterNames, - OP_PREC_SER[info.op] ?? 0, - ) ?? serializeElemRef(bFrom, parentIterName, ancestorIterNames); - } else { - rightStr = serializeElemRef(bFrom, parentIterName, ancestorIterNames); - } + // on error — value or source reference + if (tool.onError) { + if ("value" in tool.onError) { + bodyLines.push(`on error = ${tool.onError.value}`); } else { - rightStr = "0"; - } - - if (leftStr == null) return rightStr; - if (info.op === "not") return `not ${leftStr}`; - let result = `${leftStr} ${info.op} ${rightStr}`; - const myPrec = OP_PREC_SER[info.op] ?? 0; - if (parentPrec != null && myPrec < parentPrec) result = `(${result})`; - return result; - } - - /** - * Recursively serialize element wires for an array mapping block. - * Handles nested array-in-array mappings by detecting inner iterators. - */ - function serializeArrayElements( - arrayPath: string[], - parentIterName: string, - indent: string, - ancestorIterNames: string[] = [], - ): void { - const arrayPathStr = arrayPath.join("."); - const pathDepth = arrayPath.length; - - // Find element constant wires at this level (path starts with arrayPath + one more segment) - const levelConsts = elementConstAll.filter((ew) => { - if (ew.to.path.length !== pathDepth + 1) return false; - for (let i = 0; i < pathDepth; i++) { - if (ew.to.path[i] !== arrayPath[i]) return false; - } - return true; - }); - - // Find element pull wires at this level (direct fields, not nested array children) - const levelPulls = elementPullAll.filter((ew) => { - // Tool-targeting wires: include if the tool belongs to this scope - const ewToTk = refTrunkKey(ew.to); - if (elementToolTrunkKeys.has(ewToTk)) { - return elementToolScope.get(ewToTk) === arrayPathStr; - } - // Tool-output wires: include if the tool belongs to this scope - const ewFromTk = refTrunkKey(wRef(ew)); - if (elementToolTrunkKeys.has(ewFromTk)) { - return elementToolScope.get(ewFromTk) === arrayPathStr; - } - if (ew.to.path.length < pathDepth + 1) return false; - for (let i = 0; i < pathDepth; i++) { - if (ew.to.path[i] !== arrayPath[i]) return false; - } - // Check this wire is a direct field (depth == pathDepth+1) - // or a nested array source (its path matches a nested iterator key) - return true; - }); - - // Partition pulls into direct-level fields vs nested-array sources - const nestedArrayPaths = new Set(); - for (const key of Object.keys(arrayIterators)) { - // A nested array key starts with the current array path - if ( - key.length > arrayPathStr.length && - (arrayPathStr === "" ? true : key.startsWith(arrayPathStr + ".")) && - !key - .substring(arrayPathStr === "" ? 0 : arrayPathStr.length + 1) - .includes(".") - ) { - nestedArrayPaths.add(key); - } - } - - // Emit block-scoped local bindings: alias <- - for (const [alias, info] of localBindingsByAlias) { - // Ternary alias in element scope - if (info.ternaryWire) { - const tw = info.ternaryWire; - const condStr = serializeElemRef( - eRef(wTern(tw).cond), - parentIterName, - ancestorIterNames, - ); - const thenStr = - wTern(tw).then.type === "ref" - ? serializeElemRef( - eRef(wTern(tw).then), - parentIterName, - ancestorIterNames, - ) - : (eVal(wTern(tw).then) ?? "null"); - const elseStr = - wTern(tw).else.type === "ref" - ? serializeElemRef( - eRef(wTern(tw).else), - parentIterName, - ancestorIterNames, - ) - : (eVal(wTern(tw).else) ?? "null"); - const fallbackStr = serFallbacks(tw, sPipeOrRef); - const errf = serCatch(tw, sPipeOrRef); - lines.push( - `${indent}alias ${alias} <- ${condStr} ? ${thenStr} : ${elseStr}${fallbackStr}${errf}`, - ); - continue; - } - const srcWire = info.sourceWire!; - // Reconstruct the source expression - const fromRef = wRef(srcWire); - - // Determine if this alias is element-scoped (skip top-level aliases) - let isElementScoped = fromRef.element; - if (!isElementScoped) { - const srcTk = refTrunkKey(fromRef); - if (fromRef.path.length === 0 && pipeHandleTrunkKeys.has(srcTk)) { - // Walk pipe chain — element-scoped if any input is element-scoped - let walkTk = srcTk; - while (true) { - const inWire = toInMap.get(walkTk); - if (!inWire) break; - if (wRef(inWire).element) { - isElementScoped = true; - break; - } - const innerTk = refTrunkKey(wRef(inWire)); - if ( - wRef(inWire).path.length === 0 && - pipeHandleTrunkKeys.has(innerTk) - ) { - walkTk = innerTk; - } else { - break; - } - } - } - } - if (!isElementScoped) continue; - - let sourcePart: string; - if (fromRef.element) { - sourcePart = - parentIterName + - (fromRef.path.length > 0 ? "." + serPath(fromRef.path) : ""); - } else { - // Check if the source is an expression fork, concat fork, or pipe fork - const srcTk = refTrunkKey(fromRef); - if (fromRef.path.length === 0 && exprForks.has(srcTk)) { - // Expression fork → reconstruct infix expression - const exprStr = serializeElemExprTreeFn( - srcTk, - parentIterName, - ancestorIterNames, - ); - sourcePart = exprStr ?? sRef(fromRef, true); - } else if ( - fromRef.path.length === 0 && - pipeHandleTrunkKeys.has(srcTk) - ) { - // Walk the pipe chain backward to reconstruct pipe:source - const parts: string[] = []; - let currentTk = srcTk; - while (true) { - const handleName = handleMap.get(currentTk); - if (!handleName) break; - parts.push(handleName); - const inWire = toInMap.get(currentTk); - if (!inWire) break; - if (wRef(inWire).element) { - parts.push( - parentIterName + - (wRef(inWire).path.length > 0 - ? "." + serPath(wRef(inWire).path) - : ""), - ); - break; - } - const innerTk = refTrunkKey(wRef(inWire)); - if ( - wRef(inWire).path.length === 0 && - pipeHandleTrunkKeys.has(innerTk) - ) { - currentTk = innerTk; - } else { - parts.push(sRef(wRef(inWire), true)); - break; - } - } - sourcePart = parts.join(":"); - } else { - sourcePart = sRef(fromRef, true); - } - } - const elemFb = serFallbacks(srcWire, sPipeOrRef); - const elemErrf = serCatch(srcWire, sPipeOrRef); - lines.push( - `${indent}alias ${alias} <- ${sourcePart}${elemFb}${elemErrf}`, - ); - } - - // Emit element-scoped tool declarations: with as - for (let hi = 0; hi < bridge.handles.length; hi++) { - const h = bridge.handles[hi]; - if (h.kind !== "tool" || !h.element) continue; - // Only emit if this tool belongs to the current array scope - const scope = elementHandleScope.get(hi); - if (scope !== arrayPathStr) continue; - const vTag = h.version ? `@${h.version}` : ""; - const memoize = h.memoize ? " memoize" : ""; - const lastDot = h.name.lastIndexOf("."); - const defaultHandle = - lastDot !== -1 ? h.name.substring(lastDot + 1) : h.name; - if (h.handle === defaultHandle && !vTag) { - lines.push(`${indent}with ${h.name}${memoize}`); - } else { - lines.push(`${indent}with ${h.name}${vTag} as ${h.handle}${memoize}`); - } - } - - // Emit element-scoped define declarations: with as - // Only emit at root array level (pathDepth === 0) for now - if (pathDepth === 0) { - for (const h of bridge.handles) { - if (h.kind !== "define") continue; - if (!elementScopedDefines.has(h.handle)) continue; - lines.push(`${indent}with ${h.name} as ${h.handle}`); - } - } - - // Emit constant element wires - for (const ew of levelConsts) { - const fieldPath = ew.to.path.slice(pathDepth); - const elemTo = "." + serPath(fieldPath); - lines.push(`${indent}${elemTo} = ${formatBareValue(wVal(ew))}`); - } - - // Emit pull element wires (direct level only) - for (const ew of levelPulls) { - const toPathStr = ew.to.path.join("."); - - // Skip wires that belong to a nested array level - if (ew.to.path.length > pathDepth + 1) { - // Check if this wire's immediate child segment forms a nested array - const childPath = ew.to.path.slice(0, pathDepth + 1).join("."); - if (nestedArrayPaths.has(childPath)) continue; // handled by nested block - } - - // Check if this wire IS a nested array source - if (nestedArrayPaths.has(toPathStr) && !serializedArrays.has(toPathStr)) { - serializedArrays.add(toPathStr); - const nestedIterName = arrayIterators[toPathStr]; - let nestedFromIter = parentIterName; - if (wRef(ew).element && wRef(ew).elementDepth) { - const stack = [...ancestorIterNames, parentIterName]; - const idx = stack.length - 1 - wRef(ew).elementDepth!; - if (idx >= 0) nestedFromIter = stack[idx]; - } - const fromPart = wRef(ew).element - ? nestedFromIter + "." + serPath(wRef(ew).path) - : sRef(wRef(ew), true); - const fieldPath = ew.to.path.slice(pathDepth); - const elemTo = "." + serPath(fieldPath); - lines.push( - `${indent}${elemTo} <- ${fromPart}[] as ${nestedIterName} {`, - ); - serializeArrayElements(ew.to.path, nestedIterName, indent + " ", [ - ...ancestorIterNames, - parentIterName, - ]); - lines.push(`${indent}}`); - continue; - } - - // Regular element pull wire - let resolvedIterName = parentIterName; - if (wRef(ew).element && wRef(ew).elementDepth) { - const stack = [...ancestorIterNames, parentIterName]; - const idx = stack.length - 1 - wRef(ew).elementDepth!; - if (idx >= 0) resolvedIterName = stack[idx]; - } - const fromPart = wRef(ew).element - ? resolvedIterName + - (wRef(ew).path.length > 0 ? "." + serPath(wRef(ew).path) : "") - : sRef(wRef(ew), true); - // Tool input or define-in wires target a scoped handle - const toTk = refTrunkKey(ew.to); - const toToolHandle = - elementToolTrunkKeys.has(toTk) || - ew.to.module.startsWith("__define_in_") - ? handleMap.get(toTk) - : undefined; - const elemTo = toToolHandle - ? toToolHandle + - (ew.to.path.length > 0 ? "." + serPath(ew.to.path) : "") - : "." + serPath(ew.to.path.slice(pathDepth)); - - const fallbackStr = serFallbacks(ew, sPipeOrRef); - const errf = serCatch(ew, sPipeOrRef); - lines.push(`${indent}${elemTo} <- ${fromPart}${fallbackStr}${errf}`); - } - - // Emit expression element wires at this level - for (const eew of elementExprWires) { - if (eew.toPath.length !== pathDepth + 1) continue; - let match = true; - for (let i = 0; i < pathDepth; i++) { - if (eew.toPath[i] !== arrayPath[i]) { - match = false; - break; - } - } - if (!match) continue; - const fieldPath = eew.toPath.slice(pathDepth); - const elemTo = "." + serPath(fieldPath); - // Replace ITER. placeholder with actual iterator name - const src = eew.sourceStr.replaceAll("ITER.", parentIterName + "."); - lines.push(`${indent}${elemTo} <- ${src}`); - } - - // Emit pipe chain element wires at this level - for (const epw of elementPipeWires) { - if (epw.toPath.length !== pathDepth + 1) continue; - let match = true; - for (let i = 0; i < pathDepth; i++) { - if (epw.toPath[i] !== arrayPath[i]) { - match = false; - break; - } - } - if (!match) continue; - const fieldPath = epw.toPath.slice(pathDepth); - const elemTo = "." + serPath(fieldPath); - // Replace ITER placeholder with actual iterator name - const src = epw.sourceStr - .replaceAll("ITER.", parentIterName + ".") - .replaceAll(/ITER(?!\.)/g, parentIterName); - lines.push(`${indent}${elemTo} <- ${src}${epw.fallbackStr}${epw.errStr}`); - } - - // Emit element-scoped ternary wires at this level - for (const tw of elementTernaryWires) { - if (tw.to.path.length !== pathDepth + 1) continue; - let match = true; - for (let i = 0; i < pathDepth; i++) { - if (tw.to.path[i] !== arrayPath[i]) { - match = false; - break; - } - } - if (!match) continue; - const fieldPath = tw.to.path.slice(pathDepth); - const elemTo = "." + serPath(fieldPath); - // Serialize condition — resolve element refs to iterator name - const condStr = serializeElemRef( - eRef(wTern(tw).cond), - parentIterName, - ancestorIterNames, - ); - const thenStr = - wTern(tw).then.type === "ref" - ? serializeElemRef( - eRef(wTern(tw).then), - parentIterName, - ancestorIterNames, - ) - : (eVal(wTern(tw).then) ?? "null"); - const elseStr = - wTern(tw).else.type === "ref" - ? serializeElemRef( - eRef(wTern(tw).else), - parentIterName, - ancestorIterNames, - ) - : (eVal(wTern(tw).else) ?? "null"); - const fallbackStr = serFallbacks(tw, sPipeOrRef); - const errf = serCatch(tw, sPipeOrRef); - lines.push( - `${indent}${elemTo} <- ${condStr} ? ${thenStr} : ${elseStr}${fallbackStr}${errf}`, - ); - } - - // Emit local-binding read wires at this level (.field <- alias.path) - for (const lw of localReadWires) { - if (lw.to.path.length < pathDepth + 1) continue; - let match = true; - for (let i = 0; i < pathDepth; i++) { - if (lw.to.path[i] !== arrayPath[i]) { - match = false; - break; - } - } - if (!match) continue; - const fieldPath = lw.to.path.slice(pathDepth); - const elemTo = "." + serPath(fieldPath); - const alias = wRef(lw).field; // __local:Shadow: - const safeSep = wSafe(lw) || wRef(lw).rootSafe ? "?." : "."; - const fromPart = - wRef(lw).path.length > 0 - ? alias + - safeSep + - serPath(wRef(lw).path, wRef(lw).rootSafe, wRef(lw).pathSafe) - : alias; - lines.push(`${indent}${elemTo} <- ${fromPart}`); - } - } - - // ── Helper: serialize an expression fork tree for a ref (used for cond) ── - /** Resolve a ref to a concat template string if it points to a __concat fork output. */ - function tryResolveConcat(ref: NodeRef): string | null { - if (ref.path.length === 1 && ref.path[0] === "value") { - const tk = refTrunkKey(ref); - if (concatForks.has(tk)) { - return reconstructTemplateString(tk); - } - } - return null; - } - - function serializeExprOrRef(ref: NodeRef): string { - const tk = refTrunkKey(ref); - // Check if ref is a concat output first - const concatStr = tryResolveConcat(ref); - if (concatStr) return concatStr; - if (ref.path.length === 0 && exprForks.has(tk)) { - // Recursively serialize expression fork - function serFork(forkTk: string, parentPrec?: number): string { - const info = exprForks.get(forkTk); - if (!info) return "?"; - const myPrec = OP_PREC_SER[info.op] ?? 0; - let leftStr: string | null = null; - if (info.aWire) { - const aTk = refTrunkKey(wRef(info.aWire!)); - const concatLeft = tryResolveConcat(wRef(info.aWire!)); - if (concatLeft) { - leftStr = concatLeft; - } else if ( - wRef(info.aWire!).path.length === 0 && - exprForks.has(aTk) - ) { - leftStr = serFork(aTk, myPrec); - } else { - leftStr = sRef(wRef(info.aWire!), true); - } - } - let rightStr: string; - if (info.bWire && isLit(info.bWire)) { - rightStr = formatExprValue(wVal(info.bWire!)); - } else if (info.bWire && isPull(info.bWire)) { - const bFrom = wRef(info.bWire!); - const bTk = refTrunkKey(bFrom); - const concatRight = tryResolveConcat(bFrom); - if (concatRight) { - rightStr = concatRight; - } else { - rightStr = - bFrom.path.length === 0 && exprForks.has(bTk) - ? serFork(bTk, myPrec) - : sRef(bFrom, true); - } - } else { - rightStr = "0"; - } - if (leftStr == null) return rightStr; - if (info.op === "not") return `not ${leftStr}`; - let result = `${leftStr} ${info.op} ${rightStr}`; - if (parentPrec != null && myPrec < parentPrec) result = `(${result})`; - return result; - } - return serFork(tk) ?? sRef(ref, true); - } - return sRef(ref, true); - } - - // ── Identify spread wires and their sibling wires ─────────────────── - // Spread wires must be emitted inside path scope blocks: `target { ...source; .field <- ... }` - // Group each spread wire with sibling wires whose to.path extends the spread's to.path. - type SpreadGroup = { - spreadWires: Wire[]; - siblingWires: Wire[]; - scopePath: string[]; - }; - const spreadGroups: SpreadGroup[] = []; - const spreadConsumedWires = new Set(); - - { - const spreadWiresInRegular = regularWires.filter( - (w): w is Wire => isPull(w) && !!w.spread, - ); - // Group by to.path (scope path) - const groupMap = new Map(); - for (const sw of spreadWiresInRegular) { - const key = sw.to.path.join("."); - if (!groupMap.has(key)) { - groupMap.set(key, { - spreadWires: [], - siblingWires: [], - scopePath: sw.to.path, - }); - } - groupMap.get(key)!.spreadWires.push(sw); - spreadConsumedWires.add(sw); - } - // Find sibling wires: non-spread wires whose to.path starts with the scope path - if (groupMap.size > 0) { - for (const w of regularWires) { - if (spreadConsumedWires.has(w)) continue; - for (const [key, group] of groupMap) { - const wPath = w.to.path.join("."); - const prefix = key === "" ? "" : key + "."; - if (key === "" ? wPath.length > 0 : wPath.startsWith(prefix)) { - group.siblingWires.push(w); - spreadConsumedWires.add(w); - break; - } - } - } - for (const g of groupMap.values()) { - spreadGroups.push(g); - } + bodyLines.push(`on error <- ${tool.onError.source}`); } } - // ── Emit spread scope blocks ─────────────────────────────────────── - for (const group of spreadGroups) { - const scopePrefix = - group.scopePath.length > 0 - ? sRef( - { - module: SELF_MODULE, - type: bridge.type, - field: bridge.field, - path: group.scopePath, - }, - false, - ) - : (outputHandle ?? "o"); - lines.push(`${scopePrefix} {`); - // Emit spread lines - for (const sw of group.spreadWires) { - let fromStr = sRef(wRef(sw), true); - if (wSafe(sw)) { - const ref = wRef(sw); - if (!ref.rootSafe && !ref.pathSafe?.some((s) => s)) { - if (fromStr.includes(".")) { - fromStr = fromStr.replace(".", "?."); - } - } - } - lines.push(` ... <- ${fromStr}`); - } - // Emit sibling wires with paths relative to the scope - const scopeLen = group.scopePath.length; - for (const w of group.siblingWires) { - const relPath = w.to.path.slice(scopeLen); - if (isLit(w)) { - lines.push(` .${relPath.join(".")} = ${formatBareValue(wVal(w))}`); - } else if (isPull(w)) { - let fromStr = sRef(wRef(w), true); - if (wSafe(w)) { - const ref = wRef(w); - if (!ref.rootSafe && !ref.pathSafe?.some((s) => s)) { - if (fromStr.includes(".")) { - fromStr = fromStr.replace(".", "?."); - } - } - } - const fallbackStr = serFallbacks(w, sPipeOrRef); - const errf = serCatch(w, sPipeOrRef); - lines.push( - ` .${relPath.join(".")} <- ${fromStr}${fallbackStr}${errf}`, - ); - } - } + const source = tool.extends ?? tool.fn; + const lines: string[] = []; + if (bodyLines.length > 0) { + lines.push(`tool ${tool.name} from ${source} {`); + lines.push(...bodyLines); lines.push(`}`); + } else { + lines.push(`tool ${tool.name} from ${source}`); } - - for (const w of regularWires) { - // Skip wires already emitted in spread scope blocks - if (spreadConsumedWires.has(w)) continue; - - // Conditional (ternary) wire - if (isTern(w)) { - const toStr = sRef(w.to, false); - const condStr = serializeExprOrRef(eRef(wTern(w).cond)); - const thenStr = - wTern(w).then.type === "ref" - ? sRef(eRef(wTern(w).then), true) - : (eVal(wTern(w).then) ?? "null"); - const elseStr = - wTern(w).else.type === "ref" - ? sRef(eRef(wTern(w).else), true) - : (eVal(wTern(w).else) ?? "null"); - const fallbackStr = serFallbacks(w, sPipeOrRef); - const errf = serCatch(w, sPipeOrRef); - lines.push( - `${toStr} <- ${condStr} ? ${thenStr} : ${elseStr}${fallbackStr}${errf}`, - ); - continue; - } - - // Constant wire - if (isLit(w)) { - const toStr = sRef(w.to, false); - lines.push(`${toStr} = ${formatBareValue(wVal(w))}`); - continue; - } - - // Skip condAnd/condOr wires (handled in expression tree serialization) - if (isAndW(w) || isOrW(w)) continue; - - // Array mapping — emit brace-delimited element block - const arrayKey = w.to.path.join("."); - if ( - arrayKey in arrayIterators && - !serializedArrays.has(arrayKey) && - w.to.module === SELF_MODULE && - w.to.type === bridge.type && - w.to.field === bridge.field - ) { - serializedArrays.add(arrayKey); - const iterName = arrayIterators[arrayKey]; - const fromStr = sRef(wRef(w), true) + "[]"; - const toStr = sRef(w.to, false); - lines.push(`${toStr} <- ${fromStr} as ${iterName} {`); - serializeArrayElements(w.to.path, iterName, " "); - lines.push(`}`); - continue; - } - - // Regular wire - let fromStr = sRef(wRef(w), true); - // Legacy safe flag without per-segment info: put ?. after root - if (wSafe(w)) { - const ref = wRef(w); - if (!ref.rootSafe && !ref.pathSafe?.some((s) => s)) { - if (fromStr.includes(".")) { - fromStr = fromStr.replace(".", "?."); - } - } - } - const toStr = sRef(w.to, false); - const fallbackStr = serFallbacks(w, sPipeOrRef); - const errf = serCatch(w, sPipeOrRef); - lines.push(`${toStr} <- ${fromStr}${fallbackStr}${errf}`); - } - - // ── Top-level alias declarations ───────────────────────────────────── - // Emit `alias <- ` for __local bindings that are NOT - // element-scoped (those are handled inside serializeArrayElements). - for (const [alias, info] of localBindingsByAlias) { - // Ternary alias: emit `alias ? : [fallbacks] as ` - if (info.ternaryWire) { - const tw = info.ternaryWire; - const condStr = serializeExprOrRef(eRef(wTern(tw).cond)); - const thenStr = - wTern(tw).then.type === "ref" - ? sRef(eRef(wTern(tw).then), true) - : (eVal(wTern(tw).then) ?? "null"); - const elseStr = - wTern(tw).else.type === "ref" - ? sRef(eRef(wTern(tw).else), true) - : (eVal(wTern(tw).else) ?? "null"); - const fallbackStr = serFallbacks(tw, sPipeOrRef); - const errf = serCatch(tw, sPipeOrRef); - lines.push( - `alias ${alias} <- ${condStr} ? ${thenStr} : ${elseStr}${fallbackStr}${errf}`, - ); - continue; - } - const srcWire = info.sourceWire!; - const fromRef = wRef(srcWire); - // Element-scoped bindings are emitted inside array blocks - if (fromRef.element) continue; - // Check if source is a pipe fork with element-sourced input (array-scoped) - const srcTk = refTrunkKey(fromRef); - if (fromRef.path.length === 0 && pipeHandleTrunkKeys.has(srcTk)) { - const inWire = toInMap.get(srcTk); - if (inWire && wRef(inWire).element) continue; - } - // Reconstruct source expression - let sourcePart: string; - if (fromRef.path.length === 0 && exprForks.has(srcTk)) { - // Expression fork → reconstruct infix expression - sourcePart = serializeExprOrRef(fromRef); - } else if (tryResolveConcat(fromRef)) { - // Concat fork → reconstruct template string - sourcePart = tryResolveConcat(fromRef)!; - } else if (fromRef.path.length === 0 && pipeHandleTrunkKeys.has(srcTk)) { - const parts: string[] = []; - let currentTk = srcTk; - while (true) { - const handleName = handleMap.get(currentTk); - if (!handleName) break; - parts.push(handleName); - const inWire = toInMap.get(currentTk); - if (!inWire) break; - const innerTk = refTrunkKey(wRef(inWire)); - if ( - wRef(inWire).path.length === 0 && - pipeHandleTrunkKeys.has(innerTk) - ) { - currentTk = innerTk; - } else { - parts.push(sRef(wRef(inWire), true)); - break; - } - } - sourcePart = parts.join(":"); - } else { - sourcePart = sRef(fromRef, true); - } - // Serialize safe navigation on alias source - if (wSafe(srcWire)) { - const ref = wRef(srcWire); - if (!ref.rootSafe && !ref.pathSafe?.some((s) => s)) { - if (sourcePart.includes(".")) { - sourcePart = sourcePart.replace(".", "?."); - } - } - } - const aliasFb = serFallbacks(srcWire, sPipeOrRef); - const aliasErrf = serCatch(srcWire, sPipeOrRef); - lines.push(`alias ${alias} <- ${sourcePart}${aliasFb}${aliasErrf}`); - } - // Also emit wires reading from top-level __local bindings - for (const lw of localReadWires) { - // Skip element-targeting reads (emitted inside array blocks) - if ( - lw.to.module === SELF_MODULE && - lw.to.type === bridge.type && - lw.to.field === bridge.field - ) { - // Check if this targets an array element path - const toPathStr = lw.to.path.join("."); - if (toPathStr in arrayIterators) continue; - // Check if any array iterator path is a prefix of this path - let isArrayElement = false; - for (const iterPath of Object.keys(arrayIterators)) { - if (iterPath === "" || toPathStr.startsWith(iterPath + ".")) { - isArrayElement = true; - break; - } - } - if (isArrayElement) continue; - } - const alias = wRef(lw).field; - const safeSep = wSafe(lw) || wRef(lw).rootSafe ? "?." : "."; - const fromPart = - wRef(lw).path.length > 0 - ? alias + - safeSep + - serPath(wRef(lw).path, wRef(lw).rootSafe, wRef(lw).pathSafe) - : alias; - const toStr = sRef(lw.to, false); - const lwFb = serFallbacks(lw, sPipeOrRef); - const lwErrf = serCatch(lw, sPipeOrRef); - lines.push(`${toStr} <- ${fromPart}${lwFb}${lwErrf}`); - } - - // ── Pipe wires ─────────────────────────────────────────────────────── - for (const [tk, outWire] of fromOutMap.entries()) { - if (pipeHandleTrunkKeys.has(refTrunkKey(outWire.to))) continue; - - // ── Expression chain detection ──────────────────────────────────── - // If the outermost fork is an expression fork, recursively reconstruct - // the infix expression tree, respecting precedence grouping. - if (exprForks.has(tk)) { - // Element-targeting expressions are handled in serializeArrayElements - if (isUnderArrayScope(outWire.to)) continue; - // Recursively serialize an expression fork into infix notation. - function serializeExprTree( - forkTk: string, - parentPrec?: number, - ): string | null { - const info = exprForks.get(forkTk); - if (!info) return null; - - // condAnd/condOr logic wire — reconstruct from leftRef/rightRef - if (info.logicWire) { - const logic = wAndOr(info.logicWire!); - let leftStr: string; - const leftTk = refTrunkKey(eRef(logic.left)); - if (eRef(logic.left).path.length === 0 && exprForks.has(leftTk)) { - leftStr = - serializeExprTree(leftTk, OP_PREC_SER[info.op] ?? 0) ?? - sRef(eRef(logic.left), true); - } else { - leftStr = eRef(logic.left).element - ? "ITER." + serPath(eRef(logic.left).path) - : sRef(eRef(logic.left), true); - } - - let rightStr: string; - if (logic.right.type === "ref") { - const rightTk = refTrunkKey(eRef(logic.right)); - if (eRef(logic.right).path.length === 0 && exprForks.has(rightTk)) { - rightStr = - serializeExprTree(rightTk, OP_PREC_SER[info.op] ?? 0) ?? - sRef(eRef(logic.right), true); - } else { - rightStr = eRef(logic.right).element - ? "ITER." + serPath(eRef(logic.right).path) - : sRef(eRef(logic.right), true); - } - } else if (logic.right.type === "literal") { - rightStr = formatExprValue(eVal(logic.right)); - } else { - rightStr = "0"; - } - - let result = `${leftStr} ${info.op} ${rightStr}`; - const myPrec = OP_PREC_SER[info.op] ?? 0; - if (parentPrec != null && myPrec < parentPrec) result = `(${result})`; - return result; - } - - // Serialize left operand (from .a wire) - let leftStr: string | null = null; - if (info.aWire) { - const fromTk = refTrunkKey(wRef(info.aWire!)); - if (wRef(info.aWire!).path.length === 0 && exprForks.has(fromTk)) { - leftStr = serializeExprTree(fromTk, OP_PREC_SER[info.op] ?? 0); - } else { - leftStr = wRef(info.aWire!).element - ? "ITER." + serPath(wRef(info.aWire!).path) - : sRef(wRef(info.aWire!), true); - } - } - - // Serialize right operand (from .b wire) - let rightStr: string; - if (info.bWire && isLit(info.bWire)) { - rightStr = formatExprValue(wVal(info.bWire!)); - } else if (info.bWire && isPull(info.bWire)) { - const bFrom = wRef(info.bWire!); - const bTk = refTrunkKey(bFrom); - if (bFrom.path.length === 0 && exprForks.has(bTk)) { - rightStr = - serializeExprTree(bTk, OP_PREC_SER[info.op] ?? 0) ?? - sRef(bFrom, true); - } else { - rightStr = bFrom.element - ? "ITER." + serPath(bFrom.path) - : sRef(bFrom, true); - } - } else { - rightStr = "0"; - } - - if (leftStr == null) return rightStr; - // Unary `not` — only has .a operand - if (info.op === "not") return `not ${leftStr}`; - let result = `${leftStr} ${info.op} ${rightStr}`; - const myPrec = OP_PREC_SER[info.op] ?? 0; - if (parentPrec != null && myPrec < parentPrec) result = `(${result})`; - return result; - } - - const exprStr = serializeExprTree(tk); - if (exprStr) { - const destStr = sRef(outWire.to, false); - const fallbackStr = serFallbacks(outWire, sPipeOrRef); - const errf = serCatch(outWire, sPipeOrRef); - lines.push(`${destStr} <- ${exprStr}${fallbackStr}${errf}`); - } - continue; - } - - // ── Concat (template string) detection ─────────────────────────── - if (concatForks.has(tk)) { - if (isUnderArrayScope(outWire.to)) continue; // handled in serializeArrayElements - const templateStr = reconstructTemplateString(tk); - if (templateStr) { - const destStr = sRef(outWire.to, false); - const fallbackStr = serFallbacks(outWire, sPipeOrRef); - const errf = serCatch(outWire, sPipeOrRef); - lines.push(`${destStr} <- ${templateStr}${fallbackStr}${errf}`); - } - continue; - } - - // ── Normal pipe chain ───────────────────────────────────────────── - // Element-targeting pipe chains are handled in serializeArrayElements - if (isUnderArrayScope(outWire.to)) continue; - - const handleChain: string[] = []; - let currentTk = tk; - let actualSourceRef: NodeRef | null = null; - for (;;) { - const handleName = handleMap.get(currentTk); - if (!handleName) break; - const inWire = toInMap.get(currentTk); - const fieldName = inWire?.to.path[0] ?? "in"; - const token = - fieldName === "in" ? handleName : `${handleName}.${fieldName}`; - handleChain.push(token); - if (!inWire) break; - const fromTk = refTrunkKey(wRef(inWire)); - if (wRef(inWire).path.length === 0 && pipeHandleTrunkKeys.has(fromTk)) { - currentTk = fromTk; - } else { - actualSourceRef = wRef(inWire); - break; - } - } - - if (actualSourceRef && handleChain.length > 0) { - const sourceStr = sRef(actualSourceRef, true); - const destStr = sRef(outWire.to, false); - const fallbackStr = serFallbacks(outWire, sPipeOrRef); - const errf = serCatch(outWire, sPipeOrRef); - lines.push( - `${destStr} <- ${handleChain.join(":")}:${sourceStr}${fallbackStr}${errf}`, - ); - } - } - - // Force statements - if (bridge.forces) { - for (const f of bridge.forces) { - lines.push( - f.catchError ? `force ${f.handle} catch null` : `force ${f.handle}`, - ); - } - } - - // Indent wire body lines and close the block - for (let i = wireBodyStart; i < lines.length; i++) { - if (lines[i] !== "") lines[i] = ` ${lines[i]}`; - } - lines.push(`}`); - return lines.join("\n"); } -/** - * Recomputes instance numbers from handle bindings in declaration order. - */ -function buildHandleMap(bridge: Bridge): { - handleMap: Map; - inputHandle?: string; - outputHandle?: string; -} { - const handleMap = new Map(); - const instanceCounters = new Map(); - let inputHandle: string | undefined; - let outputHandle: string | undefined; - - for (const h of bridge.handles) { - switch (h.kind) { - case "tool": { - const lastDot = h.name.lastIndexOf("."); - if (lastDot !== -1) { - // Dotted name: module.field - const modulePart = h.name.substring(0, lastDot); - const fieldPart = h.name.substring(lastDot + 1); - const ik = `${modulePart}:${fieldPart}`; - const instance = (instanceCounters.get(ik) ?? 0) + 1; - instanceCounters.set(ik, instance); - handleMap.set( - `${modulePart}:${bridge.type}:${fieldPart}:${instance}`, - h.handle, - ); - } else { - // Simple name: inline tool - const ik = `Tools:${h.name}`; - const instance = (instanceCounters.get(ik) ?? 0) + 1; - instanceCounters.set(ik, instance); - handleMap.set(`${SELF_MODULE}:Tools:${h.name}:${instance}`, h.handle); - } - break; - } - case "input": - inputHandle = h.handle; - break; - case "output": - outputHandle = h.handle; - break; - case "context": - handleMap.set(`${SELF_MODULE}:Context:context`, h.handle); - break; - case "const": - handleMap.set(`${SELF_MODULE}:Const:const`, h.handle); - break; - case "define": - handleMap.set( - `__define_${h.handle}:${bridge.type}:${bridge.field}`, - h.handle, - ); - handleMap.set( - `__define_in_${h.handle}:${bridge.type}:${bridge.field}`, - h.handle, - ); - handleMap.set( - `__define_out_${h.handle}:${bridge.type}:${bridge.field}`, - h.handle, - ); - break; - } - } - - return { handleMap, inputHandle, outputHandle }; -} - -function serializeRef( - ref: NodeRef, - bridge: Bridge, - handleMap: Map, - inputHandle: string | undefined, - outputHandle: string | undefined, - isFrom: boolean, -): string { - if (ref.element) { - // Element refs are only serialized inside brace blocks (using the iterator name). - // This path should not be reached in normal serialization. - return "item." + serPath(ref.path); - } - - const hasSafe = ref.rootSafe || ref.pathSafe?.some((s) => s); - const firstSep = hasSafe && ref.rootSafe ? "?." : "."; +// ── Serializer ─────────────────────────────────────────────────────────────── - /** Join a handle/prefix with a serialized path, omitting the dot when - * the path starts with a bracket index (e.g. `geo` + `[0].lat` → `geo[0].lat`). */ - function joinHandlePath( - prefix: string, - sep: string, - pathStr: string, - ): string { - if (pathStr.startsWith("[")) return prefix + pathStr; - return prefix + sep + pathStr; - } +export function serializeBridge(doc: BridgeDocument): string { + const version = doc.version ?? BRIDGE_VERSION; + const { instructions } = doc; + if (instructions.length === 0) return ""; - // Bridge's own trunk (no instance, no element) - const isBridgeTrunk = - ref.module === SELF_MODULE && - ref.type === bridge.type && - ref.field === bridge.field && - !ref.instance && - !ref.element; + const blocks: string[] = []; - if (isBridgeTrunk) { - if (isFrom && inputHandle) { - // From side: use input handle (data comes from args) - return ref.path.length > 0 - ? joinHandlePath( - inputHandle, - firstSep, - serPath(ref.path, ref.rootSafe, ref.pathSafe), - ) - : inputHandle; - } - if (isFrom && !inputHandle && outputHandle) { - // From side reading the output itself (self-referencing bridge trunk) - return ref.path.length > 0 - ? joinHandlePath( - outputHandle, - firstSep, - serPath(ref.path, ref.rootSafe, ref.pathSafe), - ) - : outputHandle; - } - if (!isFrom && outputHandle) { - // To side: use output handle - return ref.path.length > 0 - ? joinHandlePath(outputHandle, ".", serPath(ref.path)) - : outputHandle; + // Group consecutive const declarations into a single block + let i = 0; + while (i < instructions.length) { + const instr = instructions[i]!; + if (instr.kind === "const") { + const constLines: string[] = []; + while (i < instructions.length && instructions[i]!.kind === "const") { + const c = instructions[i] as ConstDef; + constLines.push(`const ${c.name} = ${c.value}`); + i++; + } + blocks.push(constLines.join("\n")); + } else if (instr.kind === "tool") { + blocks.push(serializeToolBlock(instr as ToolDef)); + i++; + } else if (instr.kind === "define") { + blocks.push(serializeDefineBlock(instr as DefineDef)); + i++; + } else { + blocks.push(serializeBridgeBlock(instr as Bridge)); + i++; } - // Fallback (no handle declared — legacy/serializer-only path) - return serPath(ref.path, ref.rootSafe, ref.pathSafe); - } - - // Lookup by trunk key - const trunkStr = - ref.instance != null - ? `${ref.module}:${ref.type}:${ref.field}:${ref.instance}` - : `${ref.module}:${ref.type}:${ref.field}`; - const handle = handleMap.get(trunkStr); - if (handle) { - if (ref.path.length === 0) return handle; - return joinHandlePath( - handle, - firstSep, - serPath(ref.path, ref.rootSafe, ref.pathSafe), - ); } - // Fallback: bare path - return serPath(ref.path, ref.rootSafe, ref.pathSafe); + return `version ${version}\n\n` + blocks.join("\n\n") + "\n"; } -/** - * Serialize a path array to dot notation with [n] for numeric indices. - * When `rootSafe` or `pathSafe` are provided, emits `?.` for safe segments. - */ function serPath( path: string[], rootSafe?: boolean, diff --git a/packages/bridge-parser/src/language-service.ts b/packages/bridge-parser/src/language-service.ts index 82c60dfc..45df7dfa 100644 --- a/packages/bridge-parser/src/language-service.ts +++ b/packages/bridge-parser/src/language-service.ts @@ -253,16 +253,19 @@ export class BridgeLanguageService { if (closestInst.kind === "bridge") { if (word === closestInst.type || word === closestInst.field) { const hc = closestInst.handles.length; - const wc = closestInst.wires.length; + const wc = closestInst.body.length; return { - content: `**Bridge** \`${closestInst.type}.${closestInst.field}\`\n\n${hc} handle${hc !== 1 ? "s" : ""} · ${wc} wire${wc !== 1 ? "s" : ""}`, + content: `**Bridge** \`${closestInst.type}.${closestInst.field}\`\n\n${hc} handle${hc !== 1 ? "s" : ""} · ${wc} statement${wc !== 1 ? "s" : ""}`, }; } } if (closestInst.kind === "define" && word === closestInst.name) { + const wireCount = closestInst.body.filter( + (s) => s.kind === "wire" || s.kind === "alias" || s.kind === "spread", + ).length; return { - content: `**Define** \`${closestInst.name}\`\n\nReusable subgraph (${closestInst.handles.length} handles · ${closestInst.wires.length} wires)`, + content: `**Define** \`${closestInst.name}\`\n\nReusable subgraph (${closestInst.handles.length} handle${closestInst.handles.length === 1 ? "" : "s"} · ${wireCount} wire${wireCount === 1 ? "" : "s"})`, }; } } @@ -279,9 +282,9 @@ export class BridgeLanguageService { ) { const fn = closestInst.fn ?? `extends ${closestInst.extends}`; const dc = closestInst.handles.length; - const wc = closestInst.wires.length; + const wc = closestInst.body.length; return { - content: `**Tool** \`${closestInst.name}\`\n\nFunction: \`${fn}\`\n\n${dc} dep${dc !== 1 ? "s" : ""} · ${wc} wire${wc !== 1 ? "s" : ""}`, + content: `**Tool** \`${closestInst.name}\`\n\nFunction: \`${fn}\`\n\n${dc} dep${dc !== 1 ? "s" : ""} · ${wc} statement${wc !== 1 ? "s" : ""}`, }; } } diff --git a/packages/bridge-parser/src/parser/ast-builder.ts b/packages/bridge-parser/src/parser/ast-builder.ts new file mode 100644 index 00000000..4aca6ac4 --- /dev/null +++ b/packages/bridge-parser/src/parser/ast-builder.ts @@ -0,0 +1,2191 @@ +/** + * AST Builder — converts Chevrotain CST into the nested Statement[]-based IR. + * + * This is a clean reimplementation of the CST→AST visitor that produces + * `body: Statement[]` directly, without the legacy flat `Wire[]` intermediate. + * + * Key differences from the legacy `buildBridgeBody()`: + * - Scope blocks (`target { ... }`) become `ScopeStatement` nodes (not flattened) + * - Array mappings become `ArrayExpression` in Expression trees (not metadata) + * - Operators (+, -, *, /, ==, etc.) become `BinaryExpression` nodes (not tool forks) + * - `not` becomes `UnaryExpression` (not a tool fork) + * - Template strings become `ConcatExpression` (not a tool fork) + * - Pipe chains become `PipeExpression` (not synthetic fork wires) + * - Literal values are pre-parsed `JsonValue` (not JSON-encoded strings) + */ +import type { CstNode, IToken } from "chevrotain"; +import type { + BinaryOp, + DefineDef, + Expression, + ForceStatement, + HandleBinding, + Instruction, + JsonValue, + NodeRef, + ScopeStatement, + SourceChain, + SpreadStatement, + Statement, + WireAliasStatement, + WireCatch, + WireSourceEntry, + WireStatement, + WithStatement, +} from "@stackables/bridge-core"; +import { SELF_MODULE } from "@stackables/bridge-core"; +import type { SourceLocation } from "@stackables/bridge-types"; + +// ── CST Navigation Helpers ────────────────────────────────────────────────── + +function sub(node: CstNode, ruleName: string): CstNode | undefined { + return (node.children[ruleName] as CstNode[] | undefined)?.[0]; +} + +function subs(node: CstNode, ruleName: string): CstNode[] { + return (node.children[ruleName] as CstNode[] | undefined) ?? []; +} + +function tok(node: CstNode, label: string): IToken | undefined { + return (node.children[label] as IToken[] | undefined)?.[0]; +} + +function toks(node: CstNode, label: string): IToken[] { + return (node.children[label] as IToken[] | undefined) ?? []; +} + +function line(token: IToken | undefined): number { + return token?.startLine ?? 0; +} + +function makeLoc( + start: IToken | undefined, + end: IToken | undefined = start, +): SourceLocation | undefined { + if (!start) return undefined; + const last = end ?? start; + return { + startLine: start.startLine ?? 0, + startColumn: start.startColumn ?? 0, + endLine: last.endLine ?? last.startLine ?? 0, + endColumn: last.endColumn ?? last.startColumn ?? 0, + }; +} + +// ── Token / Node extraction ───────────────────────────────────────────────── + +function extractNameToken(node: CstNode): string { + for (const key of Object.keys(node.children)) { + const tokens = node.children[key] as IToken[] | undefined; + if (tokens?.[0]) return tokens[0].image; + } + return ""; +} + +function extractDottedName(node: CstNode): string { + const first = extractNameToken(sub(node, "first")!); + const rest = subs(node, "rest").map((n) => extractNameToken(n)); + return [first, ...rest].join("."); +} + +function extractPathSegment(node: CstNode): string { + for (const key of Object.keys(node.children)) { + const tokens = node.children[key] as IToken[] | undefined; + if (tokens?.[0]) return tokens[0].image; + } + return ""; +} + +function extractDottedPathStr(node: CstNode): string { + const first = extractPathSegment(sub(node, "first")!); + const rest = subs(node, "rest").map((n) => extractPathSegment(n)); + return [first, ...rest].join("."); +} + +function findFirstToken(node: CstNode): IToken | undefined { + for (const key of Object.keys(node.children)) { + const child = node.children[key]; + if (!Array.isArray(child)) continue; + for (const item of child) { + if ("image" in item) return item as IToken; + if ("children" in item) { + const found = findFirstToken(item as CstNode); + if (found) return found; + } + } + } + return undefined; +} + +function findLastToken(node: CstNode): IToken | undefined { + const keys = Object.keys(node.children); + for (let k = keys.length - 1; k >= 0; k--) { + const child = node.children[keys[k]]; + if (!Array.isArray(child)) continue; + for (let i = child.length - 1; i >= 0; i--) { + const item = child[i]; + if ("image" in item) return item as IToken; + if ("children" in item) { + const found = findLastToken(item as CstNode); + if (found) return found; + } + } + } + return undefined; +} + +function locFromNode(node: CstNode | undefined): SourceLocation | undefined { + if (!node) return undefined; + return makeLoc(findFirstToken(node), findLastToken(node)); +} + +function parsePath(text: string): string[] { + return text.split(/\.|\[|\]/).filter(Boolean); +} + +function extractBareValue(node: CstNode): string { + for (const key of Object.keys(node.children)) { + const tokens = node.children[key] as IToken[] | undefined; + if (tokens?.[0]) { + let val = tokens[0].image; + if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1); + return val; + } + } + return ""; +} + +function reconstructJson(node: CstNode): string { + const tokens: IToken[] = []; + collectTokens(node, tokens); + tokens.sort((a, b) => a.startOffset - b.startOffset); + if (tokens.length === 0) return ""; + let result = tokens[0].image; + for (let i = 1; i < tokens.length; i++) { + const gap = + tokens[i].startOffset - + (tokens[i - 1].startOffset + tokens[i - 1].image.length); + if (gap > 0) result += " ".repeat(gap); + result += tokens[i].image; + } + return result; +} + +function collectTokens(node: CstNode, out: IToken[]): void { + for (const key of Object.keys(node.children)) { + const children = node.children[key]; + if (!Array.isArray(children)) continue; + for (const child of children) { + if ("image" in child) out.push(child as IToken); + else if ("children" in child) collectTokens(child as CstNode, out); + } + } +} + +// ── Address path extraction ───────────────────────────────────────────────── + +function extractAddressPath(node: CstNode): { + root: string; + segments: string[]; + safe?: boolean; + rootSafe?: boolean; + segmentSafe?: boolean[]; +} { + const root = extractNameToken(sub(node, "root")!); + type Seg = { offset: number; value: string }; + const items: Seg[] = []; + const safeNavTokens = (node.children.safeNav as IToken[] | undefined) ?? []; + const hasSafeNav = safeNavTokens.length > 0; + const dotTokens = (node.children.Dot as IToken[] | undefined) ?? []; + + for (const seg of subs(node, "segment")) { + items.push({ + offset: + seg.location?.startOffset ?? findFirstToken(seg)?.startOffset ?? 0, + value: extractPathSegment(seg), + }); + } + for (const idxTok of toks(node, "arrayIndex")) { + if (idxTok.image.includes(".")) { + throw new Error( + `Line ${idxTok.startLine}: Array indices must be integers, found "${idxTok.image}"`, + ); + } + items.push({ offset: idxTok.startOffset, value: idxTok.image }); + } + items.sort((a, b) => a.offset - b.offset); + + const allSeps: { offset: number; isSafe: boolean }[] = [ + ...dotTokens.map((t) => ({ offset: t.startOffset, isSafe: false })), + ...safeNavTokens.map((t) => ({ offset: t.startOffset, isSafe: true })), + ].sort((a, b) => a.offset - b.offset); + + const segmentSafe: boolean[] = []; + let rootSafe = false; + let sepIdx = -1; + for (let i = 0; i < items.length; i++) { + const segOffset = items[i].offset; + while ( + sepIdx + 1 < allSeps.length && + allSeps[sepIdx + 1].offset < segOffset + ) { + sepIdx++; + } + const isSafe = sepIdx >= 0 ? allSeps[sepIdx].isSafe : false; + if (i === 0) { + rootSafe = isSafe; + } + segmentSafe.push(isSafe); + } + + return { + root, + segments: items.map((i) => i.value), + ...(hasSafeNav ? { safe: true } : {}), + ...(rootSafe ? { rootSafe } : {}), + ...(segmentSafe.some((s) => s) ? { segmentSafe } : {}), + }; +} + +// ── Template string parsing ───────────────────────────────────────────────── + +type TemplateSeg = + | { kind: "text"; value: string } + | { kind: "ref"; path: string }; + +function parseTemplateString(raw: string): TemplateSeg[] | null { + const segs: TemplateSeg[] = []; + let i = 0; + let hasRef = false; + let text = ""; + while (i < raw.length) { + if (raw[i] === "\\" && i + 1 < raw.length) { + if (raw[i + 1] === "{") { + text += "{"; + i += 2; + continue; + } + text += raw[i] + raw[i + 1]; + i += 2; + continue; + } + if (raw[i] === "{") { + const end = raw.indexOf("}", i + 1); + if (end === -1) { + text += raw[i]; + i++; + continue; + } + const ref = raw.slice(i + 1, end).trim(); + if (ref.length === 0) { + text += "{}"; + i = end + 1; + continue; + } + if (text.length > 0) { + segs.push({ kind: "text", value: text }); + text = ""; + } + segs.push({ kind: "ref", path: ref }); + hasRef = true; + i = end + 1; + continue; + } + text += raw[i]; + i++; + } + if (text.length > 0) segs.push({ kind: "text", value: text }); + return hasRef ? segs : null; +} + +// ── Literal parsing ───────────────────────────────────────────────────────── + +/** Parse a JSON-encoded string into a JsonValue. */ +function parseLiteral(raw: string): JsonValue { + const trimmed = raw.trim(); + if (trimmed === "true") return true; + if (trimmed === "false") return false; + if (trimmed === "null") return null; + if ( + trimmed.length >= 2 && + trimmed.charCodeAt(0) === 0x22 && + trimmed.charCodeAt(trimmed.length - 1) === 0x22 + ) { + // JSON string — parse it + return JSON.parse(trimmed) as string; + } + const num = Number(trimmed); + if (trimmed !== "" && !isNaN(num) && isFinite(num)) return num; + // Attempt JSON parse for objects/arrays + try { + return JSON.parse(trimmed) as JsonValue; + } catch { + return trimmed; + } +} + +// ── Reserved keywords ─────────────────────────────────────────────────────── + +const RESERVED_KEYWORDS = new Set([ + "version", + "tool", + "bridge", + "define", + "const", + "with", + "as", + "from", + "extends", + "alias", + "force", + "catch", + "throw", + "panic", + "continue", + "break", + "not", + "and", + "or", + "memoize", + "true", + "false", + "null", +]); + +const SOURCE_IDENTIFIERS = new Set(["input", "output", "context"]); + +function assertNotReserved(name: string, lineNum: number, label: string) { + if (RESERVED_KEYWORDS.has(name.toLowerCase())) { + throw new Error( + `Line ${lineNum}: "${name}" is a reserved keyword and cannot be used as a ${label}`, + ); + } + if (SOURCE_IDENTIFIERS.has(name.toLowerCase())) { + throw new Error( + `Line ${lineNum}: "${name}" is a reserved source identifier and cannot be used as a ${label}`, + ); + } +} + +// ── Operator precedence ───────────────────────────────────────────────────── + +const OP_TO_BINARY: Record = { + "*": "mul", + "/": "div", + "+": "add", + "-": "sub", + "==": "eq", + "!=": "neq", + ">": "gt", + ">=": "gte", + "<": "lt", + "<=": "lte", +}; + +const OP_PREC: Record = { + "*": 4, + "/": 4, + "+": 3, + "-": 3, + "==": 2, + "!=": 2, + ">": 2, + ">=": 2, + "<": 2, + "<=": 2, + and: 1, + or: 0, +}; + +function extractExprOpStr(opNode: CstNode): string { + const c = opNode.children; + if (c.star) return "*"; + if (c.slash) return "/"; + if (c.plus) return "+"; + if (c.minus) return "-"; + if (c.doubleEquals) return "=="; + if (c.notEquals) return "!="; + if (c.greaterEqual) return ">="; + if (c.lessEqual) return "<="; + if (c.greaterThan) return ">"; + if (c.lessThan) return "<"; + if (c.andKw) return "and"; + if (c.orKw) return "or"; + throw new Error("Invalid expression operator"); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Handle Resolution +// ═══════════════════════════════════════════════════════════════════════════ + +type HandleResolution = { + module: string; + type: string; + field: string; + instance?: number; +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// Body Builder — produces Statement[] from bridgeBodyLine CST nodes +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Build a `Statement[]` body from bridge/define/tool body CST lines. + * + * This is the core of the new AST builder. It processes with-declarations + * first (to build the handle resolution map), then processes wires/aliases/ + * force/scope statements to produce the nested IR. + */ +export function buildBody( + bodyLines: CstNode[], + bridgeType: string, + bridgeField: string, + previousInstructions: Instruction[], + options?: { + forbiddenHandleKinds?: Set; + selfWireNodes?: CstNode[]; + spreadNodes?: CstNode[]; + }, +): { + handles: HandleBinding[]; + body: Statement[]; + handleRes: Map; +} { + const handleBindings: HandleBinding[] = []; + const handleRes = new Map(); + const instanceCounters = new Map(); + const body: Statement[] = []; + + // ── Step 1: Process with-declarations ───────────────────────────────── + + for (const bodyLine of bodyLines) { + const withNode = sub(bodyLine, "bridgeWithDecl"); + if (!withNode) continue; + const wc = withNode.children; + const lineNum = line(findFirstToken(withNode)); + + const checkDuplicate = (handle: string) => { + if (handleRes.has(handle)) { + throw new Error(`Line ${lineNum}: Duplicate handle name "${handle}"`); + } + }; + + let binding: HandleBinding | undefined; + let resolution: HandleResolution | undefined; + + if (wc.inputKw) { + if (options?.forbiddenHandleKinds?.has("input")) { + throw new Error( + `Line ${lineNum}: 'with input' is not allowed in tool blocks`, + ); + } + if (wc.memoizeKw) { + throw new Error( + `Line ${lineNum}: memoize is only valid for tool references`, + ); + } + const handle = wc.inputAlias + ? extractNameToken((wc.inputAlias as CstNode[])[0]) + : "input"; + checkDuplicate(handle); + binding = { handle, kind: "input" }; + resolution = { + module: SELF_MODULE, + type: bridgeType, + field: bridgeField, + }; + } else if (wc.outputKw) { + if (options?.forbiddenHandleKinds?.has("output")) { + throw new Error( + `Line ${lineNum}: 'with output' is not allowed in tool blocks`, + ); + } + if (wc.memoizeKw) { + throw new Error( + `Line ${lineNum}: memoize is only valid for tool references`, + ); + } + const handle = wc.outputAlias + ? extractNameToken((wc.outputAlias as CstNode[])[0]) + : "output"; + checkDuplicate(handle); + binding = { handle, kind: "output" }; + resolution = { + module: SELF_MODULE, + type: bridgeType, + field: bridgeField, + }; + } else if (wc.contextKw) { + if (wc.memoizeKw) { + throw new Error( + `Line ${lineNum}: memoize is only valid for tool references`, + ); + } + const handle = wc.contextAlias + ? extractNameToken((wc.contextAlias as CstNode[])[0]) + : "context"; + checkDuplicate(handle); + binding = { handle, kind: "context" }; + resolution = { + module: SELF_MODULE, + type: "Context", + field: "context", + }; + } else if (wc.constKw) { + if (wc.memoizeKw) { + throw new Error( + `Line ${lineNum}: memoize is only valid for tool references`, + ); + } + const handle = wc.constAlias + ? extractNameToken((wc.constAlias as CstNode[])[0]) + : "const"; + checkDuplicate(handle); + binding = { handle, kind: "const" }; + resolution = { + module: SELF_MODULE, + type: "Const", + field: "const", + }; + } else if (wc.refName) { + const name = extractDottedName((wc.refName as CstNode[])[0]); + const versionTag = ( + wc.refVersion as IToken[] | undefined + )?.[0]?.image.slice(1); + const lastDot = name.lastIndexOf("."); + const defaultHandle = lastDot !== -1 ? name.substring(lastDot + 1) : name; + const handle = wc.refAlias + ? extractNameToken((wc.refAlias as CstNode[])[0]) + : defaultHandle; + const memoize = !!wc.memoizeKw; + + checkDuplicate(handle); + if (wc.refAlias) assertNotReserved(handle, lineNum, "handle alias"); + + const defineDef = previousInstructions.find( + (inst): inst is DefineDef => + inst.kind === "define" && inst.name === name, + ); + if (defineDef) { + if (memoize) { + throw new Error( + `Line ${lineNum}: memoize is only valid for tool references`, + ); + } + binding = { handle, kind: "define", name }; + resolution = { + module: `__define_${handle}`, + type: bridgeType, + field: bridgeField, + }; + } else if (lastDot !== -1) { + const modulePart = name.substring(0, lastDot); + const fieldPart = name.substring(lastDot + 1); + const key = `${modulePart}:${fieldPart}`; + const instance = (instanceCounters.get(key) ?? 0) + 1; + instanceCounters.set(key, instance); + binding = { + handle, + kind: "tool", + name, + ...(memoize ? { memoize: true as const } : {}), + ...(versionTag ? { version: versionTag } : {}), + }; + resolution = { + module: modulePart, + type: bridgeType, + field: fieldPart, + instance, + }; + } else { + const key = `Tools:${name}`; + const instance = (instanceCounters.get(key) ?? 0) + 1; + instanceCounters.set(key, instance); + binding = { + handle, + kind: "tool", + name, + ...(memoize ? { memoize: true as const } : {}), + ...(versionTag ? { version: versionTag } : {}), + }; + resolution = { + module: SELF_MODULE, + type: "Tools", + field: name, + instance, + }; + } + } + + if (binding && resolution) { + handleBindings.push(binding); + handleRes.set(binding.handle, resolution); + body.push({ kind: "with", binding } satisfies WithStatement); + } + } + + // ── Address resolution helpers ──────────────────────────────────────── + + function resolveAddress( + root: string, + segments: string[], + lineNum: number, + ): NodeRef { + if (root === "") { + throw new Error( + `Line ${lineNum}: Self-reference creates a circular dependency. Remove the leading dot or use a declared handle.`, + ); + } + const resolution = handleRes.get(root); + if (!resolution) { + if (segments.length === 0) { + throw new Error( + `Line ${lineNum}: Undeclared reference "${root}". Add 'with output as o' for output fields, or 'with ${root}' for a tool.`, + ); + } + throw new Error( + `Line ${lineNum}: Undeclared handle "${root}". Add 'with ${root}' or 'with ${root} as ${root}' to the bridge header.`, + ); + } + const ref: NodeRef = { + module: resolution.module, + type: resolution.type, + field: resolution.field, + path: [...segments], + }; + if (resolution.instance != null) ref.instance = resolution.instance; + return ref; + } + + function resolveIterRef( + root: string, + segments: string[], + iterScope?: string[], + ): NodeRef | undefined { + if (!iterScope) return undefined; + for (let index = iterScope.length - 1; index >= 0; index--) { + if (iterScope[index] !== root) continue; + const elementDepth = iterScope.length - 1 - index; + return { + module: SELF_MODULE, + type: bridgeType, + field: bridgeField, + element: true, + ...(elementDepth > 0 ? { elementDepth } : {}), + path: [...segments], + }; + } + return undefined; + } + + function resolveRef( + root: string, + segments: string[], + lineNum: number, + iterScope?: string[], + ): NodeRef { + const iterRef = resolveIterRef(root, segments, iterScope); + if (iterRef) return iterRef; + return resolveAddress(root, segments, lineNum); + } + + function assertNoTargetIndices(ref: NodeRef, lineNum: number): void { + if (ref.path.some((seg) => /^\d+$/.test(seg))) { + throw new Error( + `Line ${lineNum}: Explicit array index in wire target is not supported. Use array mapping (\`[] as iter { }\`) instead.`, + ); + } + } + + // ── Expression builders ─────────────────────────────────────────────── + + /** + * Build an Expression from a sourceExpr CST node. + * Handles simple refs and pipe chains. + */ + function buildSourceExpression( + sourceNode: CstNode, + lineNum: number, + iterScope?: string[], + ): Expression { + const loc = locFromNode(sourceNode); + const headNode = sub(sourceNode, "head")!; + const pipeNodes = subs(sourceNode, "pipeSegment"); + + if (pipeNodes.length === 0) { + // Simple ref: handle.path.to.data + const { root, segments, safe, rootSafe, segmentSafe } = + extractAddressPath(headNode); + const ref = resolveRef(root, segments, lineNum, iterScope); + const fullRef: NodeRef = { + ...ref, + ...(rootSafe ? { rootSafe: true } : {}), + ...(segmentSafe ? { pathSafe: segmentSafe } : {}), + }; + return { + type: "ref", + ref: fullRef, + ...(safe ? { safe: true as const } : {}), + loc, + }; + } + + // Pipe chain: handle:source or handle.path:source + // CST gives us [head, ...pipeSegment] — last is the data source, + // everything before are pipe handles. + const allParts = [headNode, ...pipeNodes]; + const actualSourceNode = allParts[allParts.length - 1]; + const pipeChainNodes = allParts.slice(0, -1); + + // Validate pipe handles + for (const pipeNode of pipeChainNodes) { + const { root } = extractAddressPath(pipeNode); + if (!handleRes.has(root)) { + throw new Error( + `Line ${lineNum}: Undeclared handle in pipe: "${root}". Add 'with as ${root}' to the bridge header.`, + ); + } + } + + // Build the innermost source expression + const { + root: srcRoot, + segments: srcSegments, + safe: srcSafe, + rootSafe: srcRootSafe, + segmentSafe: srcSegmentSafe, + } = extractAddressPath(actualSourceNode); + const srcRef = resolveRef(srcRoot, srcSegments, lineNum, iterScope); + let expr: Expression = { + type: "ref", + ref: { + ...srcRef, + ...(srcRootSafe ? { rootSafe: true } : {}), + ...(srcSegmentSafe ? { pathSafe: srcSegmentSafe } : {}), + }, + ...(srcSafe ? { safe: true as const } : {}), + loc, + }; + + // Wrap in PipeExpressions from innermost (rightmost) to outermost (leftmost) + const reversed = [...pipeChainNodes].reverse(); + for (const pNode of reversed) { + const { root: handleName, segments: handleSegs } = + extractAddressPath(pNode); + const path = handleSegs.length > 0 ? handleSegs : undefined; + expr = { + type: "pipe", + source: expr, + handle: handleName, + ...(path ? { path } : {}), + loc, + }; + } + + return expr; + } + + /** + * Build a concat Expression from template string segments. + */ + function buildConcatExpression( + segs: TemplateSeg[], + lineNum: number, + iterScope?: string[], + loc?: SourceLocation, + ): Expression { + const parts: Expression[] = []; + for (const seg of segs) { + if (seg.kind === "text") { + parts.push({ type: "literal", value: seg.value, loc }); + } else { + parts.push( + buildTemplateSegExpression(seg.path, lineNum, iterScope, loc), + ); + } + } + return { type: "concat", parts, loc }; + } + + /** + * Build an Expression from a raw template segment path. + * Handles simple refs ("handle.field") and pipe chains ("pipe:handle.field"). + */ + function buildTemplateSegExpression( + segPath: string, + lineNum: number, + iterScope?: string[], + loc?: SourceLocation, + ): Expression { + // Split on ":" to detect pipe chains: "toUpper:i.symbol" → ["toUpper", "i.symbol"] + const colonIdx = segPath.indexOf(":"); + if (colonIdx === -1) { + // Simple ref: "handle.field.subfield" + const dotParts = segPath.split("."); + const root = dotParts[0]!; + const path = dotParts.slice(1); + const ref = resolveRef(root, path, lineNum, iterScope); + return { type: "ref", ref, loc }; + } + + // Pipe chain: split on ":" — everything before the last segment are pipe handles, + // the last segment is the actual data source ref. + const pipeAndSource = segPath.split(":"); + const pipeHandles = pipeAndSource.slice(0, -1); + const sourceSegment = pipeAndSource[pipeAndSource.length - 1]!; + + // Build the innermost source ref + const dotParts = sourceSegment.split("."); + const root = dotParts[0]!; + const path = dotParts.slice(1); + const ref = resolveRef(root, path, lineNum, iterScope); + + // Validate pipe handles + for (const handle of pipeHandles) { + if (!handleRes.has(handle)) { + throw new Error( + `Line ${lineNum}: Undeclared handle in pipe: "${handle}". Add 'with as ${handle}' to the bridge header.`, + ); + } + } + + // Wrap in PipeExpressions from innermost (rightmost) to outermost (leftmost) + let expr: Expression = { type: "ref", ref, loc }; + for (const handle of [...pipeHandles].reverse()) { + expr = { type: "pipe", source: expr, handle, loc }; + } + return expr; + } + + /** + * Resolve an expression operand (right side of binary op). + */ + function resolveOperandExpression( + operandNode: CstNode, + lineNum: number, + iterScope?: string[], + ): Expression { + const c = operandNode.children; + const loc = locFromNode(operandNode); + + if (c.numberLit) { + return { + type: "literal", + value: Number((c.numberLit as IToken[])[0].image), + loc, + }; + } + if (c.stringLit) { + const raw = (c.stringLit as IToken[])[0].image; + const content = raw.slice(1, -1); + const segs = parseTemplateString(content); + if (segs) return buildConcatExpression(segs, lineNum, iterScope, loc); + return { type: "literal", value: content, loc }; + } + if (c.trueLit) return { type: "literal", value: true, loc }; + if (c.falseLit) return { type: "literal", value: false, loc }; + if (c.nullLit) return { type: "literal", value: null, loc }; + if (c.sourceRef) { + return buildSourceExpression( + (c.sourceRef as CstNode[])[0], + lineNum, + iterScope, + ); + } + if (c.parenExpr) { + return buildParenExpression( + (c.parenExpr as CstNode[])[0], + lineNum, + iterScope, + ); + } + throw new Error(`Line ${lineNum}: Invalid expression operand`); + } + + /** + * Build an expression chain with operator precedence. + * Returns a single Expression tree. + */ + function buildExprChain( + left: Expression, + exprOps: CstNode[], + exprRights: CstNode[], + lineNum: number, + iterScope?: string[], + loc?: SourceLocation, + ): Expression { + const operands: Expression[] = [left]; + const ops: string[] = []; + + for (let i = 0; i < exprOps.length; i++) { + ops.push(extractExprOpStr(exprOps[i])); + operands.push( + resolveOperandExpression(exprRights[i], lineNum, iterScope), + ); + } + + // Reduce a precedence level: fold all ops at `prec` left-to-right + function reduceLevel(prec: number): void { + let i = 0; + while (i < ops.length) { + if ((OP_PREC[ops[i]] ?? 0) !== prec) { + i++; + continue; + } + const opStr = ops[i]; + const l = operands[i]; + const r = operands[i + 1]; + + let expr: Expression; + if (opStr === "and") { + expr = { type: "and", left: l, right: r, loc }; + } else if (opStr === "or") { + expr = { type: "or", left: l, right: r, loc }; + } else { + const op = OP_TO_BINARY[opStr]; + if (!op) + throw new Error(`Line ${lineNum}: Unknown operator "${opStr}"`); + expr = { type: "binary", op, left: l, right: r, loc }; + } + operands.splice(i, 2, expr); + ops.splice(i, 1); + } + } + + reduceLevel(4); // * / + reduceLevel(3); // + - + reduceLevel(2); // == != > >= < <= + reduceLevel(1); // and + reduceLevel(0); // or + + return operands[0]; + } + + /** + * Build expression from a parenthesized sub-expression. + */ + function buildParenExpression( + parenNode: CstNode, + lineNum: number, + iterScope?: string[], + ): Expression { + const pc = parenNode.children; + const innerSourceNode = sub(parenNode, "parenSource")!; + const innerOps = subs(parenNode, "parenExprOp"); + const innerRights = subs(parenNode, "parenExprRight"); + const hasNot = !!(pc.parenNotPrefix as IToken[] | undefined)?.length; + + let expr = buildSourceExpression(innerSourceNode, lineNum, iterScope); + + if (innerOps.length > 0) { + expr = buildExprChain( + expr, + innerOps, + innerRights, + lineNum, + iterScope, + locFromNode(parenNode), + ); + } + + if (hasNot) { + expr = { + type: "unary", + op: "not", + operand: expr, + loc: locFromNode(parenNode), + }; + } + + return expr; + } + + /** + * Resolve a ternary branch to an Expression. + */ + function buildTernaryBranch( + branchNode: CstNode, + lineNum: number, + iterScope?: string[], + ): Expression { + const c = branchNode.children; + const loc = locFromNode(branchNode); + + if (c.stringLit) { + const raw = (c.stringLit as IToken[])[0].image; + const content = raw.slice(1, -1); + const segs = parseTemplateString(content); + if (segs) return buildConcatExpression(segs, lineNum, iterScope, loc); + return { type: "literal", value: JSON.parse(raw) as JsonValue, loc }; + } + if (c.numberLit) + return { + type: "literal", + value: Number((c.numberLit as IToken[])[0].image), + loc, + }; + if (c.trueLit) return { type: "literal", value: true, loc }; + if (c.falseLit) return { type: "literal", value: false, loc }; + if (c.nullLit) return { type: "literal", value: null, loc }; + if (c.sourceRef) { + const addrNode = (c.sourceRef as CstNode[])[0]; + const { root, segments, rootSafe, segmentSafe } = + extractAddressPath(addrNode); + const ref = resolveRef(root, segments, lineNum, iterScope); + return { + type: "ref", + ref: { + ...ref, + ...(rootSafe ? { rootSafe: true } : {}), + ...(segmentSafe ? { pathSafe: segmentSafe } : {}), + }, + loc, + }; + } + throw new Error(`Line ${lineNum}: Invalid ternary branch`); + } + + /** + * Build a coalesce alternative as an Expression. + */ + function buildCoalesceAltExpression( + altNode: CstNode, + lineNum: number, + iterScope?: string[], + ): Expression { + const c = altNode.children; + const loc = locFromNode(altNode); + + if (c.throwKw) { + const msg = (c.throwMsg as IToken[])[0].image; + return { + type: "control", + control: { kind: "throw", message: JSON.parse(msg) as string }, + loc, + }; + } + if (c.panicKw) { + const msg = (c.panicMsg as IToken[])[0].image; + return { + type: "control", + control: { kind: "panic", message: JSON.parse(msg) as string }, + loc, + }; + } + if (c.continueKw) { + const raw = (c.continueLevel as IToken[] | undefined)?.[0]?.image; + const levels = raw ? Number(raw) : undefined; + if (levels !== undefined && (!Number.isInteger(levels) || levels < 1)) { + throw new Error( + `Line ${lineNum}: continue level must be a positive integer`, + ); + } + return { + type: "control", + control: { + kind: "continue", + ...(levels ? { levels } : {}), + }, + loc, + }; + } + if (c.breakKw) { + const raw = (c.breakLevel as IToken[] | undefined)?.[0]?.image; + const levels = raw ? Number(raw) : undefined; + if (levels !== undefined && (!Number.isInteger(levels) || levels < 1)) { + throw new Error( + `Line ${lineNum}: break level must be a positive integer`, + ); + } + return { + type: "control", + control: { + kind: "break", + ...(levels ? { levels } : {}), + }, + loc, + }; + } + if (c.stringLit) { + const raw = (c.stringLit as IToken[])[0].image; + const content = raw.slice(1, -1); + const segs = parseTemplateString(content); + if (segs) return buildConcatExpression(segs, lineNum, iterScope, loc); + return { type: "literal", value: JSON.parse(raw) as JsonValue, loc }; + } + if (c.numberLit) + return { + type: "literal", + value: Number((c.numberLit as IToken[])[0].image), + loc, + }; + if (c.trueLit) return { type: "literal", value: true, loc }; + if (c.falseLit) return { type: "literal", value: false, loc }; + if (c.nullLit) return { type: "literal", value: null, loc }; + if (c.objectLit) { + const jsonStr = reconstructJson((c.objectLit as CstNode[])[0]); + return { type: "literal", value: JSON.parse(jsonStr) as JsonValue, loc }; + } + if (c.arrayLit) { + const jsonStr = reconstructJson((c.arrayLit as CstNode[])[0]); + return { type: "literal", value: JSON.parse(jsonStr) as JsonValue, loc }; + } + if (c.sourceAlt) { + const primaryExpr = buildSourceExpression( + (c.sourceAlt as CstNode[])[0], + lineNum, + iterScope, + ); + const altExprOps = (c.altExprOp as CstNode[] | undefined) ?? []; + const altExprRights = (c.altExprRight as CstNode[] | undefined) ?? []; + if (altExprOps.length === 0) return primaryExpr; + return buildExprChain( + primaryExpr, + altExprOps, + altExprRights, + lineNum, + iterScope, + loc, + ); + } + throw new Error(`Line ${lineNum}: Invalid coalesce alternative`); + } + + /** + * Build WireSourceEntry[] from coalesce chain items. + */ + function buildFallbacks( + items: CstNode[], + lineNum: number, + iterScope?: string[], + ): WireSourceEntry[] { + return items.map((item) => { + const gate = tok(item, "falsyOp") + ? ("falsy" as const) + : ("nullish" as const); + const altNode = sub(item, "altValue")!; + let expr = buildCoalesceAltExpression(altNode, lineNum, iterScope); + + // Array mapping on coalesce alternative: || source[] as iter { ... } + const arrayMappingNode = sub(item, "altArrayMapping"); + if (arrayMappingNode) { + const iterName = extractNameToken(sub(arrayMappingNode, "iterName")!); + const newIterScope = [...(iterScope ?? []), iterName]; + const arrayBody = buildArrayMappingBody( + arrayMappingNode, + lineNum, + newIterScope, + ); + expr = { + type: "array", + source: expr, + iteratorName: iterName, + body: arrayBody, + loc: locFromNode(arrayMappingNode), + }; + } + + return { expr, gate, loc: locFromNode(altNode) }; + }); + } + + /** + * Build a WireCatch from a catch alternative CST node. + */ + function buildCatch( + catchAlt: CstNode, + lineNum: number, + iterScope?: string[], + ): WireCatch { + const c = catchAlt.children; + const loc = locFromNode(catchAlt); + + // Control flow + if (c.throwKw) { + const msg = (c.throwMsg as IToken[])[0].image; + return { + control: { kind: "throw", message: JSON.parse(msg) as string }, + ...(loc ? { loc } : {}), + }; + } + if (c.panicKw) { + const msg = (c.panicMsg as IToken[])[0].image; + return { + control: { kind: "panic", message: JSON.parse(msg) as string }, + ...(loc ? { loc } : {}), + }; + } + if (c.continueKw) { + const raw = (c.continueLevel as IToken[] | undefined)?.[0]?.image; + const levels = raw ? Number(raw) : undefined; + return { + control: { + kind: "continue", + ...(levels ? { levels } : {}), + }, + ...(loc ? { loc } : {}), + }; + } + if (c.breakKw) { + const raw = (c.breakLevel as IToken[] | undefined)?.[0]?.image; + const levels = raw ? Number(raw) : undefined; + return { + control: { + kind: "break", + ...(levels ? { levels } : {}), + }, + ...(loc ? { loc } : {}), + }; + } + // Literals + if (c.stringLit) { + const raw = (c.stringLit as IToken[])[0].image; + // Check for template strings in catch position + const content = raw.slice(1, -1); + const segs = parseTemplateString(content); + if (segs) { + // WireCatch only supports ref, value, or control — not arbitrary expressions. + // Template strings in catch position are rare. Keep as raw string. + return { value: content, ...(loc ? { loc } : {}) }; + } + return { + value: JSON.parse(raw) as string, + ...(loc ? { loc } : {}), + }; + } + if (c.numberLit) + return { + value: Number((c.numberLit as IToken[])[0].image), + ...(loc ? { loc } : {}), + }; + if (c.trueLit) return { value: true, ...(loc ? { loc } : {}) }; + if (c.falseLit) return { value: false, ...(loc ? { loc } : {}) }; + if (c.nullLit) return { value: null, ...(loc ? { loc } : {}) }; + if (c.objectLit) { + const jsonStr = reconstructJson((c.objectLit as CstNode[])[0]); + return { + value: JSON.parse(jsonStr) as JsonValue, + ...(loc ? { loc } : {}), + }; + } + if (c.arrayLit) { + const jsonStr = reconstructJson((c.arrayLit as CstNode[])[0]); + return { + value: JSON.parse(jsonStr) as JsonValue, + ...(loc ? { loc } : {}), + }; + } + // Source ref (possibly a pipe expression) + if (c.sourceAlt) { + const srcNode = (c.sourceAlt as CstNode[])[0]; + const expr = buildSourceExpression(srcNode, lineNum, iterScope); + if (expr.type === "ref") { + // Simple ref — keep backward-compatible format + return { + ref: expr.ref, + ...(loc ? { loc } : {}), + }; + } + // Complex expression (pipe chain, etc.) — use expr variant + return { + expr, + ...(loc ? { loc } : {}), + }; + } + throw new Error(`Line ${lineNum}: Invalid catch alternative`); + } + + // ── Wire RHS builder ────────────────────────────────────────────────── + + /** + * Build the full RHS of a wire: primary expression + coalesce + catch. + * + * This is the central function that converts the wire RHS CST + * (source expr + operators + ternary + array mapping + coalesce + catch) + * into a SourceChain (sources[] + catch?). + */ + function buildWireRHS( + wireNode: CstNode, + lineNum: number, + iterScope?: string[], + // Label config for different CST node shapes + labels?: { + stringSource?: string; + notPrefix?: string; + firstParenExpr?: string; + firstSource?: string; + exprOp?: string; + exprRight?: string; + ternaryOp?: string; + thenBranch?: string; + elseBranch?: string; + arrayMapping?: string; + coalesceItem?: string; + catchAlt?: string; + }, + ): SourceChain & { arrayMapping?: CstNode } { + const loc = locFromNode(wireNode); + const lb = labels ?? {}; + + // String literal source (template or plain) + const stringToken = tok(wireNode, lb.stringSource ?? "stringSource"); + if (stringToken) { + const raw = stringToken.image.slice(1, -1); + const segs = parseTemplateString(raw); + let primaryExpr: Expression; + if (segs) { + primaryExpr = buildConcatExpression(segs, lineNum, iterScope, loc); + } else { + primaryExpr = { + type: "literal", + value: JSON.parse(stringToken.image) as JsonValue, + loc, + }; + } + + // String source can also have expression chain after it + const stringOps = subs(wireNode, lb.exprOp ?? "exprOp"); + const stringRights = subs(wireNode, lb.exprRight ?? "exprRight"); + if (stringOps.length > 0) { + primaryExpr = buildExprChain( + primaryExpr, + stringOps, + stringRights, + lineNum, + iterScope, + loc, + ); + } + + // Ternary after string expression + const ternOp = tok(wireNode, lb.ternaryOp ?? "ternaryOp"); + if (ternOp) { + const thenBranch = buildTernaryBranch( + sub(wireNode, lb.thenBranch ?? "thenBranch")!, + lineNum, + iterScope, + ); + const elseBranch = buildTernaryBranch( + sub(wireNode, lb.elseBranch ?? "elseBranch")!, + lineNum, + iterScope, + ); + primaryExpr = { + type: "ternary", + cond: primaryExpr, + then: thenBranch, + else: elseBranch, + loc, + }; + } + + const sources: WireSourceEntry[] = [{ expr: primaryExpr, loc }]; + + // Coalesce chain + const coalesceItems = subs(wireNode, lb.coalesceItem ?? "coalesceItem"); + if (coalesceItems.length > 0) { + sources.push(...buildFallbacks(coalesceItems, lineNum, iterScope)); + } + + // Catch + const catchAlt = sub(wireNode, lb.catchAlt ?? "catchAlt"); + const catchHandler = catchAlt + ? buildCatch(catchAlt, lineNum, iterScope) + : undefined; + + return { + sources, + ...(catchHandler ? { catch: catchHandler } : {}), + }; + } + + // Normal source expression with optional not prefix, operators, ternary + const notPrefix = tok(wireNode, lb.notPrefix ?? "notPrefix"); + const parenExprNode = sub(wireNode, lb.firstParenExpr ?? "firstParenExpr"); + const sourceNode = sub(wireNode, lb.firstSource ?? "firstSource"); + + let primaryExpr: Expression; + if (parenExprNode) { + primaryExpr = buildParenExpression(parenExprNode, lineNum, iterScope); + } else if (sourceNode) { + primaryExpr = buildSourceExpression(sourceNode, lineNum, iterScope); + } else { + throw new Error(`Line ${lineNum}: Expected source expression`); + } + + // Expression chain: op operand pairs + const exprOps = subs(wireNode, lb.exprOp ?? "exprOp"); + const exprRights = subs(wireNode, lb.exprRight ?? "exprRight"); + if (exprOps.length > 0) { + primaryExpr = buildExprChain( + primaryExpr, + exprOps, + exprRights, + lineNum, + iterScope, + loc, + ); + } + + // Ternary + const ternOp = tok(wireNode, lb.ternaryOp ?? "ternaryOp"); + if (ternOp) { + const thenBranch = buildTernaryBranch( + sub(wireNode, lb.thenBranch ?? "thenBranch")!, + lineNum, + iterScope, + ); + const elseBranch = buildTernaryBranch( + sub(wireNode, lb.elseBranch ?? "elseBranch")!, + lineNum, + iterScope, + ); + primaryExpr = { + type: "ternary", + cond: primaryExpr, + then: thenBranch, + else: elseBranch, + loc, + }; + } + + // Not prefix wraps the entire expression + if (notPrefix) { + primaryExpr = { type: "unary", op: "not", operand: primaryExpr, loc }; + } + + // Array mapping: [] as iter { ... } + const arrayMappingNode = sub(wireNode, lb.arrayMapping ?? "arrayMapping"); + if (arrayMappingNode) { + const iterName = extractNameToken(sub(arrayMappingNode, "iterName")!); + const newIterScope = [...(iterScope ?? []), iterName]; + + // Process element lines inside the array mapping + const arrayBody = buildArrayMappingBody( + arrayMappingNode, + lineNum, + newIterScope, + ); + + primaryExpr = { + type: "array", + source: primaryExpr, + iteratorName: iterName, + body: arrayBody, + loc: locFromNode(arrayMappingNode), + }; + } + + const sources: WireSourceEntry[] = [{ expr: primaryExpr, loc }]; + + // Coalesce chain + const coalesceItems = subs(wireNode, lb.coalesceItem ?? "coalesceItem"); + if (coalesceItems.length > 0) { + sources.push(...buildFallbacks(coalesceItems, lineNum, iterScope)); + } + + // Catch + const catchAlt = sub(wireNode, lb.catchAlt ?? "catchAlt"); + const catchHandler = catchAlt + ? buildCatch(catchAlt, lineNum, iterScope) + : undefined; + + return { + sources, + ...(catchHandler ? { catch: catchHandler } : {}), + ...(arrayMappingNode ? { arrayMapping: arrayMappingNode } : {}), + }; + } + + // ── Array mapping body builder ──────────────────────────────────────── + + /** + * Build Statement[] from the inside of an array mapping block. + */ + function buildArrayMappingBody( + arrayMappingNode: CstNode, + _lineNum: number, + iterScope: string[], + ): Statement[] { + const stmts: Statement[] = []; + + // elementWithDecl: alias name <- source (local bindings) + for (const withDecl of subs(arrayMappingNode, "elementWithDecl")) { + const alias = extractNameToken(sub(withDecl, "elemWithAlias")!); + const elemLineNum = line(findFirstToken(withDecl)); + assertNotReserved(alias, elemLineNum, "local binding alias"); + + const sourceNode = sub(withDecl, "elemWithSource")!; + const expr = buildSourceExpression(sourceNode, elemLineNum, iterScope); + + // Coalesce chain on the alias + const coalesceItems = subs(withDecl, "elemCoalesceItem"); + const fallbacks = buildFallbacks(coalesceItems, elemLineNum, iterScope); + const catchAlt = sub(withDecl, "elemCatchAlt"); + const catchHandler = catchAlt + ? buildCatch(catchAlt, elemLineNum, iterScope) + : undefined; + + const sources: WireSourceEntry[] = [ + { expr, loc: locFromNode(sourceNode) }, + ...fallbacks, + ]; + + // Register the alias in handleRes for subsequent element lines + handleRes.set(alias, { + module: SELF_MODULE, + type: "__local", + field: alias, + }); + + stmts.push({ + kind: "alias", + name: alias, + sources, + ...(catchHandler ? { catch: catchHandler } : {}), + loc: locFromNode(withDecl), + } satisfies WireAliasStatement); + } + + // elementToolWithDecl: with [as ] [memoize] + for (const toolWith of subs(arrayMappingNode, "elementToolWithDecl")) { + const elemLineNum = line(findFirstToken(toolWith)); + const name = extractDottedName(sub(toolWith, "refName")!); + const versionTag = ( + toolWith.children.refVersion as IToken[] | undefined + )?.[0]?.image.slice(1); + const lastDot = name.lastIndexOf("."); + const defaultHandle = lastDot !== -1 ? name.substring(lastDot + 1) : name; + const handle = toolWith.children.refAlias + ? extractNameToken((toolWith.children.refAlias as CstNode[])[0]) + : defaultHandle; + const memoize = !!toolWith.children.memoizeKw; + + if (toolWith.children.refAlias) { + assertNotReserved(handle, elemLineNum, "handle alias"); + } + + let binding: HandleBinding; + const defineDef = previousInstructions.find( + (inst): inst is DefineDef => + inst.kind === "define" && inst.name === name, + ); + if (defineDef) { + if (memoize) { + throw new Error( + `Line ${elemLineNum}: memoize is only valid for tool references`, + ); + } + binding = { handle, kind: "define", name }; + handleRes.set(handle, { + module: `__define_${handle}`, + type: bridgeType, + field: bridgeField, + }); + } else if (lastDot !== -1) { + const modulePart = name.substring(0, lastDot); + const fieldPart = name.substring(lastDot + 1); + const key = `${modulePart}:${fieldPart}`; + const instance = (instanceCounters.get(key) ?? 0) + 1; + instanceCounters.set(key, instance); + binding = { + handle, + kind: "tool", + name, + element: true, + ...(memoize ? { memoize: true as const } : {}), + ...(versionTag ? { version: versionTag } : {}), + }; + handleRes.set(handle, { + module: modulePart, + type: bridgeType, + field: fieldPart, + instance, + }); + } else { + const key = `Tools:${name}`; + const instance = (instanceCounters.get(key) ?? 0) + 1; + instanceCounters.set(key, instance); + binding = { + handle, + kind: "tool", + name, + element: true, + ...(memoize ? { memoize: true as const } : {}), + ...(versionTag ? { version: versionTag } : {}), + }; + handleRes.set(handle, { + module: SELF_MODULE, + type: "Tools", + field: name, + instance, + }); + } + + handleBindings.push(binding); + stmts.push({ kind: "with", binding } satisfies WithStatement); + } + + // elementHandleWire: handle.field <- expr | handle.field = value + for (const wireNode of subs(arrayMappingNode, "elementHandleWire")) { + const elemLineNum = line(findFirstToken(wireNode)); + const { root: targetRoot, segments: targetSegs } = extractAddressPath( + sub(wireNode, "target")!, + ); + const toRef = resolveAddress(targetRoot, targetSegs, elemLineNum); + assertNoTargetIndices(toRef, elemLineNum); + + const wc = wireNode.children; + if (wc.equalsOp) { + const value = extractBareValue(sub(wireNode, "constValue")!); + stmts.push({ + kind: "wire", + target: toRef, + sources: [{ expr: { type: "literal", value: parseLiteral(value) } }], + loc: locFromNode(wireNode), + } satisfies WireStatement); + continue; + } + + const rhs = buildWireRHS(wireNode, elemLineNum, iterScope, { + coalesceItem: "coalesceItem", + catchAlt: "catchAlt", + }); + stmts.push({ + kind: "wire", + target: toRef, + sources: rhs.sources, + ...(rhs.catch ? { catch: rhs.catch } : {}), + loc: locFromNode(wireNode), + } satisfies WireStatement); + } + + // elemMapSpreadLine: ... <- source (spread inside array mapper) + for (const spreadLine of subs(arrayMappingNode, "elemMapSpreadLine")) { + buildSpreadLine(spreadLine, stmts, iterScope); + } + + // elementLine: .field = value | .field <- expr | .field { ... } + for (const elemLine of subs(arrayMappingNode, "elementLine")) { + const elemLineNum = line(findFirstToken(elemLine)); + const targetStr = extractDottedPathStr(sub(elemLine, "elemTarget")!); + const elemSegs = parsePath(targetStr); + const wc = elemLine.children; + + // Scope block: .field { ... } + if (wc.elemScopeBlock) { + const scopeRef: NodeRef = { + module: SELF_MODULE, + type: bridgeType, + field: bridgeField, + element: true, + path: elemSegs, + }; + const scopeBody: Statement[] = []; + + for (const scopeLine of subs(elemLine, "elemScopeLine")) { + buildPathScopeLine(scopeLine, scopeBody, iterScope, true); + } + for (const spreadLine of subs(elemLine, "elemSpreadLine")) { + buildSpreadLine(spreadLine, scopeBody, iterScope); + } + + stmts.push({ + kind: "scope", + target: scopeRef, + body: scopeBody, + loc: locFromNode(elemLine), + } satisfies ScopeStatement); + continue; + } + + const toRef: NodeRef = { + module: SELF_MODULE, + type: bridgeType, + field: bridgeField, + element: true, + path: elemSegs, + }; + + // Constant: .field = value + if (wc.elemEquals) { + const value = extractBareValue(sub(elemLine, "elemValue")!); + stmts.push({ + kind: "wire", + target: toRef, + sources: [{ expr: { type: "literal", value: parseLiteral(value) } }], + loc: locFromNode(elemLine), + } satisfies WireStatement); + continue; + } + + // Pull wire: .field <- expr ... + const rhs = buildWireRHS(elemLine, elemLineNum, iterScope, { + stringSource: "elemStringSource", + notPrefix: "elemNotPrefix", + firstParenExpr: "elemFirstParenExpr", + firstSource: "elemSource", + exprOp: "elemExprOp", + exprRight: "elemExprRight", + ternaryOp: "elemTernaryOp", + thenBranch: "elemThenBranch", + elseBranch: "elemElseBranch", + arrayMapping: "nestedArrayMapping", + coalesceItem: "elemCoalesceItem", + catchAlt: "elemCatchAlt", + }); + stmts.push({ + kind: "wire", + target: toRef, + sources: rhs.sources, + ...(rhs.catch ? { catch: rhs.catch } : {}), + loc: locFromNode(elemLine), + } satisfies WireStatement); + } + + return stmts; + } + + // ── Path scope line builder ─────────────────────────────────────────── + + function buildPathScopeLine( + scopeLine: CstNode, + stmts: Statement[], + iterScope?: string[], + inElement?: boolean, + ): void { + const scopeLineNum = line(findFirstToken(scopeLine)); + const targetStr = extractDottedPathStr(sub(scopeLine, "scopeTarget")!); + const scopeSegs = parsePath(targetStr); + const sc = scopeLine.children; + + // Nested scope: .field { ... } + const nestedScopeLines = subs(scopeLine, "pathScopeLine"); + const nestedSpreadLines = subs(scopeLine, "scopeSpreadLine"); + const nestedAliases = subs(scopeLine, "scopeAlias"); + if ( + nestedScopeLines.length > 0 || + nestedSpreadLines.length > 0 || + nestedAliases.length > 0 + ) { + // This is a nested scope block + const scopeRef: NodeRef = { + module: SELF_MODULE, + type: bridgeType, + field: bridgeField, + ...(inElement ? { element: true as const } : {}), + path: scopeSegs, + }; + const scopeBody: Statement[] = []; + + for (const innerAlias of nestedAliases) { + buildAliasStatement(innerAlias, scopeBody, iterScope); + } + for (const innerLine of nestedScopeLines) { + buildPathScopeLine(innerLine, scopeBody, iterScope, inElement); + } + for (const innerSpread of nestedSpreadLines) { + buildSpreadLine(innerSpread, scopeBody, iterScope); + } + + stmts.push({ + kind: "scope", + target: scopeRef, + body: scopeBody, + loc: locFromNode(scopeLine), + } satisfies ScopeStatement); + return; + } + + // Target ref for non-scope wires inside the scope block + const toRef: NodeRef = { + module: SELF_MODULE, + type: bridgeType, + field: bridgeField, + ...(inElement ? { element: true as const } : {}), + path: scopeSegs, + }; + + // Constant: .field = value + if (sc.scopeEquals) { + const value = extractBareValue(sub(scopeLine, "scopeValue")!); + stmts.push({ + kind: "wire", + target: toRef, + sources: [{ expr: { type: "literal", value: parseLiteral(value) } }], + loc: locFromNode(scopeLine), + } satisfies WireStatement); + return; + } + + // Pull wire: .field <- expr + const rhs = buildWireRHS(scopeLine, scopeLineNum, iterScope, { + stringSource: "scopeStringSource", + notPrefix: "scopeNotPrefix", + firstParenExpr: "scopeFirstParenExpr", + firstSource: "scopeSource", + exprOp: "scopeExprOp", + exprRight: "scopeExprRight", + ternaryOp: "scopeTernaryOp", + thenBranch: "scopeThenBranch", + elseBranch: "scopeElseBranch", + arrayMapping: "scopeArrayMapping", + coalesceItem: "scopeCoalesceItem", + catchAlt: "scopeCatchAlt", + }); + stmts.push({ + kind: "wire", + target: toRef, + sources: rhs.sources, + ...(rhs.catch ? { catch: rhs.catch } : {}), + loc: locFromNode(scopeLine), + } satisfies WireStatement); + } + + // ── Spread line builder ─────────────────────────────────────────────── + + function buildSpreadLine( + spreadLine: CstNode, + stmts: Statement[], + iterScope?: string[], + ): void { + const spreadLineNum = line(findFirstToken(spreadLine)); + const sourceNode = sub(spreadLine, "spreadSource")!; + const expr = buildSourceExpression(sourceNode, spreadLineNum, iterScope); + + stmts.push({ + kind: "spread", + sources: [{ expr, loc: locFromNode(sourceNode) }], + loc: locFromNode(spreadLine), + } satisfies SpreadStatement); + } + + // ── Alias statement builder ─────────────────────────────────────────── + + function buildAliasStatement( + aliasNode: CstNode, + stmts: Statement[], + iterScope?: string[], + ): void { + const aliasLineNum = line(findFirstToken(aliasNode)); + const aliasName = extractNameToken(sub(aliasNode, "nodeAliasName")!); + + // String literal source + const stringToken = tok(aliasNode, "aliasStringSource"); + if (stringToken) { + const raw = stringToken.image.slice(1, -1); + const segs = parseTemplateString(raw); + let primaryExpr: Expression; + if (segs) { + primaryExpr = buildConcatExpression( + segs, + aliasLineNum, + iterScope, + locFromNode(aliasNode), + ); + } else { + primaryExpr = { + type: "literal", + value: JSON.parse(stringToken.image) as JsonValue, + loc: locFromNode(aliasNode), + }; + } + + // Expression chain after string + const ops = subs(aliasNode, "aliasStringExprOp"); + const rights = subs(aliasNode, "aliasStringExprRight"); + if (ops.length > 0) { + primaryExpr = buildExprChain( + primaryExpr, + ops, + rights, + aliasLineNum, + iterScope, + locFromNode(aliasNode), + ); + } + + // Ternary after string expression + const ternOp = tok(aliasNode, "aliasStringTernaryOp"); + if (ternOp) { + const thenBranch = buildTernaryBranch( + sub(aliasNode, "aliasStringThenBranch")!, + aliasLineNum, + iterScope, + ); + const elseBranch = buildTernaryBranch( + sub(aliasNode, "aliasStringElseBranch")!, + aliasLineNum, + iterScope, + ); + primaryExpr = { + type: "ternary", + cond: primaryExpr, + then: thenBranch, + else: elseBranch, + loc: locFromNode(aliasNode), + }; + } + + const sources: WireSourceEntry[] = [ + { expr: primaryExpr, loc: locFromNode(aliasNode) }, + ]; + + // Coalesce + catch + const coalesceItems = subs(aliasNode, "aliasCoalesceItem"); + sources.push(...buildFallbacks(coalesceItems, aliasLineNum, iterScope)); + const catchAlt = sub(aliasNode, "aliasCatchAlt"); + const catchHandler = catchAlt + ? buildCatch(catchAlt, aliasLineNum, iterScope) + : undefined; + + // Register alias in handleRes + handleRes.set(aliasName, { + module: SELF_MODULE, + type: "__local", + field: aliasName, + }); + + stmts.push({ + kind: "alias", + name: aliasName, + sources, + ...(catchHandler ? { catch: catchHandler } : {}), + loc: locFromNode(aliasNode), + } satisfies WireAliasStatement); + return; + } + + // Normal source alias (not prefix + source/paren expr + ops + ternary + array mapping) + const rhs = buildWireRHS(aliasNode, aliasLineNum, iterScope, { + notPrefix: "aliasNotPrefix", + firstParenExpr: "aliasFirstParen", + firstSource: "nodeAliasSource", + exprOp: "aliasExprOp", + exprRight: "aliasExprRight", + ternaryOp: "aliasTernaryOp", + thenBranch: "aliasThenBranch", + elseBranch: "aliasElseBranch", + arrayMapping: "arrayMapping", + coalesceItem: "aliasCoalesceItem", + catchAlt: "aliasCatchAlt", + }); + + // Register alias + handleRes.set(aliasName, { + module: SELF_MODULE, + type: "__local", + field: aliasName, + }); + + stmts.push({ + kind: "alias", + name: aliasName, + sources: rhs.sources, + ...(rhs.catch ? { catch: rhs.catch } : {}), + loc: locFromNode(aliasNode), + } satisfies WireAliasStatement); + } + + // ── Step 2: Process body lines (wires, aliases, force, scopes) ──────── + + for (const bodyLine of bodyLines) { + const bc = bodyLine.children; + const bodyLineNum = line(findFirstToken(bodyLine)); + const bodyLineLoc = locFromNode(bodyLine); + + // Skip with-declarations (already processed in Step 1) + if (bc.bridgeWithDecl) continue; + + // Force statement + if (bc.bridgeForce) { + const forceNode = (bc.bridgeForce as CstNode[])[0]; + const handle = extractNameToken(sub(forceNode, "forcedHandle")!); + const hasCatchNull = !!tok(forceNode, "forceCatchKw"); + const res = handleRes.get(handle); + if (!res) { + throw new Error( + `Line ${bodyLineNum}: Cannot force undeclared handle "${handle}"`, + ); + } + body.push({ + kind: "force", + handle, + module: res.module, + type: res.type, + field: res.field, + ...(res.instance != null ? { instance: res.instance } : {}), + ...(hasCatchNull ? { catchError: true as const } : {}), + loc: locFromNode(forceNode), + } satisfies ForceStatement); + continue; + } + + // Node alias + if (bc.bridgeNodeAlias) { + buildAliasStatement( + (bc.bridgeNodeAlias as CstNode[])[0], + body, + undefined, + ); + continue; + } + + // Bridge wire (constant, pull, or scope block) + if (bc.bridgeWire) { + const wireNode = (bc.bridgeWire as CstNode[])[0]; + const wc = wireNode.children; + const { root: targetRoot, segments: targetSegs } = extractAddressPath( + sub(wireNode, "target")!, + ); + const toRef = resolveAddress(targetRoot, targetSegs, bodyLineNum); + assertNoTargetIndices(toRef, bodyLineNum); + + // Constant wire: target = value + if (wc.equalsOp) { + const value = extractBareValue(sub(wireNode, "constValue")!); + body.push({ + kind: "wire", + target: toRef, + sources: [{ expr: { type: "literal", value: parseLiteral(value) } }], + loc: bodyLineLoc, + } satisfies WireStatement); + continue; + } + + // Scope block: target { ... } + if (wc.scopeBlock) { + const scopeBody: Statement[] = []; + + for (const aliasNode of subs(wireNode, "scopeAlias")) { + buildAliasStatement(aliasNode, scopeBody, undefined); + } + for (const scopeLine of subs(wireNode, "pathScopeLine")) { + buildPathScopeLine(scopeLine, scopeBody, undefined); + } + for (const spreadLine of subs(wireNode, "scopeSpreadLine")) { + buildSpreadLine(spreadLine, scopeBody, undefined); + } + + body.push({ + kind: "scope", + target: toRef, + body: scopeBody, + loc: bodyLineLoc, + } satisfies ScopeStatement); + continue; + } + + // Pull wire: target <- expr [modifiers] + const rhs = buildWireRHS(wireNode, bodyLineNum); + body.push({ + kind: "wire", + target: toRef, + sources: rhs.sources, + ...(rhs.catch ? { catch: rhs.catch } : {}), + loc: bodyLineLoc, + } satisfies WireStatement); + continue; + } + } + + // ── Tool self-wires (.key = value | .key <- expr) ───────────────────── + + if (options?.selfWireNodes) { + for (const selfWire of options.selfWireNodes) { + const selfLineNum = line(findFirstToken(selfWire)); + const targetStr = extractDottedPathStr(sub(selfWire, "elemTarget")!); + const selfSegs = parsePath(targetStr); + const wc = selfWire.children; + + // The tool itself is the target — resolve from the first handle + // that represents the tool (usually the only one for self-wires) + const toRef: NodeRef = { + module: SELF_MODULE, + type: "Tools", + field: bridgeField, + path: selfSegs, + }; + + if (wc.elemEquals) { + const value = extractBareValue(sub(selfWire, "elemValue")!); + body.push({ + kind: "wire", + target: toRef, + sources: [{ expr: { type: "literal", value: parseLiteral(value) } }], + loc: locFromNode(selfWire), + } satisfies WireStatement); + continue; + } + + // Scope block: .field { .sub <- source, ... } + if (wc.elemScopeBlock) { + const scopeBody: Statement[] = []; + for (const scopeLine of subs(selfWire, "elemScopeLine")) { + buildPathScopeLine(scopeLine, scopeBody, undefined); + } + for (const spreadLine of subs(selfWire, "elemSpreadLine")) { + buildSpreadLine(spreadLine, scopeBody, undefined); + } + body.push({ + kind: "scope", + target: toRef, + body: scopeBody, + loc: locFromNode(selfWire), + } satisfies ScopeStatement); + continue; + } + + // Pull wire + const rhs = buildWireRHS(selfWire, selfLineNum, undefined, { + stringSource: "elemStringSource", + notPrefix: "elemNotPrefix", + firstParenExpr: "elemFirstParenExpr", + firstSource: "elemSource", + exprOp: "elemExprOp", + exprRight: "elemExprRight", + ternaryOp: "elemTernaryOp", + thenBranch: "elemThenBranch", + elseBranch: "elemElseBranch", + coalesceItem: "elemCoalesceItem", + catchAlt: "elemCatchAlt", + }); + body.push({ + kind: "wire", + target: toRef, + sources: rhs.sources, + ...(rhs.catch ? { catch: rhs.catch } : {}), + loc: locFromNode(selfWire), + } satisfies WireStatement); + } + } + + // ── Tool-level spread lines (... <- source) ────────────────────────── + + if (options?.spreadNodes) { + for (const spreadNode of options.spreadNodes) { + buildSpreadLine(spreadNode, body, undefined); + } + } + + return { handles: handleBindings, body, handleRes }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// Top-level document builder +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * Build a complete BridgeDocument from a Chevrotain CST, populating + * `body: Statement[]` on all bridge/tool/define instructions. + * + * This can be called alongside the existing `toBridgeAst()` to augment + * instructions with the nested IR. + */ +export function buildBodies(_cst: CstNode, instructions: Instruction[]): void { + // Walk instruction list and build body for each bridge/tool/define + for (const _inst of instructions) { + // Find corresponding CST node and call buildBody per-block. + // This function is a hook for future integration. + } +} diff --git a/packages/bridge-parser/src/parser/parser.ts b/packages/bridge-parser/src/parser/parser.ts index 932082f8..f50f26b3 100644 --- a/packages/bridge-parser/src/parser/parser.ts +++ b/packages/bridge-parser/src/parser/parser.ts @@ -72,19 +72,11 @@ import type { Bridge, BridgeDocument, ConstDef, - ControlFlowInstruction, DefineDef, - Expression, - HandleBinding, Instruction, - NodeRef, - SourceLocation, ToolDef, - Wire, - WireCatch, - WireSourceEntry, } from "@stackables/bridge-core"; -import { SELF_MODULE } from "@stackables/bridge-core"; +import { buildBody } from "./ast-builder.ts"; // ── Reserved-word guards (mirroring the regex parser) ────────────────────── @@ -168,6 +160,12 @@ class BridgeParser extends CstParser { ALT: () => this.SUBRULE(this.elementLine, { LABEL: "toolSelfWire" }), }, + { + ALT: () => + this.SUBRULE(this.scopeSpreadLine, { + LABEL: "toolSpreadLine", + }), + }, { ALT: () => this.SUBRULE(this.bridgeBodyLine) }, ]), ); @@ -506,6 +504,12 @@ class BridgeParser extends CstParser { { ALT: () => this.SUBRULE(this.elementToolWithDecl) }, { ALT: () => this.SUBRULE(this.elementHandleWire) }, { ALT: () => this.SUBRULE(this.elementLine) }, + { + ALT: () => + this.SUBRULE(this.scopeSpreadLine, { + LABEL: "elemMapSpreadLine", + }), + }, ]), ); this.CONSUME(RCurly); @@ -785,6 +789,10 @@ class BridgeParser extends CstParser { }, }, ]); + // Optional array mapping: [] as { ... } + this.OPTION6(() => + this.SUBRULE(this.arrayMapping, { LABEL: "scopeArrayMapping" }), + ); // || / ?? coalesce chain (mixed order) this.MANY2(() => { this.SUBRULE3(this.coalesceChainItem, { @@ -882,7 +890,19 @@ class BridgeParser extends CstParser { { ALT: () => this.SUBRULE(this.jsonInlineObject, { LABEL: "objectLit" }), }, - { ALT: () => this.SUBRULE(this.sourceExpr, { LABEL: "sourceAlt" }) }, + { + ALT: () => this.SUBRULE(this.jsonInlineArray, { LABEL: "arrayLit" }), + }, + { + ALT: () => { + this.SUBRULE(this.sourceExpr, { LABEL: "sourceAlt" }); + // Optional expression chain after source ref in fallback position + this.MANY(() => { + this.SUBRULE(this.exprOperator, { LABEL: "altExprOp" }); + this.SUBRULE(this.exprOperand, { LABEL: "altExprRight" }); + }); + }, + }, ]); }); @@ -897,6 +917,10 @@ class BridgeParser extends CstParser { { ALT: () => this.CONSUME(ErrorCoalesce, { LABEL: "nullishOp" }) }, ]); this.SUBRULE(this.coalesceAlternative, { LABEL: "altValue" }); + // Optional array mapping on source-based coalesce alternatives + this.OPTION(() => + this.SUBRULE(this.arrayMapping, { LABEL: "altArrayMapping" }), + ); }); // ── Define block ─────────────────────────────────────────────────────── @@ -1238,15 +1262,32 @@ class BridgeParser extends CstParser { { ALT: () => this.CONSUME(FalseLiteral) }, { ALT: () => this.CONSUME(NullLiteral) }, { ALT: () => this.CONSUME(Identifier) }, - { ALT: () => this.CONSUME(LSquare) }, - { ALT: () => this.CONSUME(RSquare) }, { ALT: () => this.CONSUME(Dot) }, { ALT: () => this.CONSUME(Equals) }, { ALT: () => this.SUBRULE(this.jsonInlineObject) }, + { ALT: () => this.SUBRULE(this.jsonInlineArray) }, ]); }); this.CONSUME(RCurly); }); + + /** Inline JSON array — used in coalesce alternatives */ + public jsonInlineArray = this.RULE("jsonInlineArray", () => { + this.CONSUME(LSquare); + this.MANY(() => { + this.OR([ + { ALT: () => this.CONSUME(StringLiteral) }, + { ALT: () => this.CONSUME(NumberLiteral) }, + { ALT: () => this.CONSUME(Comma) }, + { ALT: () => this.CONSUME(TrueLiteral) }, + { ALT: () => this.CONSUME(FalseLiteral) }, + { ALT: () => this.CONSUME(NullLiteral) }, + { ALT: () => this.SUBRULE(this.jsonInlineObject) }, + { ALT: () => this.SUBRULE(this.jsonInlineArray) }, + ]); + }); + this.CONSUME(RSquare); + }); } // Singleton parser instances (Chevrotain best practice) @@ -1449,64 +1490,10 @@ function tok(node: CstNode, tokenName: string): IToken | undefined { return tokens?.[0]; } -function toks(node: CstNode, tokenName: string): IToken[] { - return (node.children[tokenName] as IToken[] | undefined) ?? []; -} - function line(token: IToken | undefined): number { return token?.startLine ?? 0; } -function makeLoc( - start: IToken | undefined, - end: IToken | undefined = start, -): SourceLocation | undefined { - if (!start) return undefined; - const last = end ?? start; - return { - startLine: start.startLine ?? 0, - startColumn: start.startColumn ?? 0, - endLine: last.endLine ?? last.startLine ?? 0, - endColumn: last.endColumn ?? last.startColumn ?? 0, - }; -} - -type CoalesceAltResult = - | { literal: string } - | { sourceRef: NodeRef } - | { control: ControlFlowInstruction }; - -function buildSourceEntry( - gate: "falsy" | "nullish", - altNode: CstNode, - altResult: CoalesceAltResult, -): WireSourceEntry { - const loc = locFromNode(altNode); - let expr: Expression; - if ("literal" in altResult) { - expr = { type: "literal", value: altResult.literal, loc }; - } else if ("control" in altResult) { - expr = { type: "control", control: altResult.control, loc }; - } else { - expr = { type: "ref", ref: altResult.sourceRef, loc }; - } - return { expr, gate, loc }; -} - -function buildCatchHandler( - catchAlt: CstNode, - altResult: CoalesceAltResult, -): WireCatch { - const loc = locFromNode(catchAlt); - if ("literal" in altResult) { - return { value: altResult.literal, ...(loc ? { loc } : {}) }; - } - if ("control" in altResult) { - return { control: altResult.control, ...(loc ? { loc } : {}) }; - } - return { ref: altResult.sourceRef, ...(loc ? { loc } : {}) }; -} - /* ── extractNameToken: get string from nameToken CST node ── */ function extractNameToken(node: CstNode): string { const c = node.children; @@ -1524,91 +1511,6 @@ function extractDottedName(node: CstNode): string { return [first, ...rest].join("."); } -/* ── extractPathSegment: get string from pathSegment ── */ -function extractPathSegment(node: CstNode): string { - for (const key of Object.keys(node.children)) { - const tokens = node.children[key] as IToken[] | undefined; - if (tokens?.[0]) return tokens[0].image; - } - return ""; -} - -/* ── extractDottedPathStr: reassemble from dottedPath CST node ── */ -function extractDottedPathStr(node: CstNode): string { - const first = extractPathSegment(sub(node, "first")!); - const rest = subs(node, "rest").map((n) => extractPathSegment(n)); - return [first, ...rest].join("."); -} - -/* ── extractAddressPath: get root + segments preserving order ── */ -function extractAddressPath(node: CstNode): { - root: string; - segments: string[]; - safe?: boolean; - rootSafe?: boolean; - segmentSafe?: boolean[]; -} { - const root = extractNameToken(sub(node, "root")!); - type Seg = { offset: number; value: string }; - const items: Seg[] = []; - const safeNavTokens = (node.children.safeNav as IToken[] | undefined) ?? []; - const hasSafeNav = safeNavTokens.length > 0; - - // Also collect Dot token offsets - const dotTokens = (node.children.Dot as IToken[] | undefined) ?? []; - - for (const seg of subs(node, "segment")) { - items.push({ - offset: - seg.location?.startOffset ?? findFirstToken(seg)?.startOffset ?? 0, - value: extractPathSegment(seg), - }); - } - for (const idxTok of toks(node, "arrayIndex")) { - if (idxTok.image.includes(".")) { - throw new Error( - `Line ${idxTok.startLine}: Array indices must be integers, found "${idxTok.image}"`, - ); - } - items.push({ offset: idxTok.startOffset, value: idxTok.image }); - } - items.sort((a, b) => a.offset - b.offset); - - // For each segment, determine if it was preceded by a SafeNav token. - // Collect all separators (Dot + SafeNav) sorted by offset, then correlate with segments. - const allSeps: { offset: number; isSafe: boolean }[] = [ - ...dotTokens.map((t) => ({ offset: t.startOffset, isSafe: false })), - ...safeNavTokens.map((t) => ({ offset: t.startOffset, isSafe: true })), - ].sort((a, b) => a.offset - b.offset); - - // Match separators to segments: each separator precedes the next segment - const segmentSafe: boolean[] = []; - let rootSafe = false; - let sepIdx = -1; - for (let i = 0; i < items.length; i++) { - const segOffset = items[i].offset; - while ( - sepIdx + 1 < allSeps.length && - allSeps[sepIdx + 1].offset < segOffset - ) { - sepIdx++; - } - const isSafe = sepIdx >= 0 ? allSeps[sepIdx].isSafe : false; - if (i === 0) { - rootSafe = isSafe; - } - segmentSafe.push(isSafe); - } - - return { - root, - segments: items.map((i) => i.value), - ...(hasSafeNav ? { safe: true } : {}), - ...(rootSafe ? { rootSafe } : {}), - ...(segmentSafe.some((s) => s) ? { segmentSafe } : {}), - }; -} - function findFirstToken(node: CstNode): IToken | undefined { for (const key of Object.keys(node.children)) { const child = node.children[key]; @@ -1621,40 +1523,6 @@ function findFirstToken(node: CstNode): IToken | undefined { return undefined; } -function findLastToken(node: CstNode): IToken | undefined { - const tokens: IToken[] = []; - collectTokens(node, tokens); - if (tokens.length === 0) return undefined; - tokens.sort((left, right) => left.startOffset - right.startOffset); - return tokens[tokens.length - 1]; -} - -function locFromNode(node: CstNode | undefined): SourceLocation | undefined { - if (!node) return undefined; - return makeLoc(findFirstToken(node), findLastToken(node)); -} - -function locFromNodeRange( - startNode: CstNode | undefined, - endNode: CstNode | undefined = startNode, -): SourceLocation | undefined { - if (!startNode) return undefined; - return makeLoc( - findFirstToken(startNode), - findLastToken(endNode ?? startNode), - ); -} - -function withLoc(wire: T, loc: SourceLocation | undefined): T { - if (!loc) return wire; - return { ...wire, loc } as T; -} - -/* ── parsePath: split "a.b[0].c" → ["a","b","0","c"] ── */ -function parsePath(text: string): string[] { - return text.split(/\.|\[|\]/).filter(Boolean); -} - /* ── Collect all tokens recursively from a CST node ── */ function collectTokens(node: CstNode, out: IToken[]): void { for (const key of Object.keys(node.children)) { @@ -1684,72 +1552,6 @@ function reconstructJson(node: CstNode): string { return result; } -/* ── extractBareValue: get the string from a bareValue CST node ── */ -function extractBareValue(node: CstNode): string { - for (const key of Object.keys(node.children)) { - const tokens = node.children[key] as IToken[] | undefined; - if (tokens?.[0]) { - let val = tokens[0].image; - if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1); - return val; - } - } - return ""; -} - -/* ── parseTemplateString: split a string into text and ref segments ── */ -type TemplateSeg = - | { kind: "text"; value: string } - | { kind: "ref"; path: string }; - -function parseTemplateString(raw: string): TemplateSeg[] | null { - // raw is the content between quotes (already stripped of outer quotes) - const segs: TemplateSeg[] = []; - let i = 0; - let hasRef = false; - let text = ""; - while (i < raw.length) { - if (raw[i] === "\\" && i + 1 < raw.length) { - if (raw[i + 1] === "{") { - text += "{"; - i += 2; - continue; - } - // preserve other escapes as-is - text += raw[i] + raw[i + 1]; - i += 2; - continue; - } - if (raw[i] === "{") { - const end = raw.indexOf("}", i + 1); - if (end === -1) { - // unclosed brace — treat as literal text - text += raw[i]; - i++; - continue; - } - const ref = raw.slice(i + 1, end).trim(); - if (ref.length === 0) { - text += "{}"; - i = end + 1; - continue; - } - if (text.length > 0) { - segs.push({ kind: "text", value: text }); - text = ""; - } - segs.push({ kind: "ref", path: ref }); - hasRef = true; - i = end + 1; - continue; - } - text += raw[i]; - i++; - } - if (text.length > 0) segs.push({ kind: "text", value: text }); - return hasRef ? segs : null; -} - /* ── extractJsonValue: from a jsonValue CST node ── */ function extractJsonValue(node: CstNode): string { const c = node.children; @@ -1764,1322 +1566,130 @@ function extractJsonValue(node: CstNode): string { return ""; } -// ── Handle resolution type ────────────────────────────────────────────── - -type HandleResolution = { - module: string; - type: string; - field: string; - instance?: number; -}; - // ═══════════════════════════════════════════════════════════════════════════ -// Recursive element-line processor (supports nested array-in-array mapping) +// Main AST builder // ═══════════════════════════════════════════════════════════════════════════ -/** - * Process element lines inside an array mapping block. - * When an element line itself contains a nested `[] as iter { ... }` block, - * this function registers the inner iterator and recurses into the nested - * element lines, building wires with the correct concatenated paths. - */ -function processElementLines( - elemLines: CstNode[], - arrayToPath: string[], - iterScope: string | string[], - bridgeType: string, - bridgeField: string, - wires: Wire[], - arrayIterators: Record, - buildSourceExpr: ( - node: CstNode, - lineNum: number, - iterScope?: string | string[], - ) => NodeRef, - extractCoalesceAlt: ( - altNode: CstNode, - lineNum: number, - iterScope?: string | string[], - ) => - | { literal: string } - | { sourceRef: NodeRef } - | { control: ControlFlowInstruction }, - desugarExprChain: ( - leftRef: NodeRef, - exprOps: CstNode[], - exprRights: CstNode[], - lineNum: number, - iterScope?: string | string[], - safe?: boolean, - loc?: SourceLocation, - ) => NodeRef, - extractTernaryBranchFn: ( - branchNode: CstNode, - lineNum: number, - iterScope?: string | string[], - ) => - | { kind: "literal"; value: string; loc?: SourceLocation } - | { kind: "ref"; ref: NodeRef; loc?: SourceLocation }, - processLocalBindings: ( - withDecls: CstNode[], - iterScope: string | string[], - ) => () => void, - processLocalToolBindings: (withDecls: CstNode[]) => { - writableHandles: Set; - cleanup: () => void; - }, - processElementHandleWires: ( - wireNodes: CstNode[], - iterScope: string | string[], - writableHandles: Set, - ) => void, - desugarTemplateStringFn: ( - segs: TemplateSeg[], - lineNum: number, - iterScope?: string | string[], - loc?: SourceLocation, - ) => NodeRef, - desugarNotFn: ( - sourceRef: NodeRef, - lineNum: number, - safe?: boolean, - loc?: SourceLocation, - ) => NodeRef, - resolveParenExprFn: ( - parenNode: CstNode, - lineNum: number, - iterScope?: string | string[], - safe?: boolean, - loc?: SourceLocation, - ) => NodeRef, -): void { - const iterNames = Array.isArray(iterScope) ? iterScope : [iterScope]; +function toBridgeAst( + cst: CstNode, + previousInstructions?: Instruction[], +): { + version: string; + instructions: Instruction[]; + startLines: Map; +} { + const instructions: Instruction[] = []; + const startLines = new Map(); - function resolveScopedIterRef( - root: string, - segments: string[], - ): NodeRef | undefined { - for (let index = iterNames.length - 1; index >= 0; index--) { - if (iterNames[index] !== root) continue; - const elementDepth = iterNames.length - 1 - index; - return { - module: SELF_MODULE, - type: bridgeType, - field: bridgeField, - element: true, - ...(elementDepth > 0 ? { elementDepth } : {}), - path: segments, - }; - } - return undefined; - } + // If called from passthrough expansion, seed with prior context + const contextInstructions: Instruction[] = previousInstructions + ? [...previousInstructions] + : []; - function extractCoalesceAltIterAware( - altNode: CstNode, - lineNum: number, - ): - | { literal: string } - | { sourceRef: NodeRef } - | { control: ControlFlowInstruction } { - const c = altNode.children; - if (c.sourceAlt) { - const srcNode = (c.sourceAlt as CstNode[])[0]; - const headNode = sub(srcNode, "head"); - if (headNode) { - const { root, segments } = extractAddressPath(headNode); - const pipeSegs = subs(srcNode, "pipeSegment"); - const iterRef = - pipeSegs.length === 0 - ? resolveScopedIterRef(root, segments) - : undefined; - if (iterRef) { - return { - sourceRef: iterRef, - }; - } - } - } - return extractCoalesceAlt(altNode, lineNum, iterNames); + // ── Version check ── + const versionDecl = sub(cst, "versionDecl"); + if (!versionDecl) { + throw new Error( + `Missing version declaration. Bridge files must begin with: version ${BRIDGE_VERSION}`, + ); } - - for (const elemLine of elemLines) { - const elemC = elemLine.children; - const elemLineNum = line(findFirstToken(elemLine)); - const elemLineLoc = locFromNode(elemLine); - const elemTargetPathStr = extractDottedPathStr( - sub(elemLine, "elemTarget")!, + const versionTok = tok(versionDecl, "ver"); + const versionNum = versionTok?.image; + if (!versionNum) { + throw new Error( + `Missing version number. Bridge files must begin with: version ${BRIDGE_VERSION}`, ); - const elemToPath = [...arrayToPath, ...parsePath(elemTargetPathStr)]; - - if (elemC.elemEquals) { - const value = extractBareValue(sub(elemLine, "elemValue")!); - wires.push( - withLoc( - { - to: { - module: SELF_MODULE, - type: bridgeType, - field: bridgeField, - element: true, - path: elemToPath, - }, - sources: [{ expr: { type: "literal", value } }], - }, - elemLineLoc, - ), - ); - } else if (elemC.elemArrow) { - // ── String source in element context: .field <- "..." ── - const elemStrToken = ( - elemC.elemStringSource as IToken[] | undefined - )?.[0]; - if (elemStrToken && desugarTemplateStringFn) { - const raw = elemStrToken.image.slice(1, -1); - const segs = parseTemplateString(raw); - - const elemToRef: NodeRef = { - module: SELF_MODULE, - type: bridgeType, - field: bridgeField, - path: elemToPath, - }; - - // Process coalesce modifiers - const fallbacks: WireSourceEntry[] = []; - const fallbackInternalWires: Wire[] = []; - for (const item of subs(elemLine, "elemCoalesceItem")) { - const type = tok(item, "falsyOp") - ? ("falsy" as const) - : ("nullish" as const); - const altNode = sub(item, "altValue")!; - const preLen = wires.length; - const altResult = extractCoalesceAltIterAware(altNode, elemLineNum); - fallbacks.push(buildSourceEntry(type, altNode, altResult)); - if ("sourceRef" in altResult) { - fallbackInternalWires.push(...wires.splice(preLen)); - } - } - let catchHandler: WireCatch | undefined; - let catchInternalWires: Wire[] = []; - const catchAlt = sub(elemLine, "elemCatchAlt"); - if (catchAlt) { - const preLen = wires.length; - const altResult = extractCoalesceAltIterAware(catchAlt, elemLineNum); - catchHandler = buildCatchHandler(catchAlt, altResult); - if ("sourceRef" in altResult) { - catchInternalWires = wires.splice(preLen); - } - } + } + // Accept any version whose major falls within the supported range. + // When the parser supports multiple majors (e.g. 1.x through 2.x), + // bridge files from any of them are valid syntax. + const vParts = versionNum.split("."); + const vMajor = parseInt(vParts[0], 10); + const supportedRange = + BRIDGE_MIN_MAJOR === BRIDGE_MAX_MAJOR + ? `${BRIDGE_MIN_MAJOR}.x` + : `${BRIDGE_MIN_MAJOR}.x – ${BRIDGE_MAX_MAJOR}.x`; + if (isNaN(vMajor) || vMajor < BRIDGE_MIN_MAJOR || vMajor > BRIDGE_MAX_MAJOR) { + throw new Error( + `Unsupported bridge major version "${versionNum}". This parser supports version ${supportedRange}`, + ); + } - if (segs) { - const concatOutRef = desugarTemplateStringFn( - segs, - elemLineNum, - iterNames, - elemLineLoc, - ); - const elemToRefWithElement: NodeRef = { ...elemToRef, element: true }; - wires.push( - withLoc( - { - to: elemToRefWithElement, - sources: [ - { expr: { type: "ref", ref: concatOutRef } }, - ...fallbacks, - ], - ...(catchHandler ? { catch: catchHandler } : {}), - pipe: true, - }, - elemLineLoc, - ), - ); - } else { - wires.push( - withLoc( - { - to: elemToRef, - sources: [ - { expr: { type: "literal", value: raw } }, - ...fallbacks, - ], - ...(catchHandler ? { catch: catchHandler } : {}), - }, - elemLineLoc, - ), - ); - } - wires.push(...fallbackInternalWires); - wires.push(...catchInternalWires); - continue; - } + // Store the declared version (lives on BridgeDocument, not in instructions). + const version = versionNum; - const elemSourceNode = sub(elemLine, "elemSource"); - const elemFirstParenNode = sub(elemLine, "elemFirstParenExpr"); + // Process in source order (same as old parser: all blocks sequentially) + // Chevrotain stores them by rule name, so we need to interleave by offset. + type TaggedNode = { offset: number; kind: string; node: CstNode }; + const tagged: TaggedNode[] = []; + for (const n of subs(cst, "constDecl")) + tagged.push({ + offset: findFirstToken(n)?.startOffset ?? 0, + kind: "const", + node: n, + }); + for (const n of subs(cst, "toolBlock")) + tagged.push({ + offset: findFirstToken(n)?.startOffset ?? 0, + kind: "tool", + node: n, + }); + for (const n of subs(cst, "defineBlock")) + tagged.push({ + offset: findFirstToken(n)?.startOffset ?? 0, + kind: "define", + node: n, + }); + for (const n of subs(cst, "bridgeBlock")) + tagged.push({ + offset: findFirstToken(n)?.startOffset ?? 0, + kind: "bridge", + node: n, + }); + tagged.sort((a, b) => a.offset - b.offset); - // Check if iterator-relative source (only for non-paren sources) - let elemHeadNode: CstNode | undefined; - let elemPipeSegs: CstNode[] = []; - let elemSrcRoot: string = ""; - let elemSrcSegs: string[] = []; - let elemSafe: boolean = false; - if (elemSourceNode) { - elemHeadNode = sub(elemSourceNode, "head")!; - elemPipeSegs = subs(elemSourceNode, "pipeSegment"); - const extracted = extractAddressPath(elemHeadNode); - elemSrcRoot = extracted.root; - elemSrcSegs = extracted.segments; - elemSafe = !!extracted.rootSafe; + for (const item of tagged) { + const startLine = findFirstToken(item.node)?.startLine ?? 1; + switch (item.kind) { + case "const": { + const inst = buildConstDef(item.node); + instructions.push(inst); + startLines.set(inst, startLine); + break; } - - // ── Nested array mapping: .legs <- j.legs[] as l { ... } ── - const nestedArrayNode = ( - elemC.nestedArrayMapping as CstNode[] | undefined - )?.[0]; - if (nestedArrayNode) { - // Emit the pass-through wire for the inner array source - let innerFromRef: NodeRef; - const directIterRef = - elemPipeSegs.length === 0 - ? resolveScopedIterRef(elemSrcRoot, elemSrcSegs) - : undefined; - if (directIterRef) { - innerFromRef = directIterRef; - } else { - innerFromRef = buildSourceExpr( - elemSourceNode!, - elemLineNum, - iterNames, - ); - } - const innerToRef: NodeRef = { - module: SELF_MODULE, - type: bridgeType, - field: bridgeField, - path: elemToPath, - }; - wires.push( - withLoc( - { - to: innerToRef, - sources: [{ expr: { type: "ref", ref: innerFromRef } }], - }, - elemLineLoc, - ), - ); - - // Register the inner iterator - const innerIterName = extractNameToken( - sub(nestedArrayNode, "iterName")!, - ); - assertNotReserved(innerIterName, elemLineNum, "iterator handle"); - // Key by the joined path for nested arrays (e.g. "legs" or "journeys.legs") - const iterKey = elemToPath.join("."); - arrayIterators[iterKey] = innerIterName; - - // Recurse into nested element lines - const nestedWithDecls = subs(nestedArrayNode, "elementWithDecl"); - const nestedToolWithDecls = subs( - nestedArrayNode, - "elementToolWithDecl", - ); - const { - writableHandles: nestedWritableHandles, - cleanup: nestedToolCleanup, - } = processLocalToolBindings(nestedToolWithDecls); - const nestedCleanup = processLocalBindings?.(nestedWithDecls, [ - ...iterNames, - innerIterName, + case "tool": { + const inst = buildToolDef(item.node, [ + ...contextInstructions, + ...instructions, ]); - processElementHandleWires( - subs(nestedArrayNode, "elementHandleWire"), - [...iterNames, innerIterName], - nestedWritableHandles, - ); - processElementLines( - subs(nestedArrayNode, "elementLine"), - elemToPath, - [...iterNames, innerIterName], - bridgeType, - bridgeField, - wires, - arrayIterators, - buildSourceExpr, - extractCoalesceAlt, - desugarExprChain, - extractTernaryBranchFn, - processLocalBindings, - processLocalToolBindings, - processElementHandleWires, - desugarTemplateStringFn, - desugarNotFn, - resolveParenExprFn, - ); - nestedCleanup?.(); - nestedToolCleanup(); - continue; + instructions.push(inst); + startLines.set(inst, startLine); + break; } - - // ── Element pull wire (expression or plain) ── - const elemToRef: NodeRef = { - module: SELF_MODULE, - type: bridgeType, - field: bridgeField, - path: elemToPath, - }; - - const sourceParts: { ref: NodeRef; isPipeFork: boolean }[] = []; - - const elemExprOps = subs(elemLine, "elemExprOp"); - const elemExprRights = subs(elemLine, "elemExprRight"); - const elemCondLoc = locFromNodeRange( - elemFirstParenNode ?? elemSourceNode, - elemExprRights[elemExprRights.length - 1] ?? - elemFirstParenNode ?? - elemSourceNode, - ); - - // Compute condition ref (expression chain result or plain source) - let elemCondRef: NodeRef; - let elemCondIsPipeFork: boolean; - if (elemFirstParenNode && resolveParenExprFn) { - // First source is a parenthesized sub-expression - const parenRef = resolveParenExprFn( - elemFirstParenNode, - elemLineNum, - iterNames, - elemSafe || undefined, - ); - if (elemExprOps.length > 0 && desugarExprChain) { - elemCondRef = desugarExprChain( - parenRef, - elemExprOps, - elemExprRights, - elemLineNum, - iterNames, - elemSafe || undefined, - elemLineLoc, - ); - } else { - elemCondRef = parenRef; - } - elemCondIsPipeFork = true; - } else if (elemExprOps.length > 0 && desugarExprChain) { - // Expression in element line — desugar then merge with fallback path - let leftRef: NodeRef; - const directIterRef = - elemPipeSegs.length === 0 - ? resolveScopedIterRef(elemSrcRoot, elemSrcSegs) - : undefined; - if (directIterRef) { - leftRef = directIterRef; - } else { - leftRef = buildSourceExpr(elemSourceNode!, elemLineNum, iterNames); - } - elemCondRef = desugarExprChain( - leftRef, - elemExprOps, - elemExprRights, - elemLineNum, - iterNames, - elemSafe || undefined, - elemLineLoc, - ); - elemCondIsPipeFork = true; - } else if (elemPipeSegs.length === 0) { - const directIterRef = resolveScopedIterRef(elemSrcRoot, elemSrcSegs); - if (directIterRef) { - elemCondRef = directIterRef; - elemCondIsPipeFork = false; - } else { - elemCondRef = buildSourceExpr( - elemSourceNode!, - elemLineNum, - iterNames, - ); - elemCondIsPipeFork = false; + case "define": { + const inst = buildDefineDef(item.node); + instructions.push(inst); + startLines.set(inst, startLine); + break; + } + case "bridge": { + const newInsts = buildBridge(item.node, [ + ...contextInstructions, + ...instructions, + ]); + for (const bi of newInsts) { + instructions.push(bi); + startLines.set(bi, startLine); } - } else { - elemCondRef = buildSourceExpr(elemSourceNode!, elemLineNum, iterNames); - elemCondIsPipeFork = - elemCondRef.instance != null && - elemCondRef.path.length === 0 && - elemPipeSegs.length > 0; + break; } + } + } - // ── Apply `not` prefix if present (element context) ── - if ((elemC.elemNotPrefix as IToken[] | undefined)?.[0] && desugarNotFn) { - elemCondRef = desugarNotFn( - elemCondRef, - elemLineNum, - elemSafe || undefined, - elemLineLoc, - ); - elemCondIsPipeFork = true; - } + return { version, instructions, startLines }; +} - // ── Ternary wire in element context ── - const elemTernaryOp = (elemC.elemTernaryOp as IToken[] | undefined)?.[0]; - if (elemTernaryOp && extractTernaryBranchFn) { - const thenNode = sub(elemLine, "elemThenBranch")!; - const elseNode = sub(elemLine, "elemElseBranch")!; - const thenBranch = extractTernaryBranchFn( - thenNode, - elemLineNum, - iterNames, - ); - const elseBranch = extractTernaryBranchFn( - elseNode, - elemLineNum, - iterNames, - ); - - // Process coalesce alternatives. - const elemFallbacks: WireSourceEntry[] = []; - const elemFallbackInternalWires: Wire[] = []; - for (const item of subs(elemLine, "elemCoalesceItem")) { - const type = tok(item, "falsyOp") - ? ("falsy" as const) - : ("nullish" as const); - const altNode = sub(item, "altValue")!; - const preLen = wires.length; - const altResult = extractCoalesceAltIterAware(altNode, elemLineNum); - elemFallbacks.push(buildSourceEntry(type, altNode, altResult)); - if ("sourceRef" in altResult) { - elemFallbackInternalWires.push(...wires.splice(preLen)); - } - } - - // Process catch error fallback. - let elemCatchHandler: WireCatch | undefined; - let elemCatchFallbackInternalWires: Wire[] = []; - const elemCatchAlt = sub(elemLine, "elemCatchAlt"); - if (elemCatchAlt) { - const preLen = wires.length; - const altResult = extractCoalesceAltIterAware( - elemCatchAlt, - elemLineNum, - ); - elemCatchHandler = buildCatchHandler(elemCatchAlt, altResult); - if ("sourceRef" in altResult) { - elemCatchFallbackInternalWires = wires.splice(preLen); - } - } - - wires.push( - withLoc( - { - to: elemToRef, - sources: [ - { - expr: { - type: "ternary", - cond: { type: "ref", ref: elemCondRef, loc: elemCondLoc }, - then: - thenBranch.kind === "ref" - ? { - type: "ref" as const, - ref: thenBranch.ref, - loc: thenBranch.loc, - } - : { - type: "literal" as const, - value: thenBranch.value, - loc: thenBranch.loc, - }, - else: - elseBranch.kind === "ref" - ? { - type: "ref" as const, - ref: elseBranch.ref, - loc: elseBranch.loc, - } - : { - type: "literal" as const, - value: elseBranch.value, - loc: elseBranch.loc, - }, - ...(elemCondLoc ? { condLoc: elemCondLoc } : {}), - thenLoc: thenBranch.loc, - elseLoc: elseBranch.loc, - }, - }, - ...elemFallbacks, - ], - ...(elemCatchHandler ? { catch: elemCatchHandler } : {}), - }, - elemLineLoc, - ), - ); - wires.push(...elemFallbackInternalWires); - wires.push(...elemCatchFallbackInternalWires); - continue; - } - - sourceParts.push({ ref: elemCondRef, isPipeFork: elemCondIsPipeFork }); - - // Coalesce alternatives (|| and ??) - const fallbacks: WireSourceEntry[] = []; - const fallbackInternalWires: Wire[] = []; - for (const item of subs(elemLine, "elemCoalesceItem")) { - const type = tok(item, "falsyOp") - ? ("falsy" as const) - : ("nullish" as const); - const altNode = sub(item, "altValue")!; - const preLen = wires.length; - const altResult = extractCoalesceAltIterAware(altNode, elemLineNum); - fallbacks.push(buildSourceEntry(type, altNode, altResult)); - if ("sourceRef" in altResult) { - fallbackInternalWires.push(...wires.splice(preLen)); - } - } - - // catch error fallback - let catchHandler: WireCatch | undefined; - let catchInternalWires: Wire[] = []; - const catchAlt = sub(elemLine, "elemCatchAlt"); - if (catchAlt) { - const preLen = wires.length; - const altResult = extractCoalesceAltIterAware(catchAlt, elemLineNum); - catchHandler = buildCatchHandler(catchAlt, altResult); - if ("sourceRef" in altResult) { - catchInternalWires = wires.splice(preLen); - } - } - - // Emit wire - const { ref: fromRef, isPipeFork } = sourceParts[0]; - - wires.push( - withLoc( - { - to: elemToRef, - sources: [{ expr: { type: "ref", ref: fromRef } }, ...fallbacks], - ...(catchHandler ? { catch: catchHandler } : {}), - ...(isPipeFork ? { pipe: true as const } : {}), - }, - elemLineLoc, - ), - ); - wires.push(...fallbackInternalWires); - wires.push(...catchInternalWires); - } else if (elemC.elemScopeBlock) { - // ── Path scope block inside array mapping: .field { lines: .sub <- ..., ...source } ── - const scopeLines = subs(elemLine, "elemScopeLine"); - // Process spread lines at the top level of this scope block - const spreadLines = subs(elemLine, "elemSpreadLine"); - for (const spreadLine of spreadLines) { - const spreadLineNum = line(findFirstToken(spreadLine)); - const sourceNode = sub(spreadLine, "spreadSource")!; - const fromRef = buildSourceExpr(sourceNode, spreadLineNum, iterNames); - // Propagate safe navigation (?.) flag from source expression - const headNode = sub(sourceNode, "head")!; - const pipeNodes = subs(sourceNode, "pipeSegment"); - const actualNode = - pipeNodes.length > 0 ? pipeNodes[pipeNodes.length - 1]! : headNode; - const { safe: spreadSafe } = extractAddressPath(actualNode); - wires.push( - withLoc( - { - to: { - module: SELF_MODULE, - type: bridgeType, - field: bridgeField, - element: true, - path: elemToPath, - }, - sources: [ - { - expr: { - type: "ref", - ref: fromRef, - ...(spreadSafe ? { safe: true as const } : {}), - }, - }, - ], - spread: true as const, - }, - locFromNode(spreadLine), - ), - ); - } - processElementScopeLines( - scopeLines, - elemToPath, - [], - iterNames, - bridgeType, - bridgeField, - wires, - buildSourceExpr, - extractCoalesceAlt, - desugarExprChain, - extractTernaryBranchFn, - desugarTemplateStringFn, - desugarNotFn, - resolveParenExprFn, - ); - } - } -} -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Recursively flatten path-scope blocks (`pathScopeLine` CST nodes) that - * appear inside an array-mapping block. Mirrors `processScopeLines` in - * `buildBridgeBody` but emits element-context wires (same as - * `processElementLines`). - */ -function processElementScopeLines( - scopeLines: CstNode[], - arrayToPath: string[], - pathPrefix: string[], - iterScope: string | string[], - bridgeType: string, - bridgeField: string, - wires: Wire[], - buildSourceExpr: ( - node: CstNode, - lineNum: number, - iterScope?: string | string[], - ) => NodeRef, - extractCoalesceAlt: ( - altNode: CstNode, - lineNum: number, - iterScope?: string | string[], - ) => - | { literal: string } - | { sourceRef: NodeRef } - | { control: ControlFlowInstruction }, - desugarExprChain?: ( - leftRef: NodeRef, - exprOps: CstNode[], - exprRights: CstNode[], - lineNum: number, - iterScope?: string | string[], - safe?: boolean, - loc?: SourceLocation, - ) => NodeRef, - extractTernaryBranchFn?: ( - branchNode: CstNode, - lineNum: number, - iterScope?: string | string[], - ) => - | { kind: "literal"; value: string; loc?: SourceLocation } - | { kind: "ref"; ref: NodeRef; loc?: SourceLocation }, - desugarTemplateStringFn?: ( - segs: TemplateSeg[], - lineNum: number, - iterScope?: string | string[], - loc?: SourceLocation, - ) => NodeRef, - desugarNotFn?: ( - sourceRef: NodeRef, - lineNum: number, - safe?: boolean, - loc?: SourceLocation, - ) => NodeRef, - resolveParenExprFn?: ( - parenNode: CstNode, - lineNum: number, - iterScope?: string | string[], - safe?: boolean, - loc?: SourceLocation, - ) => NodeRef, -): void { - const iterNames = Array.isArray(iterScope) ? iterScope : [iterScope]; - - function resolveScopedIterRef( - root: string, - segments: string[], - ): NodeRef | undefined { - for (let index = iterNames.length - 1; index >= 0; index--) { - if (iterNames[index] !== root) continue; - const elementDepth = iterNames.length - 1 - index; - return { - module: SELF_MODULE, - type: bridgeType, - field: bridgeField, - element: true, - ...(elementDepth > 0 ? { elementDepth } : {}), - path: segments, - }; - } - return undefined; - } - - function extractCoalesceAltIterAware( - altNode: CstNode, - lineNum: number, - ): - | { literal: string } - | { sourceRef: NodeRef } - | { control: ControlFlowInstruction } { - const c = altNode.children; - if (c.sourceAlt) { - const srcNode = (c.sourceAlt as CstNode[])[0]; - const headNode = sub(srcNode, "head"); - if (headNode) { - const { root, segments } = extractAddressPath(headNode); - const pipeSegs = subs(srcNode, "pipeSegment"); - const iterRef = - pipeSegs.length === 0 - ? resolveScopedIterRef(root, segments) - : undefined; - if (iterRef) { - return { - sourceRef: iterRef, - }; - } - } - } - return extractCoalesceAlt(altNode, lineNum, iterNames); - } - - for (const scopeLine of scopeLines) { - const sc = scopeLine.children; - const scopeLineNum = line(findFirstToken(scopeLine)); - const scopeLineLoc = locFromNode(scopeLine); - const targetStr = extractDottedPathStr(sub(scopeLine, "scopeTarget")!); - const scopeSegs = parsePath(targetStr); - const fullSegs = [...pathPrefix, ...scopeSegs]; - - // ── Nested scope: .field { ... } ── - const nestedScopeLines = subs(scopeLine, "pathScopeLine"); - const nestedSpreadLines = subs(scopeLine, "scopeSpreadLine"); - if ( - (nestedScopeLines.length > 0 || nestedSpreadLines.length > 0) && - !sc.scopeEquals && - !sc.scopeArrow - ) { - // Process spread lines inside this nested scope block: ...sourceExpr - const spreadToPath = [...arrayToPath, ...fullSegs]; - for (const spreadLine of nestedSpreadLines) { - const spreadLineNum = line(findFirstToken(spreadLine)); - const sourceNode = sub(spreadLine, "spreadSource")!; - const fromRef = buildSourceExpr(sourceNode, spreadLineNum, iterNames); - // Propagate safe navigation (?.) flag from source expression - const headNode = sub(sourceNode, "head")!; - const pipeNodes = subs(sourceNode, "pipeSegment"); - const actualNode = - pipeNodes.length > 0 ? pipeNodes[pipeNodes.length - 1]! : headNode; - const { safe: spreadSafe } = extractAddressPath(actualNode); - wires.push( - withLoc( - { - to: { - module: SELF_MODULE, - type: bridgeType, - field: bridgeField, - element: true, - path: spreadToPath, - }, - sources: [ - { - expr: { - type: "ref", - ref: fromRef, - ...(spreadSafe ? { safe: true as const } : {}), - }, - }, - ], - spread: true as const, - }, - locFromNode(spreadLine), - ), - ); - } - processElementScopeLines( - nestedScopeLines, - arrayToPath, - fullSegs, - iterNames, - bridgeType, - bridgeField, - wires, - buildSourceExpr, - extractCoalesceAlt, - desugarExprChain, - extractTernaryBranchFn, - desugarTemplateStringFn, - desugarNotFn, - resolveParenExprFn, - ); - continue; - } - - const elemToPath = [...arrayToPath, ...fullSegs]; - - // ── Constant wire: .field = value ── - if (sc.scopeEquals) { - const value = extractBareValue(sub(scopeLine, "scopeValue")!); - wires.push( - withLoc( - { - to: { - module: SELF_MODULE, - type: bridgeType, - field: bridgeField, - element: true, - path: elemToPath, - }, - sources: [{ expr: { type: "literal", value } }], - }, - scopeLineLoc, - ), - ); - continue; - } - - // ── Pull wire: .field <- source [modifiers] ── - if (sc.scopeArrow) { - const elemToRef: NodeRef = { - module: SELF_MODULE, - type: bridgeType, - field: bridgeField, - path: elemToPath, - }; - - // String source (template or plain): .field <- "..." - const stringSourceToken = ( - sc.scopeStringSource as IToken[] | undefined - )?.[0]; - if (stringSourceToken && desugarTemplateStringFn) { - const raw = stringSourceToken.image.slice(1, -1); - const segs = parseTemplateString(raw); - - const fallbacks: WireSourceEntry[] = []; - const fallbackInternalWires: Wire[] = []; - for (const item of subs(scopeLine, "scopeCoalesceItem")) { - const type = tok(item, "falsyOp") - ? ("falsy" as const) - : ("nullish" as const); - const altNode = sub(item, "altValue")!; - const preLen = wires.length; - const altResult = extractCoalesceAltIterAware(altNode, scopeLineNum); - fallbacks.push(buildSourceEntry(type, altNode, altResult)); - if ("sourceRef" in altResult) { - fallbackInternalWires.push(...wires.splice(preLen)); - } - } - let catchHandler: WireCatch | undefined; - let catchInternalWires: Wire[] = []; - const catchAlt = sub(scopeLine, "scopeCatchAlt"); - if (catchAlt) { - const preLen = wires.length; - const altResult = extractCoalesceAltIterAware(catchAlt, scopeLineNum); - catchHandler = buildCatchHandler(catchAlt, altResult); - if ("sourceRef" in altResult) { - catchInternalWires = wires.splice(preLen); - } - } - - if (segs) { - const concatOutRef = desugarTemplateStringFn( - segs, - scopeLineNum, - iterNames, - scopeLineLoc, - ); - wires.push( - withLoc( - { - to: { ...elemToRef, element: true }, - sources: [ - { expr: { type: "ref", ref: concatOutRef } }, - ...fallbacks, - ], - ...(catchHandler ? { catch: catchHandler } : {}), - pipe: true, - }, - scopeLineLoc, - ), - ); - } else { - wires.push( - withLoc( - { - to: elemToRef, - sources: [ - { expr: { type: "literal", value: raw } }, - ...fallbacks, - ], - ...(catchHandler ? { catch: catchHandler } : {}), - }, - scopeLineLoc, - ), - ); - } - wires.push(...fallbackInternalWires); - wires.push(...catchInternalWires); - continue; - } - - // Normal source expression - const scopeSourceNode = sub(scopeLine, "scopeSource"); - const scopeFirstParenNode = sub(scopeLine, "scopeFirstParenExpr"); - let scopeHeadNode: CstNode | undefined; - let scopePipeSegs: CstNode[] = []; - let srcRoot: string = ""; - let srcSegs: string[] = []; - let scopeSafe: boolean = false; - if (scopeSourceNode) { - scopeHeadNode = sub(scopeSourceNode, "head")!; - scopePipeSegs = subs(scopeSourceNode, "pipeSegment"); - const extracted = extractAddressPath(scopeHeadNode); - srcRoot = extracted.root; - srcSegs = extracted.segments; - scopeSafe = !!extracted.rootSafe; - } - - const exprOps = subs(scopeLine, "scopeExprOp"); - const exprRights = subs(scopeLine, "scopeExprRight"); - const condLoc = locFromNodeRange( - scopeFirstParenNode ?? scopeSourceNode, - exprRights[exprRights.length - 1] ?? - scopeFirstParenNode ?? - scopeSourceNode, - ); - let condRef: NodeRef; - let condIsPipeFork: boolean; - if (scopeFirstParenNode && resolveParenExprFn) { - const parenRef = resolveParenExprFn( - scopeFirstParenNode, - scopeLineNum, - iterNames, - scopeSafe || undefined, - scopeLineLoc, - ); - if (exprOps.length > 0 && desugarExprChain) { - condRef = desugarExprChain( - parenRef, - exprOps, - exprRights, - scopeLineNum, - iterNames, - scopeSafe || undefined, - scopeLineLoc, - ); - } else { - condRef = parenRef; - } - condIsPipeFork = true; - } else if (exprOps.length > 0 && desugarExprChain) { - let leftRef: NodeRef; - const directIterRef = - scopePipeSegs.length === 0 - ? resolveScopedIterRef(srcRoot, srcSegs) - : undefined; - if (directIterRef) { - leftRef = directIterRef; - } else { - leftRef = buildSourceExpr(scopeSourceNode!, scopeLineNum, iterNames); - } - condRef = desugarExprChain( - leftRef, - exprOps, - exprRights, - scopeLineNum, - iterNames, - scopeSafe || undefined, - scopeLineLoc, - ); - condIsPipeFork = true; - } else if (scopePipeSegs.length === 0) { - const directIterRef = resolveScopedIterRef(srcRoot, srcSegs); - if (directIterRef) { - condRef = directIterRef; - condIsPipeFork = false; - } else { - condRef = buildSourceExpr(scopeSourceNode!, scopeLineNum, iterNames); - condIsPipeFork = false; - } - } else { - condRef = buildSourceExpr(scopeSourceNode!, scopeLineNum, iterNames); - condIsPipeFork = - condRef.instance != null && - condRef.path.length === 0 && - scopePipeSegs.length > 0; - } - - // ── Apply `not` prefix if present (scope context) ── - if ((sc.scopeNotPrefix as IToken[] | undefined)?.[0] && desugarNotFn) { - condRef = desugarNotFn(condRef, scopeLineNum, scopeSafe || undefined); - condIsPipeFork = true; - } - - // Ternary wire: .field <- cond ? then : else - const scopeTernaryOp = (sc.scopeTernaryOp as IToken[] | undefined)?.[0]; - if (scopeTernaryOp && extractTernaryBranchFn) { - const thenNode = sub(scopeLine, "scopeThenBranch")!; - const elseNode = sub(scopeLine, "scopeElseBranch")!; - const thenBranch = extractTernaryBranchFn( - thenNode, - scopeLineNum, - iterNames, - ); - const elseBranch = extractTernaryBranchFn( - elseNode, - scopeLineNum, - iterNames, - ); - - const fallbacks: WireSourceEntry[] = []; - const fallbackInternalWires: Wire[] = []; - for (const item of subs(scopeLine, "scopeCoalesceItem")) { - const type = tok(item, "falsyOp") - ? ("falsy" as const) - : ("nullish" as const); - const altNode = sub(item, "altValue")!; - const preLen = wires.length; - const altResult = extractCoalesceAltIterAware(altNode, scopeLineNum); - fallbacks.push(buildSourceEntry(type, altNode, altResult)); - if ("sourceRef" in altResult) { - fallbackInternalWires.push(...wires.splice(preLen)); - } - } - let catchHandler: WireCatch | undefined; - let catchInternalWires: Wire[] = []; - const catchAlt = sub(scopeLine, "scopeCatchAlt"); - if (catchAlt) { - const preLen = wires.length; - const altResult = extractCoalesceAltIterAware(catchAlt, scopeLineNum); - catchHandler = buildCatchHandler(catchAlt, altResult); - if ("sourceRef" in altResult) { - catchInternalWires = wires.splice(preLen); - } - } - wires.push( - withLoc( - { - to: elemToRef, - sources: [ - { - expr: { - type: "ternary", - cond: { type: "ref", ref: condRef, loc: condLoc }, - then: - thenBranch.kind === "ref" - ? { - type: "ref" as const, - ref: thenBranch.ref, - loc: thenBranch.loc, - } - : { - type: "literal" as const, - value: thenBranch.value, - loc: thenBranch.loc, - }, - else: - elseBranch.kind === "ref" - ? { - type: "ref" as const, - ref: elseBranch.ref, - loc: elseBranch.loc, - } - : { - type: "literal" as const, - value: elseBranch.value, - loc: elseBranch.loc, - }, - ...(condLoc ? { condLoc } : {}), - thenLoc: thenBranch.loc, - elseLoc: elseBranch.loc, - }, - }, - ...fallbacks, - ], - ...(catchHandler ? { catch: catchHandler } : {}), - }, - scopeLineLoc, - ), - ); - wires.push(...fallbackInternalWires); - wires.push(...catchInternalWires); - continue; - } - - const sourceParts: { ref: NodeRef; isPipeFork: boolean }[] = []; - sourceParts.push({ ref: condRef, isPipeFork: condIsPipeFork }); - - // Coalesce alternatives (|| and ??) - const fallbacks: WireSourceEntry[] = []; - const fallbackInternalWires: Wire[] = []; - for (const item of subs(scopeLine, "scopeCoalesceItem")) { - const type = tok(item, "falsyOp") - ? ("falsy" as const) - : ("nullish" as const); - const altNode = sub(item, "altValue")!; - const preLen = wires.length; - const altResult = extractCoalesceAltIterAware(altNode, scopeLineNum); - fallbacks.push(buildSourceEntry(type, altNode, altResult)); - if ("sourceRef" in altResult) { - fallbackInternalWires.push(...wires.splice(preLen)); - } - } - - let catchHandler: WireCatch | undefined; - let catchInternalWires: Wire[] = []; - const catchAlt = sub(scopeLine, "scopeCatchAlt"); - if (catchAlt) { - const preLen = wires.length; - const altResult = extractCoalesceAltIterAware(catchAlt, scopeLineNum); - catchHandler = buildCatchHandler(catchAlt, altResult); - if ("sourceRef" in altResult) { - catchInternalWires = wires.splice(preLen); - } - } - - const { ref: fromRef, isPipeFork: isPipe } = sourceParts[0]; - wires.push( - withLoc( - { - to: elemToRef, - sources: [ - { - expr: { - type: "ref", - ref: fromRef, - ...(condLoc ? { refLoc: condLoc } : {}), - }, - }, - ...fallbacks, - ], - ...(catchHandler ? { catch: catchHandler } : {}), - ...(isPipe ? { pipe: true as const } : {}), - }, - scopeLineLoc, - ), - ); - wires.push(...fallbackInternalWires); - wires.push(...catchInternalWires); - } - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Main AST builder -// ═══════════════════════════════════════════════════════════════════════════ - -function toBridgeAst( - cst: CstNode, - previousInstructions?: Instruction[], -): { - version: string; - instructions: Instruction[]; - startLines: Map; -} { - const instructions: Instruction[] = []; - const startLines = new Map(); - - // If called from passthrough expansion, seed with prior context - const contextInstructions: Instruction[] = previousInstructions - ? [...previousInstructions] - : []; - - // ── Version check ── - const versionDecl = sub(cst, "versionDecl"); - if (!versionDecl) { - throw new Error( - `Missing version declaration. Bridge files must begin with: version ${BRIDGE_VERSION}`, - ); - } - const versionTok = tok(versionDecl, "ver"); - const versionNum = versionTok?.image; - if (!versionNum) { - throw new Error( - `Missing version number. Bridge files must begin with: version ${BRIDGE_VERSION}`, - ); - } - // Accept any version whose major falls within the supported range. - // When the parser supports multiple majors (e.g. 1.x through 2.x), - // bridge files from any of them are valid syntax. - const vParts = versionNum.split("."); - const vMajor = parseInt(vParts[0], 10); - const supportedRange = - BRIDGE_MIN_MAJOR === BRIDGE_MAX_MAJOR - ? `${BRIDGE_MIN_MAJOR}.x` - : `${BRIDGE_MIN_MAJOR}.x – ${BRIDGE_MAX_MAJOR}.x`; - if (isNaN(vMajor) || vMajor < BRIDGE_MIN_MAJOR || vMajor > BRIDGE_MAX_MAJOR) { - throw new Error( - `Unsupported bridge major version "${versionNum}". This parser supports version ${supportedRange}`, - ); - } - - // Store the declared version (lives on BridgeDocument, not in instructions). - const version = versionNum; - - // Process in source order (same as old parser: all blocks sequentially) - // Chevrotain stores them by rule name, so we need to interleave by offset. - type TaggedNode = { offset: number; kind: string; node: CstNode }; - const tagged: TaggedNode[] = []; - for (const n of subs(cst, "constDecl")) - tagged.push({ - offset: findFirstToken(n)?.startOffset ?? 0, - kind: "const", - node: n, - }); - for (const n of subs(cst, "toolBlock")) - tagged.push({ - offset: findFirstToken(n)?.startOffset ?? 0, - kind: "tool", - node: n, - }); - for (const n of subs(cst, "defineBlock")) - tagged.push({ - offset: findFirstToken(n)?.startOffset ?? 0, - kind: "define", - node: n, - }); - for (const n of subs(cst, "bridgeBlock")) - tagged.push({ - offset: findFirstToken(n)?.startOffset ?? 0, - kind: "bridge", - node: n, - }); - tagged.sort((a, b) => a.offset - b.offset); - - for (const item of tagged) { - const startLine = findFirstToken(item.node)?.startLine ?? 1; - switch (item.kind) { - case "const": { - const inst = buildConstDef(item.node); - instructions.push(inst); - startLines.set(inst, startLine); - break; - } - case "tool": { - const inst = buildToolDef(item.node, [ - ...contextInstructions, - ...instructions, - ]); - instructions.push(inst); - startLines.set(inst, startLine); - break; - } - case "define": { - const inst = buildDefineDef(item.node); - instructions.push(inst); - startLines.set(inst, startLine); - break; - } - case "bridge": { - const newInsts = buildBridge(item.node, [ - ...contextInstructions, - ...instructions, - ]); - for (const bi of newInsts) { - instructions.push(bi); - startLines.set(bi, startLine); - } - break; - } - } - } - - return { version, instructions, startLines }; -} - -// ── Const ─────────────────────────────────────────────────────────────── +// ── Const ─────────────────────────────────────────────────────────────── function buildConstDef(node: CstNode): ConstDef { const nameNode = sub(node, "constName")!; @@ -3119,18 +1729,7 @@ function buildToolDef( // Tool blocks reuse bridgeBodyLine for with-declarations and handle-targeted wires const bodyLines = subs(node, "bridgeBodyLine"); const selfWireNodes = subs(node, "toolSelfWire"); - const { handles, wires, pipeHandles } = buildBridgeBody( - bodyLines, - "Tools", - toolName, - previousInstructions, - lineNum, - { - forbiddenHandleKinds: new Set(["input", "output"]), - selfWireNodes, - }, - ); - + const spreadNodes = subs(node, "toolSpreadLine"); // Extract on error from toolOnError CST nodes let onError: ToolDef["onError"]; for (const child of (node.children.toolOnError as CstNode[]) ?? []) { @@ -3144,15 +1743,27 @@ function buildToolDef( } } + // Build Statement[] body + const bodyResult = buildBody( + bodyLines, + "Tools", + toolName, + previousInstructions, + { + forbiddenHandleKinds: new Set(["input", "output"]), + selfWireNodes, + spreadNodes, + }, + ); + return { kind: "tool", name: toolName, fn: isKnownTool ? undefined : source, extends: isKnownTool ? source : undefined, - handles, - wires, - ...(pipeHandles.length > 0 ? { pipeHandles } : {}), + handles: bodyResult.handles, ...(onError ? { onError } : {}), + body: bodyResult.body, }; } @@ -3164,17 +1775,14 @@ function buildDefineDef(node: CstNode): DefineDef { assertNotReserved(name, lineNum, "define name"); const bodyLines = subs(node, "bridgeBodyLine"); - const { handles, wires, arrayIterators, pipeHandles, forces } = - buildBridgeBody(bodyLines, "Define", name, [], lineNum); + // Build Statement[] body + const bodyResult = buildBody(bodyLines, "Define", name, []); return { kind: "define", name, - handles, - wires, - ...(Object.keys(arrayIterators).length > 0 ? { arrayIterators } : {}), - ...(pipeHandles.length > 0 ? { pipeHandles } : {}), - ...(forces.length > 0 ? { forces } : {}), + handles: bodyResult.handles, + body: bodyResult.body, }; } @@ -3215,3794 +1823,22 @@ function buildBridge( // Full bridge block const bodyLines = subs(node, "bridgeBodyLine"); - const { handles, wires, arrayIterators, pipeHandles, forces } = - buildBridgeBody(bodyLines, typeName, fieldName, previousInstructions, 0); - - // Inline define invocations - const instanceCounters = new Map(); - for (const hb of handles) { - if (hb.kind !== "tool") continue; - const name = hb.name; - const lastDot = name.lastIndexOf("."); - if (lastDot !== -1) { - const key = `${name.substring(0, lastDot)}:${name.substring(lastDot + 1)}`; - instanceCounters.set(key, (instanceCounters.get(key) ?? 0) + 1); - } else { - const key = `Tools:${name}`; - instanceCounters.set(key, (instanceCounters.get(key) ?? 0) + 1); - } - } - const nextForkSeqRef = { - value: - pipeHandles.length > 0 - ? Math.max( - ...pipeHandles - .map((p) => { - const parts = p.key.split(":"); - return parseInt(parts[parts.length - 1]) || 0; - }) - .filter((n) => n >= 100000) - .map((n) => n - 100000 + 1), - 0, - ) - : 0, - }; - - for (const hb of handles) { - if (hb.kind !== "define") continue; - const def = previousInstructions.find( - (inst): inst is DefineDef => - inst.kind === "define" && inst.name === hb.name, - ); - if (!def) { - throw new Error( - `Define "${hb.name}" referenced by handle "${hb.handle}" not found`, - ); - } - inlineDefine( - hb.handle, - def, - typeName, - fieldName, - wires, - pipeHandles, - handles, - instanceCounters, - nextForkSeqRef, - ); - } + // Build Statement[] body + const bodyResult = buildBody( + bodyLines, + typeName, + fieldName, + previousInstructions, + ); const instructions: Instruction[] = []; instructions.push({ kind: "bridge", type: typeName, field: fieldName, - handles, - wires, - arrayIterators: - Object.keys(arrayIterators).length > 0 ? arrayIterators : undefined, - pipeHandles: pipeHandles.length > 0 ? pipeHandles : undefined, - forces: forces.length > 0 ? forces : undefined, + handles: bodyResult.handles, + body: bodyResult.body, }); return instructions; } - -// ═══════════════════════════════════════════════════════════════════════════ -// Bridge/Define body builder -// ═══════════════════════════════════════════════════════════════════════════ - -function buildBridgeBody( - bodyLines: CstNode[], - bridgeType: string, - bridgeField: string, - previousInstructions: Instruction[], - _lineOffset: number, - options?: { - /** Handle kinds that are not allowed in this block (e.g. "input"/"output" in tool blocks). */ - forbiddenHandleKinds?: Set; - /** Self-wire element line CST nodes to process (tool blocks). */ - selfWireNodes?: CstNode[]; - }, -): { - handles: HandleBinding[]; - wires: Wire[]; - arrayIterators: Record; - pipeHandles: NonNullable; - forces: NonNullable; - handleRes: Map; -} { - const handleRes = new Map(); - const handleBindings: HandleBinding[] = []; - const instanceCounters = new Map(); - const wires: Wire[] = []; - const arrayIterators: Record = {}; - let nextForkSeq = 0; - const pipeHandleEntries: NonNullable = []; - - // ── Step 1: Process with-declarations ───────────────────────────────── - - for (const bodyLine of bodyLines) { - const withNode = ( - bodyLine.children.bridgeWithDecl as CstNode[] | undefined - )?.[0]; - if (!withNode) continue; - const wc = withNode.children; - const lineNum = line(findFirstToken(withNode)); - - const checkDuplicate = (handle: string) => { - if (handleRes.has(handle)) { - throw new Error(`Line ${lineNum}: Duplicate handle name "${handle}"`); - } - }; - - if (wc.inputKw) { - if (options?.forbiddenHandleKinds?.has("input")) { - throw new Error( - `Line ${lineNum}: 'with input' is not allowed in tool blocks`, - ); - } - if (wc.memoizeKw) { - throw new Error( - `Line ${lineNum}: memoize is only valid for tool references`, - ); - } - const handle = wc.inputAlias - ? extractNameToken((wc.inputAlias as CstNode[])[0]) - : "input"; - checkDuplicate(handle); - handleBindings.push({ handle, kind: "input" }); - handleRes.set(handle, { - module: SELF_MODULE, - type: bridgeType, - field: bridgeField, - }); - } else if (wc.outputKw) { - if (options?.forbiddenHandleKinds?.has("output")) { - throw new Error( - `Line ${lineNum}: 'with output' is not allowed in tool blocks`, - ); - } - if (wc.memoizeKw) { - throw new Error( - `Line ${lineNum}: memoize is only valid for tool references`, - ); - } - const handle = wc.outputAlias - ? extractNameToken((wc.outputAlias as CstNode[])[0]) - : "output"; - checkDuplicate(handle); - handleBindings.push({ handle, kind: "output" }); - handleRes.set(handle, { - module: SELF_MODULE, - type: bridgeType, - field: bridgeField, - }); - } else if (wc.contextKw) { - if (wc.memoizeKw) { - throw new Error( - `Line ${lineNum}: memoize is only valid for tool references`, - ); - } - const handle = wc.contextAlias - ? extractNameToken((wc.contextAlias as CstNode[])[0]) - : "context"; - checkDuplicate(handle); - handleBindings.push({ handle, kind: "context" }); - handleRes.set(handle, { - module: SELF_MODULE, - type: "Context", - field: "context", - }); - } else if (wc.constKw) { - if (wc.memoizeKw) { - throw new Error( - `Line ${lineNum}: memoize is only valid for tool references`, - ); - } - const handle = wc.constAlias - ? extractNameToken((wc.constAlias as CstNode[])[0]) - : "const"; - checkDuplicate(handle); - handleBindings.push({ handle, kind: "const" }); - handleRes.set(handle, { - module: SELF_MODULE, - type: "Const", - field: "const", - }); - } else if (wc.refName) { - const name = extractDottedName((wc.refName as CstNode[])[0]); - const versionTag = ( - wc.refVersion as IToken[] | undefined - )?.[0]?.image.slice(1); - const lastDot = name.lastIndexOf("."); - const defaultHandle = lastDot !== -1 ? name.substring(lastDot + 1) : name; - const handle = wc.refAlias - ? extractNameToken((wc.refAlias as CstNode[])[0]) - : defaultHandle; - const memoize = !!wc.memoizeKw; - - checkDuplicate(handle); - if (wc.refAlias) assertNotReserved(handle, lineNum, "handle alias"); - - // Check if it's a define reference - const defineDef = previousInstructions.find( - (inst): inst is DefineDef => - inst.kind === "define" && inst.name === name, - ); - if (defineDef) { - if (memoize) { - throw new Error( - `Line ${lineNum}: memoize is only valid for tool references`, - ); - } - handleBindings.push({ handle, kind: "define", name }); - handleRes.set(handle, { - module: `__define_${handle}`, - type: bridgeType, - field: bridgeField, - }); - } else if (lastDot !== -1) { - const modulePart = name.substring(0, lastDot); - const fieldPart = name.substring(lastDot + 1); - const key = `${modulePart}:${fieldPart}`; - const instance = (instanceCounters.get(key) ?? 0) + 1; - instanceCounters.set(key, instance); - handleBindings.push({ - handle, - kind: "tool", - name, - ...(memoize ? { memoize: true as const } : {}), - ...(versionTag ? { version: versionTag } : {}), - }); - handleRes.set(handle, { - module: modulePart, - type: bridgeType, - field: fieldPart, - instance, - }); - } else { - const key = `Tools:${name}`; - const instance = (instanceCounters.get(key) ?? 0) + 1; - instanceCounters.set(key, instance); - handleBindings.push({ - handle, - kind: "tool", - name, - ...(memoize ? { memoize: true as const } : {}), - ...(versionTag ? { version: versionTag } : {}), - }); - handleRes.set(handle, { - module: SELF_MODULE, - type: "Tools", - field: name, - instance, - }); - } - } - } - - // ── Helper: resolve address ──────────────────────────────────────────── - - function normalizeIterScope(iterScope?: string | string[]): string[] { - if (!iterScope) return []; - return Array.isArray(iterScope) ? iterScope : [iterScope]; - } - - function resolveIterRef( - root: string, - segments: string[], - iterScope?: string | string[], - ): NodeRef | undefined { - const names = normalizeIterScope(iterScope); - for (let index = names.length - 1; index >= 0; index--) { - if (names[index] !== root) continue; - const elementDepth = names.length - 1 - index; - return { - module: SELF_MODULE, - type: bridgeType, - field: bridgeField, - element: true, - ...(elementDepth > 0 ? { elementDepth } : {}), - path: [...segments], - }; - } - return undefined; - } - - function resolveAddress( - root: string, - segments: string[], - lineNum: number, - ): NodeRef { - const resolution = handleRes.get(root); - if (!resolution) { - if (segments.length === 0) { - throw new Error( - `Line ${lineNum}: Undeclared reference "${root}". Add 'with output as o' for output fields, or 'with ${root}' for a tool.`, - ); - } - throw new Error( - `Line ${lineNum}: Undeclared handle "${root}". Add 'with ${root}' or 'with ${root} as ${root}' to the bridge header.`, - ); - } - const ref: NodeRef = { - module: resolution.module, - type: resolution.type, - field: resolution.field, - path: [...segments], - }; - if (resolution.instance != null) ref.instance = resolution.instance; - return ref; - } - - function assertNoTargetIndices(ref: NodeRef, lineNum: number): void { - if (ref.path.some((seg) => /^\d+$/.test(seg))) { - throw new Error( - `Line ${lineNum}: Explicit array index in wire target is not supported. Use array mapping (\`[] as iter { }\`) instead.`, - ); - } - } - - // ── Helper: process block-scoped with-declarations inside array maps ── - - /** - * Process `with as ` declarations inside an array mapping. - * For each declaration: - * 1. Build the source ref (iterator-aware: pipe:it becomes a pipe fork ref) - * 2. Create a __local trunk for the alias - * 3. Register the alias in handleRes so subsequent element lines can reference it - * 4. Emit a wire from source to the local trunk - * - * Returns a cleanup function that removes local aliases from handleRes. - */ - function processLocalBindings( - withDecls: CstNode[], - iterScope: string | string[], - ): () => void { - const shadowedAliases = new Map(); - for (const withDecl of withDecls) { - const lineNum = line(findFirstToken(withDecl)); - const sourceNode = sub(withDecl, "elemWithSource")!; - const alias = extractNameToken(sub(withDecl, "elemWithAlias")!); - assertNotReserved(alias, lineNum, "local binding alias"); - if (shadowedAliases.has(alias)) { - throw new Error(`Line ${lineNum}: Duplicate handle name "${alias}"`); - } - shadowedAliases.set(alias, handleRes.get(alias)); - - // Build source ref — iterator-aware (handles pipe:iter and plain iter refs) - const headNode = sub(sourceNode, "head")!; - const pipeSegs = subs(sourceNode, "pipeSegment"); - const { root: srcRoot, segments: srcSegs } = extractAddressPath(headNode); - - let sourceRef: NodeRef; - const directIterRef = resolveIterRef(srcRoot, srcSegs, iterScope); - if (directIterRef && pipeSegs.length === 0) { - sourceRef = directIterRef; - } else if (pipeSegs.length > 0) { - // Pipe expression — the last segment may be iterator-relative. - // Resolve data source (last part), then build pipe fork chain. - const allParts = [headNode, ...pipeSegs]; - const actualSourceNode = allParts[allParts.length - 1]; - const pipeChainNodes = allParts.slice(0, -1); - - const { root: dataSrcRoot, segments: dataSrcSegs } = - extractAddressPath(actualSourceNode); - - let prevOutRef: NodeRef; - const iterRef = resolveIterRef(dataSrcRoot, dataSrcSegs, iterScope); - if (iterRef) { - prevOutRef = iterRef; - } else { - prevOutRef = resolveAddress(dataSrcRoot, dataSrcSegs, lineNum); - } - - // Build pipe fork chain (same logic as buildSourceExpr) - const reversed = [...pipeChainNodes].reverse(); - for (let idx = 0; idx < reversed.length; idx++) { - const pNode = reversed[idx]; - const { root: handleName, segments: handleSegs } = - extractAddressPath(pNode); - if (!handleRes.has(handleName)) { - throw new Error( - `Line ${lineNum}: Undeclared handle in pipe: "${handleName}". Add 'with as ${handleName}' to the bridge header.`, - ); - } - const fieldName = handleSegs.length > 0 ? handleSegs.join(".") : "in"; - const res = handleRes.get(handleName)!; - const forkInstance = 100000 + nextForkSeq++; - const forkKey = `${res.module}:${res.type}:${res.field}:${forkInstance}`; - pipeHandleEntries.push({ - key: forkKey, - handle: handleName, - baseTrunk: { - module: res.module, - type: res.type, - field: res.field, - instance: res.instance, - }, - }); - const forkInRef: NodeRef = { - module: res.module, - type: res.type, - field: res.field, - instance: forkInstance, - path: parsePath(fieldName), - }; - const forkRootRef: NodeRef = { - module: res.module, - type: res.type, - field: res.field, - instance: forkInstance, - path: [], - }; - wires.push({ - to: forkInRef, - sources: [{ expr: { type: "ref", ref: prevOutRef } }], - pipe: true, - }); - prevOutRef = forkRootRef; - } - sourceRef = prevOutRef; - } else { - sourceRef = buildSourceExpr(sourceNode, lineNum, iterScope); - } - - // Create __local trunk for the alias - const localRes: HandleResolution = { - module: "__local", - type: "Shadow", - field: alias, - }; - handleRes.set(alias, localRes); - - // Emit wire from source to local trunk - const localToRef: NodeRef = { - module: "__local", - type: "Shadow", - field: alias, - path: [], - }; - - // Process coalesce and catch from elementWithDecl - const elemFallbacks: WireSourceEntry[] = []; - for (const item of subs(withDecl, "elemCoalesceItem")) { - const type = tok(item, "falsyOp") - ? ("falsy" as const) - : ("nullish" as const); - const altNode = sub(item, "altValue")!; - const altResult = extractCoalesceAlt(altNode, lineNum, iterScope); - elemFallbacks.push(buildSourceEntry(type, altNode, altResult)); - } - let elemCatch: WireCatch | undefined; - const elemCatchAlt = sub(withDecl, "elemCatchAlt"); - if (elemCatchAlt) { - const altResult = extractCoalesceAlt(elemCatchAlt, lineNum, iterScope); - elemCatch = buildCatchHandler(elemCatchAlt, altResult); - } - - const wire: Wire = { - to: localToRef, - sources: [{ expr: { type: "ref", ref: sourceRef } }, ...elemFallbacks], - }; - if (elemCatch) wire.catch = elemCatch; - wires.push(wire); - } - return () => { - for (const [alias, previous] of shadowedAliases) { - if (previous) handleRes.set(alias, previous); - else handleRes.delete(alias); - } - }; - } - - function processLocalToolBindings(withDecls: CstNode[]): { - writableHandles: Set; - cleanup: () => void; - } { - const shadowedHandles = new Map(); - const writableHandles = new Set(); - - for (const withDecl of withDecls) { - const wc = withDecl.children; - const lineNum = line(findFirstToken(withDecl)); - const name = extractDottedName((wc.refName as CstNode[])[0]); - const versionTag = ( - wc.refVersion as IToken[] | undefined - )?.[0]?.image.slice(1); - const lastDot = name.lastIndexOf("."); - const defaultHandle = lastDot !== -1 ? name.substring(lastDot + 1) : name; - const handle = wc.refAlias - ? extractNameToken((wc.refAlias as CstNode[])[0]) - : defaultHandle; - const memoize = !!wc.memoizeKw; - - if (shadowedHandles.has(handle)) { - throw new Error(`Line ${lineNum}: Duplicate handle name "${handle}"`); - } - if (wc.refAlias) assertNotReserved(handle, lineNum, "handle alias"); - - shadowedHandles.set(handle, handleRes.get(handle)); - writableHandles.add(handle); - - const defineDef = previousInstructions.find( - (inst): inst is DefineDef => - inst.kind === "define" && inst.name === name, - ); - - if (defineDef) { - if (memoize) { - throw new Error( - `Line ${lineNum}: memoize is only valid for tool references`, - ); - } - handleBindings.push({ handle, kind: "define", name }); - handleRes.set(handle, { - module: `__define_${handle}`, - type: bridgeType, - field: bridgeField, - }); - continue; - } - - if (lastDot !== -1) { - const modulePart = name.substring(0, lastDot); - const fieldPart = name.substring(lastDot + 1); - const key = `${modulePart}:${fieldPart}`; - const instance = (instanceCounters.get(key) ?? 0) + 1; - instanceCounters.set(key, instance); - handleBindings.push({ - handle, - kind: "tool", - name, - element: true as const, - ...(memoize ? { memoize: true as const } : {}), - ...(versionTag ? { version: versionTag } : {}), - }); - handleRes.set(handle, { - module: modulePart, - type: bridgeType, - field: fieldPart, - instance, - }); - } else { - const key = `Tools:${name}`; - const instance = (instanceCounters.get(key) ?? 0) + 1; - instanceCounters.set(key, instance); - handleBindings.push({ - handle, - kind: "tool", - name, - element: true as const, - ...(memoize ? { memoize: true as const } : {}), - ...(versionTag ? { version: versionTag } : {}), - }); - handleRes.set(handle, { - module: SELF_MODULE, - type: "Tools", - field: name, - instance, - }); - } - } - - return { - writableHandles, - cleanup: () => { - for (const [handle, previous] of shadowedHandles) { - if (previous) handleRes.set(handle, previous); - else handleRes.delete(handle); - } - }, - }; - } - - function processElementHandleWires( - wireNodes: CstNode[], - iterScope: string | string[], - writableHandles: Set, - ): void { - const iterNames = Array.isArray(iterScope) ? iterScope : [iterScope]; - - for (const wireNode of wireNodes) { - const wc = wireNode.children; - const lineNum = line(findFirstToken(wireNode)); - const wireLoc = locFromNode(wireNode); - const { root: targetRoot, segments: targetSegs } = extractAddressPath( - sub(wireNode, "target")!, - ); - - if (!writableHandles.has(targetRoot)) { - throw new Error( - `Line ${lineNum}: Cannot wire inputs for handle "${targetRoot}" from this loop scope. Add 'with as ${targetRoot}' in the current array block.`, - ); - } - - const toRef = resolveAddress(targetRoot, targetSegs, lineNum); - assertNoTargetIndices(toRef, lineNum); - - if (wc.equalsOp) { - const value = extractBareValue(sub(wireNode, "constValue")!); - wires.push( - withLoc( - { to: toRef, sources: [{ expr: { type: "literal", value } }] }, - wireLoc, - ), - ); - continue; - } - - const stringSourceToken = (wc.stringSource as IToken[] | undefined)?.[0]; - if (stringSourceToken) { - const raw = stringSourceToken.image.slice(1, -1); - const segs = parseTemplateString(raw); - - const fallbacks: WireSourceEntry[] = []; - const fallbackInternalWires: Wire[] = []; - for (const item of subs(wireNode, "coalesceItem")) { - const type = tok(item, "falsyOp") - ? ("falsy" as const) - : ("nullish" as const); - const altNode = sub(item, "altValue")!; - const preLen = wires.length; - const altResult = extractCoalesceAlt(altNode, lineNum, iterNames); - fallbacks.push(buildSourceEntry(type, altNode, altResult)); - if ("sourceRef" in altResult) { - fallbackInternalWires.push(...wires.splice(preLen)); - } - } - let catchHandler: WireCatch | undefined; - let catchInternalWires: Wire[] = []; - const catchAlt = sub(wireNode, "catchAlt"); - if (catchAlt) { - const preLen = wires.length; - const altResult = extractCoalesceAlt(catchAlt, lineNum, iterNames); - catchHandler = buildCatchHandler(catchAlt, altResult); - if ("sourceRef" in altResult) { - catchInternalWires = wires.splice(preLen); - } - } - - if (segs) { - const concatOutRef = desugarTemplateString( - segs, - lineNum, - iterNames, - wireLoc, - ); - wires.push( - withLoc( - { - to: toRef, - sources: [ - { expr: { type: "ref", ref: concatOutRef } }, - ...fallbacks, - ], - ...(catchHandler ? { catch: catchHandler } : {}), - pipe: true, - }, - wireLoc, - ), - ); - } else { - wires.push( - withLoc( - { - to: toRef, - sources: [ - { expr: { type: "literal", value: raw } }, - ...fallbacks, - ], - ...(catchHandler ? { catch: catchHandler } : {}), - }, - wireLoc, - ), - ); - } - wires.push(...fallbackInternalWires); - wires.push(...catchInternalWires); - continue; - } - - const firstSourceNode = sub(wireNode, "firstSource"); - const firstParenNode = sub(wireNode, "firstParenExpr"); - const headNode = firstSourceNode - ? sub(firstSourceNode, "head") - : undefined; - const isSafe = headNode ? !!extractAddressPath(headNode).rootSafe : false; - const exprOps = subs(wireNode, "exprOp"); - const exprRights = subs(wireNode, "exprRight"); - const condLoc = locFromNodeRange( - firstParenNode ?? firstSourceNode, - exprRights[exprRights.length - 1] ?? firstParenNode ?? firstSourceNode, - ); - - let condRef: NodeRef; - let condIsPipeFork: boolean; - if (firstParenNode) { - const parenRef = resolveParenExpr( - firstParenNode, - lineNum, - iterNames, - isSafe, - wireLoc, - ); - if (exprOps.length > 0) { - condRef = desugarExprChain( - parenRef, - exprOps, - exprRights, - lineNum, - iterNames, - isSafe, - wireLoc, - ); - } else { - condRef = parenRef; - } - condIsPipeFork = true; - } else if (exprOps.length > 0) { - const leftRef = buildSourceExpr(firstSourceNode!, lineNum, iterNames); - condRef = desugarExprChain( - leftRef, - exprOps, - exprRights, - lineNum, - iterNames, - isSafe, - wireLoc, - ); - condIsPipeFork = true; - } else { - const pipeSegs = subs(firstSourceNode!, "pipeSegment"); - condRef = buildSourceExpr(firstSourceNode!, lineNum, iterNames); - condIsPipeFork = - condRef.instance != null && - condRef.path.length === 0 && - pipeSegs.length > 0; - } - - if (wc.notPrefix) { - condRef = desugarNot(condRef, lineNum, isSafe, wireLoc); - condIsPipeFork = true; - } - - const ternaryOp = tok(wireNode, "ternaryOp"); - if (ternaryOp) { - const thenNode = sub(wireNode, "thenBranch")!; - const elseNode = sub(wireNode, "elseBranch")!; - const thenBranch = extractTernaryBranch(thenNode, lineNum, iterNames); - const elseBranch = extractTernaryBranch(elseNode, lineNum, iterNames); - - const fallbacks: WireSourceEntry[] = []; - const fallbackInternalWires: Wire[] = []; - for (const item of subs(wireNode, "coalesceItem")) { - const type = tok(item, "falsyOp") - ? ("falsy" as const) - : ("nullish" as const); - const altNode = sub(item, "altValue")!; - const preLen = wires.length; - const altResult = extractCoalesceAlt(altNode, lineNum, iterNames); - fallbacks.push(buildSourceEntry(type, altNode, altResult)); - if ("sourceRef" in altResult) { - fallbackInternalWires.push(...wires.splice(preLen)); - } - } - - let catchHandler: WireCatch | undefined; - let catchInternalWires: Wire[] = []; - const catchAlt = sub(wireNode, "catchAlt"); - if (catchAlt) { - const preLen = wires.length; - const altResult = extractCoalesceAlt(catchAlt, lineNum, iterNames); - catchHandler = buildCatchHandler(catchAlt, altResult); - if ("sourceRef" in altResult) { - catchInternalWires = wires.splice(preLen); - } - } - - wires.push( - withLoc( - { - to: toRef, - sources: [ - { - expr: { - type: "ternary", - cond: { type: "ref", ref: condRef, loc: condLoc }, - then: - thenBranch.kind === "ref" - ? { - type: "ref" as const, - ref: thenBranch.ref, - loc: thenBranch.loc, - } - : { - type: "literal" as const, - value: thenBranch.value, - loc: thenBranch.loc, - }, - else: - elseBranch.kind === "ref" - ? { - type: "ref" as const, - ref: elseBranch.ref, - loc: elseBranch.loc, - } - : { - type: "literal" as const, - value: elseBranch.value, - loc: elseBranch.loc, - }, - ...(condLoc ? { condLoc } : {}), - thenLoc: thenBranch.loc, - elseLoc: elseBranch.loc, - }, - }, - ...fallbacks, - ], - ...(catchHandler ? { catch: catchHandler } : {}), - }, - wireLoc, - ), - ); - wires.push(...fallbackInternalWires); - wires.push(...catchInternalWires); - continue; - } - - const fallbacks: WireSourceEntry[] = []; - const fallbackInternalWires: Wire[] = []; - let hasTruthyLiteralFallback = false; - for (const item of subs(wireNode, "coalesceItem")) { - const type = tok(item, "falsyOp") - ? ("falsy" as const) - : ("nullish" as const); - if (type === "falsy" && hasTruthyLiteralFallback) break; - const altNode = sub(item, "altValue")!; - const preLen = wires.length; - const altResult = extractCoalesceAlt(altNode, lineNum, iterNames); - fallbacks.push(buildSourceEntry(type, altNode, altResult)); - if ("literal" in altResult) { - if (type === "falsy") { - hasTruthyLiteralFallback = Boolean(JSON.parse(altResult.literal)); - } - } else if ("sourceRef" in altResult) { - fallbackInternalWires.push(...wires.splice(preLen)); - } - } - - let catchHandler: WireCatch | undefined; - let catchInternalWires: Wire[] = []; - const catchAlt = sub(wireNode, "catchAlt"); - if (catchAlt) { - const preLen = wires.length; - const altResult = extractCoalesceAlt(catchAlt, lineNum, iterNames); - catchHandler = buildCatchHandler(catchAlt, altResult); - if ("sourceRef" in altResult) { - catchInternalWires = wires.splice(preLen); - } - } - - wires.push( - withLoc( - { - to: toRef, - sources: [ - { - expr: { - type: "ref", - ref: condRef, - ...(condIsPipeFork ? {} : {}), - }, - }, - ...fallbacks, - ], - ...(condIsPipeFork ? { pipe: true as const } : {}), - ...(catchHandler ? { catch: catchHandler } : {}), - }, - wireLoc, - ), - ); - wires.push(...fallbackInternalWires); - wires.push(...catchInternalWires); - } - } - - // ── Helper: build source expression ──────────────────────────────────── - - function buildSourceExprSafe( - sourceNode: CstNode, - lineNum: number, - iterScope?: string | string[], - ): { ref: NodeRef; safe?: boolean } { - const sourceLoc = locFromNode(sourceNode); - const headNode = sub(sourceNode, "head")!; - const pipeNodes = subs(sourceNode, "pipeSegment"); - - if (pipeNodes.length === 0) { - const { root, segments, safe, rootSafe, segmentSafe } = - extractAddressPath(headNode); - let ref: NodeRef; - const iterRef = resolveIterRef(root, segments, iterScope); - if (iterRef) { - ref = iterRef; - } else { - ref = resolveAddress(root, segments, lineNum); - } - return { - ref: { - ...ref, - ...(rootSafe ? { rootSafe: true } : {}), - ...(segmentSafe ? { pathSafe: segmentSafe } : {}), - }, - safe, - }; - } - - // Pipe chain: all parts in order [head, ...pipeSegments] - // The LAST part is the actual data source; everything before is a pipe handle. - const allParts = [headNode, ...pipeNodes]; - const actualSourceNode = allParts[allParts.length - 1]; - const pipeChainNodes = allParts.slice(0, -1); - - // Validate all pipe handles - for (const pipeNode of pipeChainNodes) { - const { root } = extractAddressPath(pipeNode); - if (!handleRes.has(root)) { - throw new Error( - `Line ${lineNum}: Undeclared handle in pipe: "${root}". Add 'with as ${root}' to the bridge header.`, - ); - } - } - - const { - root: srcRoot, - segments: srcSegments, - safe, - rootSafe: srcRootSafe, - segmentSafe: srcSegmentSafe, - } = extractAddressPath(actualSourceNode); - let prevOutRef: NodeRef; - const iterRef = resolveIterRef(srcRoot, srcSegments, iterScope); - if (iterRef) { - prevOutRef = iterRef; - } else { - prevOutRef = resolveAddress(srcRoot, srcSegments, lineNum); - } - - // Process pipe handles right-to-left (innermost first) - const reversed = [...pipeChainNodes].reverse(); - for (let idx = 0; idx < reversed.length; idx++) { - const pNode = reversed[idx]; - const { root: handleName, segments: handleSegs } = - extractAddressPath(pNode); - const fieldName = handleSegs.length > 0 ? handleSegs.join(".") : "in"; - const res = handleRes.get(handleName)!; - const forkInstance = 100000 + nextForkSeq++; - const forkKey = `${res.module}:${res.type}:${res.field}:${forkInstance}`; - pipeHandleEntries.push({ - key: forkKey, - handle: handleName, - baseTrunk: { - module: res.module, - type: res.type, - field: res.field, - instance: res.instance, - }, - }); - const forkInRef: NodeRef = { - module: res.module, - type: res.type, - field: res.field, - instance: forkInstance, - path: parsePath(fieldName), - }; - const forkRootRef: NodeRef = { - module: res.module, - type: res.type, - field: res.field, - instance: forkInstance, - path: [], - }; - wires.push( - withLoc( - { - to: forkInRef, - sources: [{ expr: { type: "ref", ref: prevOutRef } }], - pipe: true, - }, - sourceLoc, - ), - ); - prevOutRef = forkRootRef; - } - return { - ref: { - ...prevOutRef, - ...(srcRootSafe ? { rootSafe: true } : {}), - ...(srcSegmentSafe ? { pathSafe: srcSegmentSafe } : {}), - }, - safe, - }; - } - - /** Backward-compat wrapper — returns just the NodeRef. */ - function buildSourceExpr( - sourceNode: CstNode, - lineNum: number, - iterScope?: string | string[], - ): NodeRef { - return buildSourceExprSafe(sourceNode, lineNum, iterScope).ref; - } - - // ── Helper: desugar template string into synthetic internal.concat fork ───── - - function desugarTemplateString( - segs: TemplateSeg[], - lineNum: number, - iterScope?: string | string[], - loc?: SourceLocation, - ): NodeRef { - const forkInstance = 100000 + nextForkSeq++; - const forkModule = SELF_MODULE; - const forkType = "Tools"; - const forkField = "concat"; - const forkKey = `${forkModule}:${forkType}:${forkField}:${forkInstance}`; - pipeHandleEntries.push({ - key: forkKey, - handle: `__concat_${forkInstance}`, - baseTrunk: { - module: forkModule, - type: forkType, - field: forkField, - }, - }); - - for (let idx = 0; idx < segs.length; idx++) { - const seg = segs[idx]; - const partRef: NodeRef = { - module: forkModule, - type: forkType, - field: forkField, - instance: forkInstance, - path: ["parts", String(idx)], - }; - if (seg.kind === "text") { - wires.push( - withLoc( - { - to: partRef, - sources: [{ expr: { type: "literal", value: seg.value } }], - }, - loc, - ), - ); - } else { - // Parse the ref path: e.g. "i.id" → root="i", segments=["id"] - const dotParts = seg.path.split("."); - const root = dotParts[0]; - const segments = dotParts.slice(1); - - // Check for iterator-relative refs - const fromRef = resolveIterRef(root, segments, iterScope); - if (fromRef) { - wires.push( - withLoc( - { - to: partRef, - sources: [{ expr: { type: "ref", ref: fromRef } }], - }, - loc, - ), - ); - } else { - wires.push( - withLoc( - { - to: partRef, - sources: [ - { - expr: { - type: "ref", - ref: resolveAddress(root, segments, lineNum), - }, - }, - ], - }, - loc, - ), - ); - } - } - } - - return { - module: forkModule, - type: forkType, - field: forkField, - instance: forkInstance, - path: ["value"], - }; - } - - // ── Helper: extract coalesce alternative ─────────────────────────────── - - function extractCoalesceAlt( - altNode: CstNode, - lineNum: number, - iterScope?: string | string[], - ): - | { literal: string } - | { sourceRef: NodeRef } - | { control: ControlFlowInstruction } { - const c = altNode.children; - // Control flow keywords - if (c.throwKw) { - const msg = (c.throwMsg as IToken[])[0].image; - return { control: { kind: "throw", message: JSON.parse(msg) } }; - } - if (c.panicKw) { - const msg = (c.panicMsg as IToken[])[0].image; - return { control: { kind: "panic", message: JSON.parse(msg) } }; - } - if (c.continueKw) { - const raw = (c.continueLevel as IToken[] | undefined)?.[0]?.image; - if (!raw) return { control: { kind: "continue" } }; - const levels = Number(raw); - if (!Number.isInteger(levels) || levels < 1) { - throw new Error( - `Line ${lineNum}: continue level must be a positive integer`, - ); - } - return { control: { kind: "continue", levels } }; - } - if (c.breakKw) { - const raw = (c.breakLevel as IToken[] | undefined)?.[0]?.image; - if (!raw) return { control: { kind: "break" } }; - const levels = Number(raw); - if (!Number.isInteger(levels) || levels < 1) { - throw new Error( - `Line ${lineNum}: break level must be a positive integer`, - ); - } - return { control: { kind: "break", levels } }; - } - if (c.stringLit) { - const raw = (c.stringLit as IToken[])[0].image; - const segs = parseTemplateString(raw.slice(1, -1)); - if (segs) - return { - sourceRef: desugarTemplateString( - segs, - lineNum, - iterScope, - locFromNode(altNode), - ), - }; - return { literal: raw }; - } - if (c.numberLit) return { literal: (c.numberLit as IToken[])[0].image }; - if (c.intLit) return { literal: (c.intLit as IToken[])[0].image }; - if (c.trueLit) return { literal: "true" }; - if (c.falseLit) return { literal: "false" }; - if (c.nullLit) return { literal: "null" }; - if (c.objectLit) - return { literal: reconstructJson((c.objectLit as CstNode[])[0]) }; - if (c.sourceAlt) { - const srcNode = (c.sourceAlt as CstNode[])[0]; - return { sourceRef: buildSourceExpr(srcNode, lineNum, iterScope) }; - } - throw new Error(`Line ${lineNum}: Invalid coalesce alternative`); - } - - // ── Helper: extract ternary branch ──────────────────────────────────── - - /** - * Resolve a ternaryBranch CST node to either a NodeRef (source) or a - * raw literal string suitable for JSON.parse (kept verbatim for numbers - * / booleans / null; kept with quotes for strings so JSON.parse works). - */ - function extractTernaryBranch( - branchNode: CstNode, - lineNum: number, - iterScope?: string | string[], - ): - | { kind: "literal"; value: string; loc?: SourceLocation } - | { kind: "ref"; ref: NodeRef; loc?: SourceLocation } { - const c = branchNode.children; - const branchLoc = locFromNode(branchNode); - if (c.stringLit) { - const raw = (c.stringLit as IToken[])[0].image; - const segs = parseTemplateString(raw.slice(1, -1)); - if (segs) - return { - kind: "ref", - loc: branchLoc, - ref: desugarTemplateString(segs, lineNum, iterScope, branchLoc), - }; - return { kind: "literal", value: raw, loc: branchLoc }; - } - if (c.numberLit) - return { - kind: "literal", - value: (c.numberLit as IToken[])[0].image, - loc: branchLoc, - }; - if (c.trueLit) return { kind: "literal", value: "true", loc: branchLoc }; - if (c.falseLit) return { kind: "literal", value: "false", loc: branchLoc }; - if (c.nullLit) return { kind: "literal", value: "null", loc: branchLoc }; - if (c.sourceRef) { - const addrNode = (c.sourceRef as CstNode[])[0]; - const { root, segments, rootSafe, segmentSafe } = - extractAddressPath(addrNode); - const iterRef = resolveIterRef(root, segments, iterScope); - if (iterRef) { - return { - kind: "ref", - loc: branchLoc, - ref: { - ...iterRef, - ...(rootSafe ? { rootSafe: true } : {}), - ...(segmentSafe ? { pathSafe: segmentSafe } : {}), - }, - }; - } - const ref = resolveAddress(root, segments, lineNum); - return { - kind: "ref", - loc: branchLoc, - ref: { - ...ref, - ...(rootSafe ? { rootSafe: true } : {}), - ...(segmentSafe ? { pathSafe: segmentSafe } : {}), - }, - }; - } - throw new Error(`Line ${lineNum}: Invalid ternary branch`); - } - - // ── Helper: operator symbol → std tool function name ────────────────── - - /** Map infix operator token to the std tool that implements it. */ - const OP_TO_FN: Record = { - "*": "multiply", - "/": "divide", - "+": "add", - "-": "subtract", - "==": "eq", - "!=": "neq", - ">": "gt", - ">=": "gte", - "<": "lt", - "<=": "lte", - // and/or are handled as native condAnd/condOr wires, not tool forks - }; - - /** Operator precedence: higher number = binds tighter. */ - const OP_PREC: Record = { - "*": 4, - "/": 4, - "+": 3, - "-": 3, - "==": 2, - "!=": 2, - ">": 2, - ">=": 2, - "<": 2, - "<=": 2, - and: 1, - or: 0, - }; - - function extractExprOpStr(opNode: CstNode): string { - const c = opNode.children; - if (c.star) return "*"; - if (c.slash) return "/"; - if (c.plus) return "+"; - if (c.minus) return "-"; - if (c.doubleEquals) return "=="; - if (c.notEquals) return "!="; - if (c.greaterEqual) return ">="; - if (c.lessEqual) return "<="; - if (c.greaterThan) return ">"; - if (c.lessThan) return "<"; - if (c.andKw) return "and"; - if (c.orKw) return "or"; - throw new Error("Invalid expression operator"); - } - - /** - * Resolve an exprOperand CST node to either a NodeRef (source) or - * a literal string value suitable for a constant wire. - */ - function resolveExprOperand( - operandNode: CstNode, - lineNum: number, - iterScope?: string | string[], - ): - | { kind: "ref"; ref: NodeRef; safe?: boolean } - | { kind: "literal"; value: string } { - const c = operandNode.children; - if (c.numberLit) - return { kind: "literal", value: (c.numberLit as IToken[])[0].image }; - if (c.stringLit) { - const raw = (c.stringLit as IToken[])[0].image; - const content = raw.slice(1, -1); - const segs = parseTemplateString(content); - if (segs) - return { - kind: "ref", - ref: desugarTemplateString( - segs, - lineNum, - iterScope, - locFromNode(operandNode), - ), - }; - return { kind: "literal", value: content }; - } - if (c.trueLit) return { kind: "literal", value: "1" }; - if (c.falseLit) return { kind: "literal", value: "0" }; - if (c.nullLit) return { kind: "literal", value: "0" }; - if (c.sourceRef) { - const srcNode = (c.sourceRef as CstNode[])[0]; - - // Check for element/iterator-relative refs - const headNode = sub(srcNode, "head")!; - const pipeSegs = subs(srcNode, "pipeSegment"); - const { root, segments, safe: sourceSafe } = extractAddressPath(headNode); - const iterRef = - pipeSegs.length === 0 - ? resolveIterRef(root, segments, iterScope) - : undefined; - if (iterRef) { - return { - kind: "ref", - safe: sourceSafe, - ref: iterRef, - }; - } - - const { ref, safe: builtSafe } = buildSourceExprSafe( - srcNode, - lineNum, - iterScope, - ); - return { kind: "ref", ref, safe: builtSafe }; - } - if (c.parenExpr) { - const parenNode = (c.parenExpr as CstNode[])[0]; - const ref = resolveParenExpr( - parenNode, - lineNum, - iterScope, - undefined, - locFromNode(operandNode), - ); - return { kind: "ref", ref }; - } - throw new Error(`Line ${lineNum}: Invalid expression operand`); - } - - /** - * Resolve a parenthesized sub-expression `( [not] source [op operand]* )` - * into a single NodeRef by recursively desugaring the inner chain. - */ - function resolveParenExpr( - parenNode: CstNode, - lineNum: number, - iterScope?: string | string[], - safe?: boolean, - loc = locFromNode(parenNode), - ): NodeRef { - const pc = parenNode.children; - const innerSourceNode = sub(parenNode, "parenSource")!; - const innerOps = subs(parenNode, "parenExprOp"); - const innerRights = subs(parenNode, "parenExprRight"); - const hasNot = !!(pc.parenNotPrefix as IToken[] | undefined)?.length; - - // Build the inner source ref (handling iterator-relative refs) - let innerRef: NodeRef; - let innerSafe = safe; - const headNode = sub(innerSourceNode, "head")!; - const pipeSegs = subs(innerSourceNode, "pipeSegment"); - const { root, segments, safe: srcSafe } = extractAddressPath(headNode); - const iterRef = - pipeSegs.length === 0 - ? resolveIterRef(root, segments, iterScope) - : undefined; - if (iterRef) { - innerRef = iterRef; - if (srcSafe) innerSafe = true; - } else { - const result = buildSourceExprSafe(innerSourceNode, lineNum, iterScope); - innerRef = result.ref; - if (result.safe) innerSafe = true; - } - - // Desugar the inner expression chain if there are operators - let resultRef: NodeRef; - if (innerOps.length > 0) { - resultRef = desugarExprChain( - innerRef, - innerOps, - innerRights, - lineNum, - iterScope, - innerSafe, - loc, - ); - } else { - resultRef = innerRef; - } - - // Apply not prefix if present - if (hasNot) { - resultRef = desugarNot(resultRef, lineNum, innerSafe, loc); - } - - return resultRef; - } - - /** - * Desugar an infix expression chain into synthetic tool wires, - * respecting operator precedence (* / before + - before comparisons). - * - * Given: leftRef + rightA * rightB > 5 - * Produces: leftRef + (rightA * rightB) > 5 - * - * Each binary node creates a synthetic tool fork (like pipe desugaring): - * __expr fork instance → { a: left, b: right } → result - */ - function desugarExprChain( - leftRef: NodeRef, - exprOps: CstNode[], - exprRights: CstNode[], - lineNum: number, - iterScope?: string | string[], - safe?: boolean, - loc?: SourceLocation, - ): NodeRef { - // Build flat operand/operator lists for the precedence parser. - // operands[0] = leftRef, operands[i+1] = resolved exprRights[i] - type Operand = - | { kind: "ref"; ref: NodeRef; safe?: boolean } - | { kind: "literal"; value: string }; - const operands: Operand[] = [{ kind: "ref", ref: leftRef, safe }]; - const ops: string[] = []; - - for (let i = 0; i < exprOps.length; i++) { - ops.push(extractExprOpStr(exprOps[i])); - operands.push(resolveExprOperand(exprRights[i], lineNum, iterScope)); - } - - // Emit a synthetic fork for a single binary operation and return - // an operand pointing to the fork's result. - function emitFork(left: Operand, opStr: string, right: Operand): Operand { - // Derive safe flag per operand - const leftSafe = left.kind === "ref" && !!left.safe; - const rightSafe = right.kind === "ref" && !!right.safe; - - // ── Short-circuit and/or: emit condAnd/condOr wire ── - if (opStr === "and" || opStr === "or") { - const forkInstance = 100000 + nextForkSeq++; - const forkField = opStr === "and" ? "__and" : "__or"; - const forkTrunkModule = SELF_MODULE; - const forkTrunkType = "Tools"; - const forkKey = `${forkTrunkModule}:${forkTrunkType}:${forkField}:${forkInstance}`; - pipeHandleEntries.push({ - key: forkKey, - handle: `__expr_${forkInstance}`, - baseTrunk: { - module: forkTrunkModule, - type: forkTrunkType, - field: forkField, - }, - }); - - const toRef: NodeRef = { - module: forkTrunkModule, - type: forkTrunkType, - field: forkField, - instance: forkInstance, - path: [], - }; - - // Build the leftRef for the condAnd/condOr - const leftRef = - left.kind === "ref" - ? left.ref - : (() => { - // Literal left: emit a constant wire and reference it - const litInstance = 100000 + nextForkSeq++; - const litField = "__lit"; - const litKey = `${forkTrunkModule}:${forkTrunkType}:${litField}:${litInstance}`; - pipeHandleEntries.push({ - key: litKey, - handle: `__expr_${litInstance}`, - baseTrunk: { - module: forkTrunkModule, - type: forkTrunkType, - field: litField, - }, - }); - const litRef: NodeRef = { - module: forkTrunkModule, - type: forkTrunkType, - field: litField, - instance: litInstance, - path: [], - }; - wires.push( - withLoc( - { - to: litRef, - sources: [ - { expr: { type: "literal", value: left.value } }, - ], - }, - loc, - ), - ); - return litRef; - })(); - - const safeAttr = leftSafe ? { safe: true as const } : {}; - const rightSafeAttr = rightSafe ? { safe: true as const } : {}; - - const leftExpr: Expression = { type: "ref", ref: leftRef, ...safeAttr }; - const rightExpr: Expression = - right.kind === "ref" - ? { type: "ref", ref: right.ref, ...rightSafeAttr } - : { type: "literal", value: right.value }; - - if (opStr === "and") { - wires.push( - withLoc( - { - to: toRef, - sources: [ - { - expr: { - type: "and", - left: leftExpr, - right: rightExpr, - ...(leftSafe ? { leftSafe: true as const } : {}), - ...(rightSafe ? { rightSafe: true as const } : {}), - }, - }, - ], - }, - loc, - ), - ); - } else { - wires.push( - withLoc( - { - to: toRef, - sources: [ - { - expr: { - type: "or", - left: leftExpr, - right: rightExpr, - ...(leftSafe ? { leftSafe: true as const } : {}), - ...(rightSafe ? { rightSafe: true as const } : {}), - }, - }, - ], - }, - loc, - ), - ); - } - - return { kind: "ref", ref: toRef }; - } - - // ── Standard math/comparison: emit synthetic tool fork ── - const fnName = OP_TO_FN[opStr]; - if (!fnName) - throw new Error(`Line ${lineNum}: Unknown operator "${opStr}"`); - - const forkInstance = 100000 + nextForkSeq++; - const forkTrunkModule = SELF_MODULE; - const forkTrunkType = "Tools"; - const forkTrunkField = fnName; - - const forkKey = `${forkTrunkModule}:${forkTrunkType}:${forkTrunkField}:${forkInstance}`; - pipeHandleEntries.push({ - key: forkKey, - handle: `__expr_${forkInstance}`, - baseTrunk: { - module: forkTrunkModule, - type: forkTrunkType, - field: forkTrunkField, - }, - }); - - const makeTarget = (slot: string): NodeRef => ({ - module: forkTrunkModule, - type: forkTrunkType, - field: forkTrunkField, - instance: forkInstance, - path: [slot], - }); - - // Wire left → fork.a (propagate safe flag from operand) - if (left.kind === "literal") { - wires.push( - withLoc( - { - to: makeTarget("a"), - sources: [{ expr: { type: "literal", value: left.value } }], - }, - loc, - ), - ); - } else { - const safeAttr = leftSafe ? { safe: true as const } : {}; - wires.push( - withLoc( - { - to: makeTarget("a"), - sources: [{ expr: { type: "ref", ref: left.ref, ...safeAttr } }], - pipe: true, - }, - loc, - ), - ); - } - - // Wire right → fork.b (propagate safe flag from operand) - if (right.kind === "literal") { - wires.push( - withLoc( - { - to: makeTarget("b"), - sources: [{ expr: { type: "literal", value: right.value } }], - }, - loc, - ), - ); - } else { - const safeAttr = rightSafe ? { safe: true as const } : {}; - wires.push( - withLoc( - { - to: makeTarget("b"), - sources: [{ expr: { type: "ref", ref: right.ref, ...safeAttr } }], - pipe: true, - }, - loc, - ), - ); - } - - return { - kind: "ref", - ref: { - module: forkTrunkModule, - type: forkTrunkType, - field: forkTrunkField, - instance: forkInstance, - path: [], - }, - }; - } - - // Reduce all operators at a given precedence level (left-to-right). - // Modifies operands/ops arrays in place, collapsing matched pairs. - function reduceLevel(prec: number): void { - let i = 0; - while (i < ops.length) { - if ((OP_PREC[ops[i]] ?? 0) === prec) { - const result = emitFork(operands[i], ops[i], operands[i + 1]); - operands.splice(i, 2, result); - ops.splice(i, 1); - } else { - i++; - } - } - } - - // Process in precedence order: * / first, then + -, then comparisons, then and, then or. - reduceLevel(4); // * / - reduceLevel(3); // + - - reduceLevel(2); // == != > >= < <= - reduceLevel(1); // and - reduceLevel(0); // or - - // After full reduction, operands[0] holds the final result. - const final = operands[0]; - if (final.kind !== "ref") { - throw new Error( - `Line ${lineNum}: Expression must contain at least one source reference`, - ); - } - return final.ref; - } - - /** - * Desugar a `not` prefix into a synthetic unary fork that calls `internal.not`. - * Wraps the given ref: not(sourceRef) → __expr fork with { a: sourceRef } - */ - function desugarNot( - sourceRef: NodeRef, - _lineNum: number, - safe?: boolean, - loc?: SourceLocation, - ): NodeRef { - const forkInstance = 100000 + nextForkSeq++; - const forkTrunkModule = SELF_MODULE; - const forkTrunkType = "Tools"; - const forkTrunkField = "not"; - - const forkKey = `${forkTrunkModule}:${forkTrunkType}:${forkTrunkField}:${forkInstance}`; - pipeHandleEntries.push({ - key: forkKey, - handle: `__expr_${forkInstance}`, - baseTrunk: { - module: forkTrunkModule, - type: forkTrunkType, - field: forkTrunkField, - }, - }); - - const safeAttr = safe ? { safe: true as const } : {}; - wires.push( - withLoc( - { - to: { - module: forkTrunkModule, - type: forkTrunkType, - field: forkTrunkField, - instance: forkInstance, - path: ["a"], - }, - sources: [{ expr: { type: "ref", ref: sourceRef, ...safeAttr } }], - pipe: true, - }, - loc, - ), - ); - - return { - module: forkTrunkModule, - type: forkTrunkType, - field: forkTrunkField, - instance: forkInstance, - path: [], - }; - } - - // ── Helper: recursively process path scoping block lines ─────────────── - // Flattens nested scope blocks into standard flat wires by prepending - // the accumulated path prefix to each inner target. - - function processScopeLines( - scopeLines: CstNode[], - targetRoot: string, - pathPrefix: string[], - ): void { - for (const scopeLine of scopeLines) { - const sc = scopeLine.children; - const scopeLineNum = line(findFirstToken(scopeLine)); - const scopeLineLoc = locFromNode(scopeLine); - const targetStr = extractDottedPathStr(sub(scopeLine, "scopeTarget")!); - const scopeSegs = parsePath(targetStr); - const fullSegs = [...pathPrefix, ...scopeSegs]; - - // ── Nested scope: .field { ... } ── - const nestedScopeLines = subs(scopeLine, "pathScopeLine"); - const nestedSpreadLines = subs(scopeLine, "scopeSpreadLine"); - if ( - (nestedScopeLines.length > 0 || nestedSpreadLines.length > 0) && - !sc.scopeEquals && - !sc.scopeArrow - ) { - // Process alias declarations inside the nested scope block first - const scopeAliases = subs(scopeLine, "scopeAlias"); - for (const aliasNode of scopeAliases) { - const aliasLineNum = line(findFirstToken(aliasNode)); - const sourceNode = sub(aliasNode, "nodeAliasSource")!; - const alias = extractNameToken(sub(aliasNode, "nodeAliasName")!); - assertNotReserved(alias, aliasLineNum, "node alias"); - if (handleRes.has(alias)) { - throw new Error( - `Line ${aliasLineNum}: Duplicate handle name "${alias}"`, - ); - } - const { ref: sourceRef, safe: aliasSafe } = buildSourceExprSafe( - sourceNode, - aliasLineNum, - ); - const localRes: HandleResolution = { - module: "__local", - type: "Shadow", - field: alias, - }; - handleRes.set(alias, localRes); - const localToRef: NodeRef = { - module: "__local", - type: "Shadow", - field: alias, - path: [], - }; - wires.push( - withLoc( - { - to: localToRef, - sources: [ - { - expr: { - type: "ref", - ref: sourceRef, - ...(aliasSafe ? { safe: true as const } : {}), - }, - }, - ], - }, - locFromNode(aliasNode), - ), - ); - } - // Process spread lines inside this nested scope block: ...sourceExpr - const nestedToRef = resolveAddress(targetRoot, fullSegs, scopeLineNum); - for (const spreadLine of nestedSpreadLines) { - const spreadLineNum = line(findFirstToken(spreadLine)); - const sourceNode = sub(spreadLine, "spreadSource")!; - const { ref: fromRef, safe: spreadSafe } = buildSourceExprSafe( - sourceNode, - spreadLineNum, - ); - wires.push( - withLoc( - { - to: nestedToRef, - sources: [ - { - expr: { - type: "ref", - ref: fromRef, - ...(spreadSafe ? { safe: true as const } : {}), - }, - }, - ], - spread: true as const, - }, - locFromNode(spreadLine), - ), - ); - } - processScopeLines(nestedScopeLines, targetRoot, fullSegs); - continue; - } - - const toRef = resolveAddress(targetRoot, fullSegs, scopeLineNum); - assertNoTargetIndices(toRef, scopeLineNum); - - // ── Constant wire: .field = value ── - if (sc.scopeEquals) { - const value = extractBareValue(sub(scopeLine, "scopeValue")!); - wires.push( - withLoc( - { to: toRef, sources: [{ expr: { type: "literal", value } }] }, - scopeLineLoc, - ), - ); - continue; - } - - // ── Pull wire: .field <- source [modifiers] ── - if (sc.scopeArrow) { - // String source (template or plain): .field <- "..." - const stringSourceToken = ( - sc.scopeStringSource as IToken[] | undefined - )?.[0]; - if (stringSourceToken) { - const raw = stringSourceToken.image.slice(1, -1); - const segs = parseTemplateString(raw); - - const fallbacks: WireSourceEntry[] = []; - const fallbackInternalWires: Wire[] = []; - for (const item of subs(scopeLine, "scopeCoalesceItem")) { - const type = tok(item, "falsyOp") - ? ("falsy" as const) - : ("nullish" as const); - const altNode = sub(item, "altValue")!; - const preLen = wires.length; - const altResult = extractCoalesceAlt(altNode, scopeLineNum); - fallbacks.push(buildSourceEntry(type, altNode, altResult)); - if ("sourceRef" in altResult) { - fallbackInternalWires.push(...wires.splice(preLen)); - } - } - let catchHandler: WireCatch | undefined; - let catchInternalWires: Wire[] = []; - const catchAlt = sub(scopeLine, "scopeCatchAlt"); - if (catchAlt) { - const preLen = wires.length; - const altResult = extractCoalesceAlt(catchAlt, scopeLineNum); - catchHandler = buildCatchHandler(catchAlt, altResult); - if ("sourceRef" in altResult) { - catchInternalWires = wires.splice(preLen); - } - } - - if (segs) { - const concatOutRef = desugarTemplateString( - segs, - scopeLineNum, - undefined, - scopeLineLoc, - ); - wires.push( - withLoc( - { - to: toRef, - sources: [ - { expr: { type: "ref", ref: concatOutRef } }, - ...fallbacks, - ], - ...(catchHandler ? { catch: catchHandler } : {}), - pipe: true, - }, - scopeLineLoc, - ), - ); - } else { - wires.push( - withLoc( - { - to: toRef, - sources: [ - { expr: { type: "literal", value: raw } }, - ...fallbacks, - ], - ...(catchHandler ? { catch: catchHandler } : {}), - }, - scopeLineLoc, - ), - ); - } - wires.push(...fallbackInternalWires); - wires.push(...catchInternalWires); - continue; - } - - // Normal source expression - const firstSourceNode = sub(scopeLine, "scopeSource"); - const scopeFirstParenNode = sub(scopeLine, "scopeFirstParenExpr"); - const sourceParts: { ref: NodeRef; isPipeFork: boolean }[] = []; - const exprOps = subs(scopeLine, "scopeExprOp"); - const exprRights = subs(scopeLine, "scopeExprRight"); - const condLoc = locFromNodeRange( - scopeFirstParenNode ?? firstSourceNode, - exprRights[exprRights.length - 1] ?? - scopeFirstParenNode ?? - firstSourceNode, - ); - - // Extract safe flag from head node - let scopeBlockSafe: boolean = false; - if (firstSourceNode) { - const headNode = sub(firstSourceNode, "head"); - if (headNode) { - scopeBlockSafe = !!extractAddressPath(headNode).safe; - } - } - - let condRef: NodeRef; - let condIsPipeFork: boolean; - if (scopeFirstParenNode) { - const parenRef = resolveParenExpr( - scopeFirstParenNode, - scopeLineNum, - undefined, - scopeBlockSafe || undefined, - ); - if (exprOps.length > 0) { - condRef = desugarExprChain( - parenRef, - exprOps, - exprRights, - scopeLineNum, - undefined, - scopeBlockSafe || undefined, - scopeLineLoc, - ); - } else { - condRef = parenRef; - } - condIsPipeFork = true; - } else if (exprOps.length > 0) { - const leftRef = buildSourceExpr(firstSourceNode!, scopeLineNum); - condRef = desugarExprChain( - leftRef, - exprOps, - exprRights, - scopeLineNum, - undefined, - scopeBlockSafe || undefined, - scopeLineLoc, - ); - condIsPipeFork = true; - } else { - const pipeSegs = subs(firstSourceNode!, "pipeSegment"); - condRef = buildSourceExpr(firstSourceNode!, scopeLineNum); - condIsPipeFork = - condRef.instance != null && - condRef.path.length === 0 && - pipeSegs.length > 0; - } - - // ── Apply `not` prefix if present (scope context) ── - if ((sc.scopeNotPrefix as IToken[] | undefined)?.[0]) { - condRef = desugarNot( - condRef, - scopeLineNum, - scopeBlockSafe || undefined, - scopeLineLoc, - ); - condIsPipeFork = true; - } - - // Ternary wire: .field <- cond ? then : else - const scopeTernaryOp = (sc.scopeTernaryOp as IToken[] | undefined)?.[0]; - if (scopeTernaryOp) { - const thenNode = sub(scopeLine, "scopeThenBranch")!; - const elseNode = sub(scopeLine, "scopeElseBranch")!; - const thenBranch = extractTernaryBranch(thenNode, scopeLineNum); - const elseBranch = extractTernaryBranch(elseNode, scopeLineNum); - const fallbacks: WireSourceEntry[] = []; - const fallbackInternalWires: Wire[] = []; - for (const item of subs(scopeLine, "scopeCoalesceItem")) { - const type = tok(item, "falsyOp") - ? ("falsy" as const) - : ("nullish" as const); - const altNode = sub(item, "altValue")!; - const preLen = wires.length; - const altResult = extractCoalesceAlt(altNode, scopeLineNum); - fallbacks.push(buildSourceEntry(type, altNode, altResult)); - if ("sourceRef" in altResult) { - fallbackInternalWires.push(...wires.splice(preLen)); - } - } - let catchHandler: WireCatch | undefined; - let catchInternalWires: Wire[] = []; - const catchAlt = sub(scopeLine, "scopeCatchAlt"); - if (catchAlt) { - const preLen = wires.length; - const altResult = extractCoalesceAlt(catchAlt, scopeLineNum); - catchHandler = buildCatchHandler(catchAlt, altResult); - if ("sourceRef" in altResult) { - catchInternalWires = wires.splice(preLen); - } - } - wires.push( - withLoc( - { - to: toRef, - sources: [ - { - expr: { - type: "ternary", - cond: { - type: "ref", - ref: condRef, - ...(condLoc ? { refLoc: condLoc } : {}), - }, - then: - thenBranch.kind === "ref" - ? { - type: "ref" as const, - ref: thenBranch.ref, - loc: thenBranch.loc, - } - : { - type: "literal" as const, - value: thenBranch.value, - loc: thenBranch.loc, - }, - else: - elseBranch.kind === "ref" - ? { - type: "ref" as const, - ref: elseBranch.ref, - loc: elseBranch.loc, - } - : { - type: "literal" as const, - value: elseBranch.value, - loc: elseBranch.loc, - }, - ...(condLoc ? { condLoc } : {}), - thenLoc: thenBranch.loc, - elseLoc: elseBranch.loc, - }, - }, - ...fallbacks, - ], - ...(catchHandler ? { catch: catchHandler } : {}), - }, - scopeLineLoc, - ), - ); - wires.push(...fallbackInternalWires); - wires.push(...catchInternalWires); - continue; - } - - sourceParts.push({ ref: condRef, isPipeFork: condIsPipeFork }); - - // Coalesce alternatives (|| and ??) - const fallbacks: WireSourceEntry[] = []; - const fallbackInternalWires: Wire[] = []; - for (const item of subs(scopeLine, "scopeCoalesceItem")) { - const type = tok(item, "falsyOp") - ? ("falsy" as const) - : ("nullish" as const); - const altNode = sub(item, "altValue")!; - const preLen = wires.length; - const altResult = extractCoalesceAlt(altNode, scopeLineNum); - fallbacks.push(buildSourceEntry(type, altNode, altResult)); - if ("sourceRef" in altResult) { - fallbackInternalWires.push(...wires.splice(preLen)); - } - } - - let catchHandler: WireCatch | undefined; - let catchInternalWires: Wire[] = []; - const catchAlt = sub(scopeLine, "scopeCatchAlt"); - if (catchAlt) { - const preLen = wires.length; - const altResult = extractCoalesceAlt(catchAlt, scopeLineNum); - catchHandler = buildCatchHandler(catchAlt, altResult); - if ("sourceRef" in altResult) { - catchInternalWires = wires.splice(preLen); - } - } - - const { ref: fromRef, isPipeFork: isPipe } = sourceParts[0]; - wires.push( - withLoc( - { - to: toRef, - sources: [ - { - expr: { - type: "ref", - ref: fromRef, - ...(condLoc ? { refLoc: condLoc } : {}), - }, - }, - ...fallbacks, - ], - ...(catchHandler ? { catch: catchHandler } : {}), - ...(isPipe ? { pipe: true as const } : {}), - }, - scopeLineLoc, - ), - ); - wires.push(...fallbackInternalWires); - wires.push(...catchInternalWires); - } - } - } - - // ── Step 1.5: Process top-level node alias declarations ──────────────── - // `with as ` at bridge body level (pipe-based). - // Also detect simple renames via bridgeWithDecl when the root is already - // a declared handle (e.g. `with api.some.complex.field as alias`). - - for (const bodyLine of bodyLines) { - const c = bodyLine.children; - - // Handle pipe-based node aliases: with uc:i.category as upper - const nodeAliasNode = (c.bridgeNodeAlias as CstNode[] | undefined)?.[0]; - if (nodeAliasNode) { - const lineNum = line(findFirstToken(nodeAliasNode)); - const aliasLoc = locFromNode(nodeAliasNode); - const alias = extractNameToken(sub(nodeAliasNode, "nodeAliasName")!); - assertNotReserved(alias, lineNum, "node alias"); - if (handleRes.has(alias)) { - throw new Error(`Line ${lineNum}: Duplicate handle name "${alias}"`); - } - - // ── Extract coalesce modifiers FIRST (shared by ternary + pull paths) ── - const aliasFallbacks: WireSourceEntry[] = []; - const aliasFallbackInternalWires: Wire[] = []; - for (const item of subs(nodeAliasNode, "aliasCoalesceItem")) { - const type = tok(item, "falsyOp") - ? ("falsy" as const) - : ("nullish" as const); - const altNode = sub(item, "altValue")!; - const preLen = wires.length; - const altResult = extractCoalesceAlt(altNode, lineNum); - aliasFallbacks.push(buildSourceEntry(type, altNode, altResult)); - if ("sourceRef" in altResult) { - aliasFallbackInternalWires.push(...wires.splice(preLen)); - } - } - let aliasCatchHandler: WireCatch | undefined; - let aliasCatchInternalWires: Wire[] = []; - const aliasCatchAlt = sub(nodeAliasNode, "aliasCatchAlt"); - if (aliasCatchAlt) { - const preLen = wires.length; - const altResult = extractCoalesceAlt(aliasCatchAlt, lineNum); - aliasCatchHandler = buildCatchHandler(aliasCatchAlt, altResult); - if ("sourceRef" in altResult) { - aliasCatchInternalWires = wires.splice(preLen); - } - } - - // ── Compute the source ref ── - let sourceRef: NodeRef; - let sourceLoc: SourceLocation | undefined; - let aliasSafe: boolean | undefined; - - const aliasStringToken = ( - nodeAliasNode.children.aliasStringSource as IToken[] | undefined - )?.[0]; - if (aliasStringToken) { - // String literal source: alias name <- "template..." [op right]* - const raw = aliasStringToken.image.slice(1, -1); - const segs = parseTemplateString(raw); - const stringExprOps = subs(nodeAliasNode, "aliasStringExprOp"); - // Produce a NodeRef for the string value (concat fork or template desugar) - const strRef: NodeRef = segs - ? desugarTemplateString(segs, lineNum, undefined, aliasLoc) - : desugarTemplateString( - [{ kind: "text", value: raw }], - lineNum, - undefined, - aliasLoc, - ); - if (stringExprOps.length > 0) { - const stringExprRights = subs(nodeAliasNode, "aliasStringExprRight"); - sourceRef = desugarExprChain( - strRef, - stringExprOps, - stringExprRights, - lineNum, - undefined, - undefined, - aliasLoc, - ); - } else { - sourceRef = strRef; - } - sourceLoc = aliasLoc; - // Ternary after string source (e.g. alias name <- "a" == "b" ? x : y) - const strTernaryOp = ( - nodeAliasNode.children.aliasStringTernaryOp as IToken[] | undefined - )?.[0]; - if (strTernaryOp) { - const thenNode = sub(nodeAliasNode, "aliasStringThenBranch")!; - const elseNode = sub(nodeAliasNode, "aliasStringElseBranch")!; - const thenBranch = extractTernaryBranch(thenNode, lineNum); - const elseBranch = extractTernaryBranch(elseNode, lineNum); - const ternaryToRef: NodeRef = { - module: "__local", - type: "Shadow", - field: alias, - path: [], - }; - handleRes.set(alias, { - module: "__local", - type: "Shadow", - field: alias, - }); - wires.push( - withLoc( - { - to: ternaryToRef, - sources: [ - { - expr: { - type: "ternary", - cond: { type: "ref", ref: sourceRef, loc: sourceLoc }, - then: - thenBranch.kind === "ref" - ? { - type: "ref" as const, - ref: thenBranch.ref, - loc: thenBranch.loc, - } - : { - type: "literal" as const, - value: thenBranch.value, - loc: thenBranch.loc, - }, - else: - elseBranch.kind === "ref" - ? { - type: "ref" as const, - ref: elseBranch.ref, - loc: elseBranch.loc, - } - : { - type: "literal" as const, - value: elseBranch.value, - loc: elseBranch.loc, - }, - ...(sourceLoc ? { condLoc: sourceLoc } : {}), - thenLoc: thenBranch.loc, - elseLoc: elseBranch.loc, - }, - }, - ...aliasFallbacks, - ], - ...(aliasCatchHandler ? { catch: aliasCatchHandler } : {}), - }, - aliasLoc, - ), - ); - wires.push(...aliasFallbackInternalWires); - wires.push(...aliasCatchInternalWires); - continue; - } - aliasSafe = false; - } else { - // Normal expression source - const firstParenNode = sub(nodeAliasNode, "aliasFirstParen"); - const firstSourceNode = sub(nodeAliasNode, "nodeAliasSource"); - const headNode = firstSourceNode - ? sub(firstSourceNode, "head") - : undefined; - const isSafe = headNode - ? !!extractAddressPath(headNode).rootSafe - : false; - const exprOps = subs(nodeAliasNode, "aliasExprOp"); - const exprRights = subs(nodeAliasNode, "aliasExprRight"); - sourceLoc = locFromNodeRange( - firstParenNode ?? firstSourceNode, - exprRights[exprRights.length - 1] ?? - firstParenNode ?? - firstSourceNode, - ); - - let condRef: NodeRef; - if (firstParenNode) { - const parenRef = resolveParenExpr( - firstParenNode, - lineNum, - undefined, - isSafe, - ); - if (exprOps.length > 0) { - condRef = desugarExprChain( - parenRef, - exprOps, - exprRights, - lineNum, - undefined, - isSafe, - aliasLoc, - ); - } else { - condRef = parenRef; - } - } else if (exprOps.length > 0) { - const leftRef = buildSourceExpr(firstSourceNode!, lineNum); - condRef = desugarExprChain( - leftRef, - exprOps, - exprRights, - lineNum, - undefined, - isSafe, - aliasLoc, - ); - } else { - const result = buildSourceExprSafe(firstSourceNode!, lineNum); - condRef = result.ref; - aliasSafe = result.safe; - } - - // Apply `not` prefix if present - if ( - (nodeAliasNode.children.aliasNotPrefix as IToken[] | undefined)?.[0] - ) { - condRef = desugarNot(condRef, lineNum, isSafe, aliasLoc); - } - - // Ternary - const ternaryOp = ( - nodeAliasNode.children.aliasTernaryOp as IToken[] | undefined - )?.[0]; - if (ternaryOp) { - const thenNode = sub(nodeAliasNode, "aliasThenBranch")!; - const elseNode = sub(nodeAliasNode, "aliasElseBranch")!; - const thenBranch = extractTernaryBranch(thenNode, lineNum); - const elseBranch = extractTernaryBranch(elseNode, lineNum); - const ternaryToRef: NodeRef = { - module: "__local", - type: "Shadow", - field: alias, - path: [], - }; - handleRes.set(alias, { - module: "__local", - type: "Shadow", - field: alias, - }); - wires.push( - withLoc( - { - to: ternaryToRef, - sources: [ - { - expr: { - type: "ternary", - cond: { - type: "ref", - ref: condRef, - ...(sourceLoc ? { refLoc: sourceLoc } : {}), - }, - then: - thenBranch.kind === "ref" - ? { - type: "ref" as const, - ref: thenBranch.ref, - loc: thenBranch.loc, - } - : { - type: "literal" as const, - value: thenBranch.value, - loc: thenBranch.loc, - }, - else: - elseBranch.kind === "ref" - ? { - type: "ref" as const, - ref: elseBranch.ref, - loc: elseBranch.loc, - } - : { - type: "literal" as const, - value: elseBranch.value, - loc: elseBranch.loc, - }, - ...(sourceLoc ? { condLoc: sourceLoc } : {}), - thenLoc: thenBranch.loc, - elseLoc: elseBranch.loc, - }, - }, - ...aliasFallbacks, - ], - ...(aliasCatchHandler ? { catch: aliasCatchHandler } : {}), - }, - aliasLoc, - ), - ); - wires.push(...aliasFallbackInternalWires); - wires.push(...aliasCatchInternalWires); - continue; - } - - sourceRef = condRef; - if (aliasSafe === undefined) aliasSafe = isSafe || undefined; - } - - // Create __local trunk for the alias - const localRes: HandleResolution = { - module: "__local", - type: "Shadow", - field: alias, - }; - handleRes.set(alias, localRes); - - // Emit wire from source to local trunk - const localToRef: NodeRef = { - module: "__local", - type: "Shadow", - field: alias, - path: [], - }; - - // ── Array mapping on alias: alias name <- source[] as it { ... } ── - const aliasArrayMapping = ( - nodeAliasNode.children.arrayMapping as CstNode[] | undefined - )?.[0]; - if (aliasArrayMapping) { - wires.push( - withLoc( - { - to: localToRef, - sources: [ - { - expr: { - type: "ref", - ref: sourceRef, - ...(aliasSafe ? { safe: true } : {}), - ...(sourceLoc ? { refLoc: sourceLoc } : {}), - }, - }, - ...aliasFallbacks, - ], - ...(aliasCatchHandler ? { catch: aliasCatchHandler } : {}), - }, - aliasLoc, - ), - ); - wires.push(...aliasFallbackInternalWires); - wires.push(...aliasCatchInternalWires); - - const iterName = extractNameToken(sub(aliasArrayMapping, "iterName")!); - assertNotReserved(iterName, lineNum, "iterator handle"); - const arrayToPath = localToRef.path; - arrayIterators[arrayToPath.join(".")] = iterName; - - const elemWithDecls = subs(aliasArrayMapping, "elementWithDecl"); - const elemToolWithDecls = subs( - aliasArrayMapping, - "elementToolWithDecl", - ); - const { writableHandles, cleanup: toolCleanup } = - processLocalToolBindings(elemToolWithDecls); - const aliasElemCleanup = processLocalBindings(elemWithDecls, iterName); - processElementHandleWires( - subs(aliasArrayMapping, "elementHandleWire"), - iterName, - writableHandles, - ); - processElementLines( - subs(aliasArrayMapping, "elementLine"), - arrayToPath, - iterName, - bridgeType, - bridgeField, - wires, - arrayIterators, - buildSourceExpr, - extractCoalesceAlt, - desugarExprChain, - extractTernaryBranch, - processLocalBindings, - processLocalToolBindings, - processElementHandleWires, - desugarTemplateString, - desugarNot, - resolveParenExpr, - ); - aliasElemCleanup(); - toolCleanup(); - continue; - } - - wires.push( - withLoc( - { - to: localToRef, - sources: [ - { - expr: { - type: "ref", - ref: sourceRef, - ...(aliasSafe ? { safe: true } : {}), - ...(sourceLoc ? { refLoc: sourceLoc } : {}), - }, - }, - ...aliasFallbacks, - ], - ...(aliasCatchHandler ? { catch: aliasCatchHandler } : {}), - }, - aliasLoc, - ), - ); - wires.push(...aliasFallbackInternalWires); - wires.push(...aliasCatchInternalWires); - } - } - - // ── Step 2: Process wire lines ───────────────────────────────────────── - - for (const bodyLine of bodyLines) { - const c = bodyLine.children; - if (c.bridgeWithDecl) continue; // already processed - if (c.bridgeNodeAlias) continue; // already processed in Step 1.5 - if (c.bridgeForce) continue; // handled below - - const wireNode = (c.bridgeWire as CstNode[] | undefined)?.[0]; - if (!wireNode) continue; - - const wc = wireNode.children; - const lineNum = line(findFirstToken(wireNode)); - const wireLoc = locFromNode(wireNode); - - // Parse target - const { root: targetRoot, segments: targetSegs } = extractAddressPath( - sub(wireNode, "target")!, - ); - const toRef = resolveAddress(targetRoot, targetSegs, lineNum); - assertNoTargetIndices(toRef, lineNum); - - // ── Constant wire: target = value ── - if (wc.equalsOp) { - const value = extractBareValue(sub(wireNode, "constValue")!); - wires.push( - withLoc( - { to: toRef, sources: [{ expr: { type: "literal", value } }] }, - wireLoc, - ), - ); - continue; - } - - // ── Path scoping block: target { .field ... } ── - if (wc.scopeBlock) { - // Process alias declarations inside the scope block first - // Scope aliases use the same bridgeNodeAlias rule — process with full - // expression support (coalesce, catch, ternary, string templates, etc.) - const scopeAliases = subs(wireNode, "scopeAlias"); - for (const aliasNode of scopeAliases) { - const aliasLineNum = line(findFirstToken(aliasNode)); - const aliasNodeLoc = locFromNode(aliasNode); - const alias = extractNameToken(sub(aliasNode, "nodeAliasName")!); - assertNotReserved(alias, aliasLineNum, "node alias"); - if (handleRes.has(alias)) { - throw new Error( - `Line ${aliasLineNum}: Duplicate handle name "${alias}"`, - ); - } - - // Extract coalesce modifiers - const scopeAliasFallbacks: WireSourceEntry[] = []; - const scopeAliasFbWires: Wire[] = []; - for (const item of subs(aliasNode, "aliasCoalesceItem")) { - const type = tok(item, "falsyOp") - ? ("falsy" as const) - : ("nullish" as const); - const altNode = sub(item, "altValue")!; - const preLen = wires.length; - const altResult = extractCoalesceAlt(altNode, aliasLineNum); - scopeAliasFallbacks.push(buildSourceEntry(type, altNode, altResult)); - if ("sourceRef" in altResult) { - scopeAliasFbWires.push(...wires.splice(preLen)); - } - } - let scopeAliasCatch: WireCatch | undefined; - let scopeAliasCatchWires: Wire[] = []; - const scopeCatchAlt = sub(aliasNode, "aliasCatchAlt"); - if (scopeCatchAlt) { - const preLen = wires.length; - const altResult = extractCoalesceAlt(scopeCatchAlt, aliasLineNum); - scopeAliasCatch = buildCatchHandler(scopeCatchAlt, altResult); - if ("sourceRef" in altResult) { - scopeAliasCatchWires = wires.splice(preLen); - } - } - - // Compute source ref (same logic as top-level alias) - const scopeStringToken = ( - aliasNode.children.aliasStringSource as IToken[] | undefined - )?.[0]; - let scopeSrcRef: NodeRef; - let scopeSrcLoc: SourceLocation | undefined; - let scopeSafe: boolean | undefined; - - if (scopeStringToken) { - const raw = scopeStringToken.image.slice(1, -1); - const segs = parseTemplateString(raw); - const strRef = segs - ? desugarTemplateString(segs, aliasLineNum, undefined, aliasNodeLoc) - : desugarTemplateString( - [{ kind: "text", value: raw }], - aliasLineNum, - undefined, - aliasNodeLoc, - ); - const strOps = subs(aliasNode, "aliasStringExprOp"); - if (strOps.length > 0) { - scopeSrcRef = desugarExprChain( - strRef, - strOps, - subs(aliasNode, "aliasStringExprRight"), - aliasLineNum, - undefined, - undefined, - aliasNodeLoc, - ); - } else { - scopeSrcRef = strRef; - } - scopeSrcLoc = aliasNodeLoc; - scopeSafe = false; - } else { - const fpn = sub(aliasNode, "aliasFirstParen"); - const fsn = sub(aliasNode, "nodeAliasSource"); - const hn = fsn ? sub(fsn, "head") : undefined; - const safe = hn ? !!extractAddressPath(hn).rootSafe : false; - const ops = subs(aliasNode, "aliasExprOp"); - const rights = subs(aliasNode, "aliasExprRight"); - scopeSrcLoc = locFromNodeRange( - fpn ?? fsn, - rights[rights.length - 1] ?? fpn ?? fsn, - ); - - if (fpn) { - const pr = resolveParenExpr(fpn, aliasLineNum, undefined, safe); - scopeSrcRef = - ops.length > 0 - ? desugarExprChain( - pr, - ops, - rights, - aliasLineNum, - undefined, - safe, - aliasNodeLoc, - ) - : pr; - } else if (ops.length > 0) { - scopeSrcRef = desugarExprChain( - buildSourceExpr(fsn!, aliasLineNum), - ops, - rights, - aliasLineNum, - undefined, - safe, - aliasNodeLoc, - ); - } else { - const r = buildSourceExprSafe(fsn!, aliasLineNum); - scopeSrcRef = r.ref; - scopeSafe = r.safe; - } - - if ( - (aliasNode.children.aliasNotPrefix as IToken[] | undefined)?.[0] - ) { - scopeSrcRef = desugarNot( - scopeSrcRef, - aliasLineNum, - safe, - aliasNodeLoc, - ); - } - - if (scopeSafe === undefined) scopeSafe = safe || undefined; - } - - const localRes: HandleResolution = { - module: "__local", - type: "Shadow", - field: alias, - }; - handleRes.set(alias, localRes); - const localToRef: NodeRef = { - module: "__local", - type: "Shadow", - field: alias, - path: [], - }; - wires.push( - withLoc( - { - to: localToRef, - sources: [ - { - expr: { - type: "ref", - ref: scopeSrcRef, - ...(scopeSafe ? { safe: true as const } : {}), - ...(scopeSrcLoc ? { refLoc: scopeSrcLoc } : {}), - }, - }, - ...scopeAliasFallbacks, - ], - ...(scopeAliasCatch ? { catch: scopeAliasCatch } : {}), - }, - aliasNodeLoc, - ), - ); - wires.push(...scopeAliasFbWires); - wires.push(...scopeAliasCatchWires); - } - const scopeLines = subs(wireNode, "pathScopeLine"); - // Process spread lines inside the scope block: ...sourceExpr - const spreadLines = subs(wireNode, "scopeSpreadLine"); - for (const spreadLine of spreadLines) { - const spreadLineNum = line(findFirstToken(spreadLine)); - const sourceNode = sub(spreadLine, "spreadSource")!; - const { ref: fromRef, safe: spreadSafe } = buildSourceExprSafe( - sourceNode, - spreadLineNum, - ); - wires.push( - withLoc( - { - to: toRef, - sources: [ - { - expr: { - type: "ref", - ref: fromRef, - ...(spreadSafe ? { safe: true } : {}), - }, - }, - ], - spread: true, - }, - locFromNode(spreadLine), - ), - ); - } - processScopeLines(scopeLines, targetRoot, targetSegs); - continue; - } - - // ── Pull wire: target <- source [modifiers] ── - - // ── String source (template or plain): target <- "..." ── - const stringSourceToken = (wc.stringSource as IToken[] | undefined)?.[0]; - if (stringSourceToken) { - const raw = stringSourceToken.image.slice(1, -1); // strip quotes - const segs = parseTemplateString(raw); - - // Process coalesce modifiers - const fallbacks: WireSourceEntry[] = []; - const fallbackInternalWires: Wire[] = []; - for (const item of subs(wireNode, "coalesceItem")) { - const type = tok(item, "falsyOp") - ? ("falsy" as const) - : ("nullish" as const); - const altNode = sub(item, "altValue")!; - const preLen = wires.length; - const altResult = extractCoalesceAlt(altNode, lineNum); - fallbacks.push(buildSourceEntry(type, altNode, altResult)); - if ("sourceRef" in altResult) { - fallbackInternalWires.push(...wires.splice(preLen)); - } - } - let catchHandler: WireCatch | undefined; - let catchInternalWires: Wire[] = []; - const catchAlt = sub(wireNode, "catchAlt"); - if (catchAlt) { - const preLen = wires.length; - const altResult = extractCoalesceAlt(catchAlt, lineNum); - catchHandler = buildCatchHandler(catchAlt, altResult); - if ("sourceRef" in altResult) { - catchInternalWires = wires.splice(preLen); - } - } - - if (segs) { - // Template string — desugar to synthetic internal.concat fork - const concatOutRef = desugarTemplateString( - segs, - lineNum, - undefined, - wireLoc, - ); - wires.push( - withLoc( - { - to: toRef, - sources: [ - { expr: { type: "ref", ref: concatOutRef } }, - ...fallbacks, - ], - ...(catchHandler ? { catch: catchHandler } : {}), - pipe: true, - }, - wireLoc, - ), - ); - } else { - // Plain string without interpolation — emit constant wire - wires.push( - withLoc( - { - to: toRef, - sources: [ - { expr: { type: "literal", value: raw } }, - ...fallbacks, - ], - ...(catchHandler ? { catch: catchHandler } : {}), - }, - wireLoc, - ), - ); - } - wires.push(...fallbackInternalWires); - wires.push(...catchInternalWires); - continue; - } - - // Array mapping? - const arrayMappingNode = (wc.arrayMapping as CstNode[] | undefined)?.[0]; - if (arrayMappingNode) { - const firstSourceNode = sub(wireNode, "firstSource"); - const firstParenNode = sub(wireNode, "firstParenExpr"); - const srcRef = firstParenNode - ? resolveParenExpr( - firstParenNode, - lineNum, - undefined, - undefined, - wireLoc, - ) - : buildSourceExpr(firstSourceNode!, lineNum); - - // Process coalesce modifiers on the array wire (same as plain pull wires) - const arrayFallbacks: WireSourceEntry[] = []; - const arrayFallbackInternalWires: Wire[] = []; - for (const item of subs(wireNode, "coalesceItem")) { - const type = tok(item, "falsyOp") - ? ("falsy" as const) - : ("nullish" as const); - const altNode = sub(item, "altValue")!; - const preLen = wires.length; - const altResult = extractCoalesceAlt(altNode, lineNum); - arrayFallbacks.push(buildSourceEntry(type, altNode, altResult)); - if ("sourceRef" in altResult) { - arrayFallbackInternalWires.push(...wires.splice(preLen)); - } - } - let arrayCatchHandler: WireCatch | undefined; - let arrayCatchInternalWires: Wire[] = []; - const arrayCatchAlt = sub(wireNode, "catchAlt"); - if (arrayCatchAlt) { - const preLen = wires.length; - const altResult = extractCoalesceAlt(arrayCatchAlt, lineNum); - arrayCatchHandler = buildCatchHandler(arrayCatchAlt, altResult); - if ("sourceRef" in altResult) { - arrayCatchInternalWires = wires.splice(preLen); - } - } - - wires.push( - withLoc( - { - to: toRef, - sources: [ - { expr: { type: "ref", ref: srcRef } }, - ...arrayFallbacks, - ], - ...(arrayCatchHandler ? { catch: arrayCatchHandler } : {}), - }, - wireLoc, - ), - ); - wires.push(...arrayFallbackInternalWires); - wires.push(...arrayCatchInternalWires); - - const iterName = extractNameToken(sub(arrayMappingNode, "iterName")!); - assertNotReserved(iterName, lineNum, "iterator handle"); - const arrayToPath = toRef.path; - arrayIterators[arrayToPath.join(".")] = iterName; - - // Process element lines (supports nested array mappings recursively) - const elemWithDecls = subs(arrayMappingNode, "elementWithDecl"); - const elemToolWithDecls = subs(arrayMappingNode, "elementToolWithDecl"); - const { writableHandles, cleanup: toolCleanup } = - processLocalToolBindings(elemToolWithDecls); - const cleanup = processLocalBindings(elemWithDecls, iterName); - processElementHandleWires( - subs(arrayMappingNode, "elementHandleWire"), - iterName, - writableHandles, - ); - processElementLines( - subs(arrayMappingNode, "elementLine"), - arrayToPath, - iterName, - bridgeType, - bridgeField, - wires, - arrayIterators, - buildSourceExpr, - extractCoalesceAlt, - desugarExprChain, - extractTernaryBranch, - processLocalBindings, - processLocalToolBindings, - processElementHandleWires, - desugarTemplateString, - desugarNot, - resolveParenExpr, - ); - cleanup(); - toolCleanup(); - continue; - } - - const firstSourceNode = sub(wireNode, "firstSource"); - const firstParenNode = sub(wireNode, "firstParenExpr"); - const sourceParts: { ref: NodeRef; isPipeFork: boolean }[] = []; - - // Check for safe navigation (?.) on the head address path - const headNode = firstSourceNode ? sub(firstSourceNode, "head") : undefined; - const isSafe = headNode ? !!extractAddressPath(headNode).rootSafe : false; - - const exprOps = subs(wireNode, "exprOp"); - const exprRights = subs(wireNode, "exprRight"); - const sourceLoc = locFromNodeRange( - firstParenNode ?? firstSourceNode, - exprRights[exprRights.length - 1] ?? firstParenNode ?? firstSourceNode, - ); - - // Compute condition ref (expression chain result or plain source) - let condRef: NodeRef; - let condIsPipeFork: boolean; - if (firstParenNode) { - // First source is a parenthesized sub-expression - const parenRef = resolveParenExpr( - firstParenNode, - lineNum, - undefined, - isSafe, - wireLoc, - ); - if (exprOps.length > 0) { - condRef = desugarExprChain( - parenRef, - exprOps, - exprRights, - lineNum, - undefined, - isSafe, - wireLoc, - ); - } else { - condRef = parenRef; - } - condIsPipeFork = true; - } else if (exprOps.length > 0) { - // It's a math/comparison expression — desugar it. - const leftRef = buildSourceExpr(firstSourceNode!, lineNum); - condRef = desugarExprChain( - leftRef, - exprOps, - exprRights, - lineNum, - undefined, - isSafe, - wireLoc, - ); - condIsPipeFork = true; - } else { - const pipeSegs = subs(firstSourceNode!, "pipeSegment"); - condRef = buildSourceExpr(firstSourceNode!, lineNum); - condIsPipeFork = - condRef.instance != null && - condRef.path.length === 0 && - pipeSegs.length > 0; - } - - // ── Apply `not` prefix if present ── - if (wc.notPrefix) { - condRef = desugarNot(condRef, lineNum, isSafe, wireLoc); - condIsPipeFork = true; - } - - // ── Ternary wire: cond ? thenBranch : elseBranch ── - const ternaryOp = tok(wireNode, "ternaryOp"); - if (ternaryOp) { - const thenNode = sub(wireNode, "thenBranch")!; - const elseNode = sub(wireNode, "elseBranch")!; - const thenBranch = extractTernaryBranch(thenNode, lineNum); - const elseBranch = extractTernaryBranch(elseNode, lineNum); - - // Process coalesce alternatives. - const fallbacks: WireSourceEntry[] = []; - const fallbackInternalWires: Wire[] = []; - for (const item of subs(wireNode, "coalesceItem")) { - const type = tok(item, "falsyOp") - ? ("falsy" as const) - : ("nullish" as const); - const altNode = sub(item, "altValue")!; - const preLen = wires.length; - const altResult = extractCoalesceAlt(altNode, lineNum); - fallbacks.push(buildSourceEntry(type, altNode, altResult)); - if ("sourceRef" in altResult) { - fallbackInternalWires.push(...wires.splice(preLen)); - } - } - - // Process catch error fallback. - let catchHandler: WireCatch | undefined; - let catchInternalWires: Wire[] = []; - const catchAlt = sub(wireNode, "catchAlt"); - if (catchAlt) { - const preLen = wires.length; - const altResult = extractCoalesceAlt(catchAlt, lineNum); - catchHandler = buildCatchHandler(catchAlt, altResult); - if ("sourceRef" in altResult) { - catchInternalWires = wires.splice(preLen); - } - } - - wires.push( - withLoc( - { - to: toRef, - sources: [ - { - expr: { - type: "ternary", - cond: { - type: "ref", - ref: condRef, - ...(sourceLoc ? { refLoc: sourceLoc } : {}), - }, - then: - thenBranch.kind === "ref" - ? { - type: "ref" as const, - ref: thenBranch.ref, - loc: thenBranch.loc, - } - : { - type: "literal" as const, - value: thenBranch.value, - loc: thenBranch.loc, - }, - else: - elseBranch.kind === "ref" - ? { - type: "ref" as const, - ref: elseBranch.ref, - loc: elseBranch.loc, - } - : { - type: "literal" as const, - value: elseBranch.value, - loc: elseBranch.loc, - }, - ...(sourceLoc ? { condLoc: sourceLoc } : {}), - thenLoc: thenBranch.loc, - elseLoc: elseBranch.loc, - }, - }, - ...fallbacks, - ], - ...(catchHandler ? { catch: catchHandler } : {}), - }, - wireLoc, - ), - ); - wires.push(...fallbackInternalWires); - wires.push(...catchInternalWires); - continue; - } - - sourceParts.push({ ref: condRef, isPipeFork: condIsPipeFork }); - - const fallbacks: WireSourceEntry[] = []; - const fallbackInternalWires: Wire[] = []; - let hasTruthyLiteralFallback = false; - for (const item of subs(wireNode, "coalesceItem")) { - const type = tok(item, "falsyOp") - ? ("falsy" as const) - : ("nullish" as const); - if (type === "falsy" && hasTruthyLiteralFallback) break; - const altNode = sub(item, "altValue")!; - const preLen = wires.length; - const altResult = extractCoalesceAlt(altNode, lineNum); - if ("literal" in altResult) { - fallbacks.push(buildSourceEntry(type, altNode, altResult)); - if (type === "falsy") { - hasTruthyLiteralFallback = Boolean(JSON.parse(altResult.literal)); - } - } else if ("control" in altResult) { - fallbacks.push(buildSourceEntry(type, altNode, altResult)); - } else { - fallbacks.push(buildSourceEntry(type, altNode, altResult)); - fallbackInternalWires.push(...wires.splice(preLen)); - } - } - - let catchHandler: WireCatch | undefined; - let catchInternalWires: Wire[] = []; - const catchAlt = sub(wireNode, "catchAlt"); - if (catchAlt) { - const preLen = wires.length; - const altResult = extractCoalesceAlt(catchAlt, lineNum); - catchHandler = buildCatchHandler(catchAlt, altResult); - if ("sourceRef" in altResult) { - catchInternalWires = wires.splice(preLen); - } - } - - const { ref: fromRef, isPipeFork: isPipe } = sourceParts[0]; - - wires.push( - withLoc( - { - to: toRef, - sources: [ - { - expr: { - type: "ref", - ref: fromRef, - ...(isSafe ? { safe: true } : {}), - ...(sourceLoc ? { refLoc: sourceLoc } : {}), - }, - }, - ...fallbacks, - ], - ...(catchHandler ? { catch: catchHandler } : {}), - ...(isPipe ? { pipe: true as const } : {}), - }, - wireLoc, - ), - ); - wires.push(...fallbackInternalWires); - wires.push(...catchInternalWires); - } - - // ── Step 3: Collect force statements ────────────────────────────────── - - const forces: NonNullable = []; - for (const bodyLine of bodyLines) { - const forceNode = ( - bodyLine.children.bridgeForce as CstNode[] | undefined - )?.[0]; - if (!forceNode) continue; - const lineNum = line(findFirstToken(forceNode)); - const handle = extractNameToken(sub(forceNode, "forcedHandle")!); - const res = handleRes.get(handle); - if (!res) { - throw new Error( - `Line ${lineNum}: Cannot force undeclared handle "${handle}". Add 'with ${handle}' to the bridge header.`, - ); - } - const fc = forceNode.children; - const catchError = !!(fc.forceCatchKw as IToken[] | undefined)?.length; - forces.push({ - handle, - ...res, - ...(catchError ? { catchError: true as const } : {}), - }); - } - - // ── Step 4: Process tool self-wires (elementLine CST nodes) ─────────── - - const selfWireNodes = options?.selfWireNodes; - if (selfWireNodes) { - for (const elemLine of selfWireNodes) { - const elemC = elemLine.children; - const elemLineNum = line(findFirstToken(elemLine)); - const elemLineLoc = locFromNode(elemLine); - const elemTargetPathStr = extractDottedPathStr( - sub(elemLine, "elemTarget")!, - ); - const elemToPath = parsePath(elemTargetPathStr); - const toRef: NodeRef = { - module: SELF_MODULE, - type: bridgeType, - field: bridgeField, - path: elemToPath, - }; - - if (elemC.elemEquals) { - // Constant self-wire: .property = value - const value = extractBareValue(sub(elemLine, "elemValue")!); - wires.push( - withLoc( - { to: toRef, sources: [{ expr: { type: "literal", value } }] }, - elemLineLoc, - ), - ); - continue; - } - - if (!elemC.elemArrow) continue; - - // ── String source: .field <- "..." ── - const elemStrToken = ( - elemC.elemStringSource as IToken[] | undefined - )?.[0]; - if (elemStrToken) { - const raw = elemStrToken.image.slice(1, -1); - const segs = parseTemplateString(raw); - if (segs) { - // Check for circular self-references - for (const seg of segs) { - if (seg.kind === "ref" && seg.path.startsWith(".")) { - throw new Error( - `Line ${elemLineNum}: Self-reference "{${seg.path}}" in tool "${bridgeField}" creates a circular dependency. A tool's output cannot be used as its own input.`, - ); - } - } - // Desugar template string into concat fork - const concatOutRef = desugarTemplateString( - segs, - elemLineNum, - undefined, - elemLineLoc, - ); - wires.push( - withLoc( - { - to: toRef, - sources: [{ expr: { type: "ref", ref: concatOutRef } }], - pipe: true, - }, - elemLineLoc, - ), - ); - } else { - // Plain string without interpolation — emit constant wire - wires.push( - withLoc( - { - to: toRef, - sources: [{ expr: { type: "literal", value: raw } }], - }, - elemLineLoc, - ), - ); - } - continue; - } - - // ── Source expression or paren expression ── - const elemSourceNode = sub(elemLine, "elemSource"); - const elemFirstParenNode = sub(elemLine, "elemFirstParenExpr"); - - let elemSafe = false; - if (elemSourceNode) { - const headNode = sub(elemSourceNode, "head")!; - const extracted = extractAddressPath(headNode); - elemSafe = !!extracted.rootSafe; - } - - const elemExprOps = subs(elemLine, "elemExprOp"); - const elemExprRights = subs(elemLine, "elemExprRight"); - const elemCondLoc = locFromNodeRange( - elemFirstParenNode ?? elemSourceNode, - elemExprRights[elemExprRights.length - 1] ?? - elemFirstParenNode ?? - elemSourceNode, - ); - - // Compute condition ref (expression chain or plain source) - let condRef: NodeRef; - let condIsPipeFork: boolean; - if (elemFirstParenNode) { - const parenRef = resolveParenExpr( - elemFirstParenNode, - elemLineNum, - undefined, - elemSafe || undefined, - elemLineLoc, - ); - if (elemExprOps.length > 0) { - condRef = desugarExprChain( - parenRef, - elemExprOps, - elemExprRights, - elemLineNum, - undefined, - elemSafe || undefined, - elemLineLoc, - ); - } else { - condRef = parenRef; - } - condIsPipeFork = true; - } else if (elemExprOps.length > 0) { - const leftRef = buildSourceExpr(elemSourceNode!, elemLineNum); - condRef = desugarExprChain( - leftRef, - elemExprOps, - elemExprRights, - elemLineNum, - undefined, - elemSafe || undefined, - elemLineLoc, - ); - condIsPipeFork = true; - } else { - condRef = buildSourceExpr(elemSourceNode!, elemLineNum); - condIsPipeFork = false; - } - - // Apply `not` prefix - if ((elemC.elemNotPrefix as IToken[] | undefined)?.[0]) { - condRef = desugarNot( - condRef, - elemLineNum, - elemSafe || undefined, - elemLineLoc, - ); - condIsPipeFork = true; - } - - // ── Ternary ── - const elemTernaryOp = (elemC.elemTernaryOp as IToken[] | undefined)?.[0]; - if (elemTernaryOp) { - const thenNode = sub(elemLine, "elemThenBranch")!; - const elseNode = sub(elemLine, "elemElseBranch")!; - const thenBranch = extractTernaryBranch(thenNode, elemLineNum); - const elseBranch = extractTernaryBranch(elseNode, elemLineNum); - - // Coalesce - const ternFallbacks: WireSourceEntry[] = []; - const ternFallbackWires: Wire[] = []; - for (const item of subs(elemLine, "elemCoalesceItem")) { - const type = tok(item, "falsyOp") - ? ("falsy" as const) - : ("nullish" as const); - const altNode = sub(item, "altValue")!; - const preLen = wires.length; - const altResult = extractCoalesceAlt(altNode, elemLineNum); - ternFallbacks.push(buildSourceEntry(type, altNode, altResult)); - if ("sourceRef" in altResult) { - ternFallbackWires.push(...wires.splice(preLen)); - } - } - - // Catch - let ternCatchHandler: WireCatch | undefined; - let ternCatchWires: Wire[] = []; - const ternCatchAlt = sub(elemLine, "elemCatchAlt"); - if (ternCatchAlt) { - const preLen = wires.length; - const altResult = extractCoalesceAlt(ternCatchAlt, elemLineNum); - ternCatchHandler = buildCatchHandler(ternCatchAlt, altResult); - if ("sourceRef" in altResult) { - ternCatchWires = wires.splice(preLen); - } - } - - wires.push( - withLoc( - { - to: toRef, - sources: [ - { - expr: { - type: "ternary", - cond: { - type: "ref", - ref: condRef, - ...(elemCondLoc ? { refLoc: elemCondLoc } : {}), - }, - then: - thenBranch.kind === "ref" - ? { - type: "ref" as const, - ref: thenBranch.ref, - loc: thenBranch.loc, - } - : { - type: "literal" as const, - value: thenBranch.value, - loc: thenBranch.loc, - }, - else: - elseBranch.kind === "ref" - ? { - type: "ref" as const, - ref: elseBranch.ref, - loc: elseBranch.loc, - } - : { - type: "literal" as const, - value: elseBranch.value, - loc: elseBranch.loc, - }, - ...(elemCondLoc ? { condLoc: elemCondLoc } : {}), - thenLoc: thenBranch.loc, - elseLoc: elseBranch.loc, - }, - }, - ...ternFallbacks, - ], - ...(ternCatchHandler ? { catch: ternCatchHandler } : {}), - }, - elemLineLoc, - ), - ); - wires.push(...ternFallbackWires); - wires.push(...ternCatchWires); - continue; - } - - // ── Coalesce chains ── - const fallbacks: WireSourceEntry[] = []; - const fallbackInternalWires: Wire[] = []; - for (const item of subs(elemLine, "elemCoalesceItem")) { - const type = tok(item, "falsyOp") - ? ("falsy" as const) - : ("nullish" as const); - const altNode = sub(item, "altValue")!; - const preLen = wires.length; - const altResult = extractCoalesceAlt(altNode, elemLineNum); - fallbacks.push(buildSourceEntry(type, altNode, altResult)); - if ("sourceRef" in altResult) { - fallbackInternalWires.push(...wires.splice(preLen)); - } - } - - // ── Catch fallback ── - let catchHandler: WireCatch | undefined; - let catchInternalWires: Wire[] = []; - const catchAlt = sub(elemLine, "elemCatchAlt"); - if (catchAlt) { - const preLen = wires.length; - const altResult = extractCoalesceAlt(catchAlt, elemLineNum); - catchHandler = buildCatchHandler(catchAlt, altResult); - if ("sourceRef" in altResult) { - catchInternalWires = wires.splice(preLen); - } - } - - // Emit wire - wires.push( - withLoc( - { - to: toRef, - sources: [{ expr: { type: "ref", ref: condRef } }, ...fallbacks], - ...(catchHandler ? { catch: catchHandler } : {}), - ...(condIsPipeFork ? { pipe: true as const } : {}), - }, - elemLineLoc, - ), - ); - wires.push(...fallbackInternalWires); - wires.push(...catchInternalWires); - } - } - - return { - handles: handleBindings, - wires, - arrayIterators, - pipeHandles: pipeHandleEntries, - forces, - handleRes, - }; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// inlineDefine (matching the regex parser) -// ═══════════════════════════════════════════════════════════════════════════ - -function inlineDefine( - defineHandle: string, - defineDef: DefineDef, - bridgeType: string, - bridgeField: string, - wires: Wire[], - pipeHandleEntries: NonNullable, - handleBindings: HandleBinding[], - instanceCounters: Map, - nextForkSeqRef: { value: number }, -): void { - const genericModule = `__define_${defineHandle}`; - const inModule = `__define_in_${defineHandle}`; - const outModule = `__define_out_${defineHandle}`; - const defType = "Define"; - const defField = defineDef.name; - - const defCounters = new Map(); - const trunkRemap = new Map< - string, - { module: string; type: string; field: string; instance: number } - >(); - - for (const hb of defineDef.handles) { - if ( - hb.kind === "input" || - hb.kind === "output" || - hb.kind === "context" || - hb.kind === "const" - ) - continue; - if (hb.kind === "define") continue; - const name = hb.kind === "tool" ? hb.name : ""; - if (!name) continue; - - const lastDot = name.lastIndexOf("."); - let oldModule: string, - oldType: string, - oldField: string, - instanceKey: string, - bridgeKey: string; - - if (lastDot !== -1) { - oldModule = name.substring(0, lastDot); - oldType = defType; - oldField = name.substring(lastDot + 1); - instanceKey = `${oldModule}:${oldField}`; - bridgeKey = instanceKey; - } else { - oldModule = SELF_MODULE; - oldType = "Tools"; - oldField = name; - instanceKey = `Tools:${name}`; - bridgeKey = instanceKey; - } - - const oldInstance = (defCounters.get(instanceKey) ?? 0) + 1; - defCounters.set(instanceKey, oldInstance); - const newInstance = (instanceCounters.get(bridgeKey) ?? 0) + 1; - instanceCounters.set(bridgeKey, newInstance); - - const oldKey = `${oldModule}:${oldType}:${oldField}:${oldInstance}`; - trunkRemap.set(oldKey, { - module: oldModule, - type: oldModule === SELF_MODULE ? oldType : bridgeType, - field: oldField, - instance: newInstance, - }); - handleBindings.push({ - handle: `${defineHandle}$${hb.handle}`, - kind: "tool", - name, - ...(hb.memoize ? { memoize: true as const } : {}), - ...(hb.version ? { version: hb.version } : {}), - }); - } - - // Remap existing bridge wires pointing at the generic define module - function remapModuleInExpr( - expr: Expression, - fromModule: string, - toModule: string, - ): Expression { - if (expr.type === "ref" && expr.ref.module === fromModule) { - return { ...expr, ref: { ...expr.ref, module: toModule } }; - } - if (expr.type === "ternary") { - return { - ...expr, - cond: remapModuleInExpr(expr.cond, fromModule, toModule), - then: remapModuleInExpr(expr.then, fromModule, toModule), - else: remapModuleInExpr(expr.else, fromModule, toModule), - }; - } - if (expr.type === "and" || expr.type === "or") { - return { - ...expr, - left: remapModuleInExpr(expr.left, fromModule, toModule), - right: remapModuleInExpr(expr.right, fromModule, toModule), - }; - } - return expr; - } - - for (const wire of wires) { - if (wire.to.module === genericModule) - wire.to = { ...wire.to, module: inModule }; - if (wire.sources) { - for (let i = 0; i < wire.sources.length; i++) { - wire.sources[i] = { - ...wire.sources[i], - expr: remapModuleInExpr( - wire.sources[i].expr, - genericModule, - outModule, - ), - }; - } - } - if ( - wire.catch && - "ref" in wire.catch && - wire.catch.ref.module === genericModule - ) - wire.catch = { - ...wire.catch, - ref: { ...wire.catch.ref, module: outModule }, - }; - } - - const forkOffset = nextForkSeqRef.value; - let maxDefForkSeq = 0; - - function remapRef(ref: NodeRef, side: "from" | "to"): NodeRef { - if ( - ref.module === SELF_MODULE && - ref.type === defType && - ref.field === defField - ) { - const targetModule = side === "from" ? inModule : outModule; - return { - ...ref, - module: targetModule, - type: bridgeType, - field: bridgeField, - }; - } - const key = `${ref.module}:${ref.type}:${ref.field}:${ref.instance ?? ""}`; - const newTrunk = trunkRemap.get(key); - if (newTrunk) - return { - ...ref, - module: newTrunk.module, - type: newTrunk.type, - field: newTrunk.field, - instance: newTrunk.instance, - }; - if (ref.instance != null && ref.instance >= 100000) { - const defSeq = ref.instance - 100000; - if (defSeq + 1 > maxDefForkSeq) maxDefForkSeq = defSeq + 1; - return { ...ref, instance: ref.instance + forkOffset }; - } - return ref; - } - - function remapExpr(expr: Expression, side: "from" | "to"): Expression { - if (expr.type === "ref") { - return { ...expr, ref: remapRef(expr.ref, side) }; - } - if (expr.type === "ternary") { - return { - ...expr, - cond: remapExpr(expr.cond, "from"), - then: remapExpr(expr.then, "from"), - else: remapExpr(expr.else, "from"), - }; - } - if (expr.type === "and" || expr.type === "or") { - return { - ...expr, - left: remapExpr(expr.left, "from"), - right: remapExpr(expr.right, "from"), - }; - } - return expr; - } - - for (const wire of defineDef.wires) { - const cloned: Wire = JSON.parse(JSON.stringify(wire)); - cloned.to = remapRef(cloned.to, "to"); - if (cloned.sources) { - cloned.sources = cloned.sources.map((s) => ({ - ...s, - expr: remapExpr(s.expr, "from"), - })); - } - if (cloned.catch && "ref" in cloned.catch) { - cloned.catch = { - ...cloned.catch, - ref: remapRef(cloned.catch.ref, "from"), - }; - } - wires.push(cloned); - } - - nextForkSeqRef.value += maxDefForkSeq; - - if (defineDef.pipeHandles) { - for (const ph of defineDef.pipeHandles) { - const parts = ph.key.split(":"); - const phInstance = parseInt(parts[parts.length - 1]); - let newKey = ph.key; - if (phInstance >= 100000) { - const newInst = phInstance + forkOffset; - parts[parts.length - 1] = String(newInst); - newKey = parts.join(":"); - } - const bt = ph.baseTrunk; - const btKey = `${bt.module}:${defType}:${bt.field}:${bt.instance ?? ""}`; - const newBt = trunkRemap.get(btKey); - const btKey2 = `${bt.module}:Tools:${bt.field}:${bt.instance ?? ""}`; - const newBt2 = trunkRemap.get(btKey2); - const resolvedBt = newBt ?? newBt2; - pipeHandleEntries.push({ - key: newKey, - handle: `${defineHandle}$${ph.handle}`, - baseTrunk: resolvedBt - ? { - module: resolvedBt.module, - type: resolvedBt.type, - field: resolvedBt.field, - instance: resolvedBt.instance, - } - : ph.baseTrunk, - }); - } - } -} diff --git a/packages/bridge-parser/test/bridge-format.test.ts b/packages/bridge-parser/test/bridge-format.test.ts index 566dca69..c2a1f966 100644 --- a/packages/bridge-parser/test/bridge-format.test.ts +++ b/packages/bridge-parser/test/bridge-format.test.ts @@ -10,14 +10,17 @@ import type { HandleBinding, Instruction, ToolDef, - Wire, } from "@stackables/bridge-core"; import { SELF_MODULE, parsePath } from "@stackables/bridge-core"; -import { assertDeepStrictEqualIgnoringLoc } from "./utils/parse-test-utils.ts"; +import { + assertDeepStrictEqualIgnoringLoc, + flatWires, + type FlatWire, +} from "./utils/parse-test-utils.ts"; import { bridge } from "@stackables/bridge-core"; -/** Helper to extract the source ref from a Wire */ -function sourceRef(wire: Wire) { +/** Helper to extract the source ref from a FlatWire */ +function sourceRef(wire: FlatWire) { const expr = wire.sources[0]?.expr; return expr?.type === "ref" ? expr.ref : undefined; } @@ -95,10 +98,10 @@ describe("parseBridge", () => { handle: "o", kind: "output", }); - assert.equal(instr.wires.length, 2); + assert.equal(flatWires(instr.body).length, 2); - assertDeepStrictEqualIgnoringLoc(instr.wires[0], { - to: { + assertDeepStrictEqualIgnoringLoc(flatWires(instr.body)[0], { + target: { module: SELF_MODULE, type: "Query", field: "geocode", @@ -118,8 +121,8 @@ describe("parseBridge", () => { }, ], }); - assertDeepStrictEqualIgnoringLoc(instr.wires[1], { - to: { + assertDeepStrictEqualIgnoringLoc(flatWires(instr.body)[1], { + target: { module: "hereapi", type: "Query", field: "geocode", @@ -162,8 +165,8 @@ describe("parseBridge", () => { (i): i is Bridge => i.kind === "bridge", )!; assert.equal(instr.handles.length, 3); - assertDeepStrictEqualIgnoringLoc(instr.wires[0], { - to: { + assertDeepStrictEqualIgnoringLoc(flatWires(instr.body)[0], { + target: { module: SELF_MODULE, type: "Tools", field: "toInt", @@ -185,8 +188,8 @@ describe("parseBridge", () => { }, ], }); - assertDeepStrictEqualIgnoringLoc(instr.wires[1], { - to: { + assertDeepStrictEqualIgnoringLoc(flatWires(instr.body)[1], { + target: { module: SELF_MODULE, type: "Query", field: "health", @@ -225,26 +228,24 @@ describe("parseBridge", () => { const instr = result.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - assertDeepStrictEqualIgnoringLoc(sourceRef(instr.wires[0]!), { + assertDeepStrictEqualIgnoringLoc(sourceRef(flatWires(instr.body)[0]!), { module: "zillow", type: "Query", field: "find", instance: 1, path: ["properties", "0", "streetAddress"], }); - assertDeepStrictEqualIgnoringLoc(instr.wires[0]!.to, { + assertDeepStrictEqualIgnoringLoc(flatWires(instr.body)[0]!.target, { module: SELF_MODULE, type: "Query", field: "search", path: ["topPick", "address"], }); - assertDeepStrictEqualIgnoringLoc(sourceRef(instr.wires[1]!)?.path, [ - "properties", - "0", - "location", - "city", - ]); - assertDeepStrictEqualIgnoringLoc(instr.wires[1]!.to.path, [ + assertDeepStrictEqualIgnoringLoc( + sourceRef(flatWires(instr.body)[1]!)?.path, + ["properties", "0", "location", "city"], + ); + assertDeepStrictEqualIgnoringLoc(flatWires(instr.body)[1]!.target.path, [ "topPick", "city", ]); @@ -268,34 +269,37 @@ describe("parseBridge", () => { const instr = result.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - assert.equal(instr.wires.length, 3); - assertDeepStrictEqualIgnoringLoc(instr.wires[0], { - to: { - module: SELF_MODULE, - type: "Query", - field: "search", - path: ["results"], - }, - sources: [ - { - expr: { - type: "ref", - ref: { - module: "provider", - type: "Query", - field: "list", - instance: 1, - path: ["items"], - }, - }, - }, - ], + const allWires = flatWires(instr.body); + assert.equal(allWires.length, 3); + // First wire: array mapping to results + const resultsWire = allWires[0]; + assertDeepStrictEqualIgnoringLoc(resultsWire.target, { + module: SELF_MODULE, + type: "Query", + field: "search", + path: ["results"], }); - assertDeepStrictEqualIgnoringLoc(instr.wires[1], { - to: { + const arrayExpr = resultsWire.sources[0]!.expr; + assert.equal(arrayExpr.type, "array"); + if (arrayExpr.type === "array") { + assert.equal(arrayExpr.iteratorName, "item"); + assertDeepStrictEqualIgnoringLoc(arrayExpr.source, { + type: "ref", + ref: { + module: "provider", + type: "Query", + field: "list", + instance: 1, + path: ["items"], + }, + }); + } + assertDeepStrictEqualIgnoringLoc(allWires[1], { + target: { module: SELF_MODULE, type: "Query", field: "search", + element: true, path: ["results", "name"], }, sources: [ @@ -313,11 +317,12 @@ describe("parseBridge", () => { }, ], }); - assertDeepStrictEqualIgnoringLoc(instr.wires[2], { - to: { + assertDeepStrictEqualIgnoringLoc(allWires[2], { + target: { module: SELF_MODULE, type: "Query", field: "search", + element: true, path: ["results", "lat"], }, sources: [ @@ -355,17 +360,17 @@ describe("parseBridge", () => { (i): i is Bridge => i.kind === "bridge", )!; assert.equal(instr.type, "Mutation"); - assertDeepStrictEqualIgnoringLoc(instr.wires[0]!.to, { + assertDeepStrictEqualIgnoringLoc(flatWires(instr.body)[0]!.target, { module: "sendgrid", type: "Mutation", field: "send", instance: 1, path: ["content"], }); - assertDeepStrictEqualIgnoringLoc(sourceRef(instr.wires[1]!)?.path, [ - "headers", - "x-message-id", - ]); + assertDeepStrictEqualIgnoringLoc( + sourceRef(flatWires(instr.body)[1]!)?.path, + ["headers", "x-message-id"], + ); }); test("multiple bridges separated by ---", () => { @@ -418,7 +423,7 @@ describe("parseBridge", () => { handle: "c", kind: "context", }); - assertDeepStrictEqualIgnoringLoc(sourceRef(instr.wires[0]!), { + assertDeepStrictEqualIgnoringLoc(sourceRef(flatWires(instr.body)[0]!), { module: SELF_MODULE, type: "Context", field: "context", @@ -583,9 +588,26 @@ describe("serializeBridge", () => { { handle: "i", kind: "input" }, { handle: "o", kind: "output" }, ], - wires: [ + body: [ { - to: { + kind: "with" as const, + binding: { + handle: "sg", + kind: "tool" as const, + name: "sendgrid.send", + }, + }, + { + kind: "with" as const, + binding: { handle: "i", kind: "input" as const }, + }, + { + kind: "with" as const, + binding: { handle: "o", kind: "output" as const }, + }, + { + kind: "wire" as const, + target: { module: "sendgrid", type: "Mutation", field: "send", @@ -595,7 +617,7 @@ describe("serializeBridge", () => { sources: [ { expr: { - type: "ref", + type: "ref" as const, ref: { module: SELF_MODULE, type: "Mutation", @@ -605,9 +627,10 @@ describe("serializeBridge", () => { }, }, ], - } as Wire, + }, { - to: { + kind: "wire" as const, + target: { module: SELF_MODULE, type: "Mutation", field: "sendEmail", @@ -616,7 +639,7 @@ describe("serializeBridge", () => { sources: [ { expr: { - type: "ref", + type: "ref" as const, ref: { module: "sendgrid", type: "Mutation", @@ -627,7 +650,7 @@ describe("serializeBridge", () => { }, }, ], - } as Wire, + }, ], }, ]; @@ -764,9 +787,9 @@ describe("parseBridge: tool blocks", () => { assertDeepStrictEqualIgnoringLoc(root.handles, [ { kind: "context", handle: "context" }, ]); - assertDeepStrictEqualIgnoringLoc(root.wires, [ + assertDeepStrictEqualIgnoringLoc(flatWires(root.body), [ { - to: { module: "_", type: "Tools", field: "hereapi", path: ["baseUrl"] }, + target: { module: "_", type: "Tools", field: "hereapi", path: ["baseUrl"] }, sources: [ { expr: { @@ -777,7 +800,7 @@ describe("parseBridge: tool blocks", () => { ], }, { - to: { + target: { module: "_", type: "Tools", field: "hereapi", @@ -802,9 +825,9 @@ describe("parseBridge: tool blocks", () => { const child = tools.find((t) => t.name === "hereapi.geocode")!; assert.equal(child.fn, undefined); assert.equal(child.extends, "hereapi"); - assertDeepStrictEqualIgnoringLoc(child.wires, [ + assertDeepStrictEqualIgnoringLoc(flatWires(child.body), [ { - to: { + target: { module: "_", type: "Tools", field: "hereapi.geocode", @@ -813,7 +836,7 @@ describe("parseBridge: tool blocks", () => { sources: [{ expr: { type: "literal", value: "GET" } }], }, { - to: { + target: { module: "_", type: "Tools", field: "hereapi.geocode", @@ -851,9 +874,9 @@ describe("parseBridge: tool blocks", () => { const root = result.instructions.find( (i): i is ToolDef => i.kind === "tool" && i.name === "sendgrid", )!; - assertDeepStrictEqualIgnoringLoc(root.wires, [ + assertDeepStrictEqualIgnoringLoc(flatWires(root.body), [ { - to: { + target: { module: "_", type: "Tools", field: "sendgrid", @@ -864,7 +887,7 @@ describe("parseBridge: tool blocks", () => { ], }, { - to: { + target: { module: "_", type: "Tools", field: "sendgrid", @@ -885,7 +908,7 @@ describe("parseBridge: tool blocks", () => { ], }, { - to: { + target: { module: "_", type: "Tools", field: "sendgrid", @@ -899,9 +922,9 @@ describe("parseBridge: tool blocks", () => { (i): i is ToolDef => i.kind === "tool" && i.name === "sendgrid.send", )!; assert.equal(child.extends, "sendgrid"); - assertDeepStrictEqualIgnoringLoc(child.wires, [ + assertDeepStrictEqualIgnoringLoc(flatWires(child.body), [ { - to: { + target: { module: "_", type: "Tools", field: "sendgrid.send", @@ -910,7 +933,7 @@ describe("parseBridge: tool blocks", () => { sources: [{ expr: { type: "literal", value: "POST" } }], }, { - to: { + target: { module: "_", type: "Tools", field: "sendgrid.send", @@ -955,8 +978,8 @@ describe("parseBridge: tool blocks", () => { { kind: "context", handle: "context" }, { kind: "tool", handle: "auth", name: "authService" }, ]); - assertDeepStrictEqualIgnoringLoc(serviceB.wires[1], { - to: { + assertDeepStrictEqualIgnoringLoc(flatWires(serviceB.body)[1], { + target: { module: "_", type: "Tools", field: "serviceB", @@ -1248,7 +1271,7 @@ describe("parser robustness", () => { "version 1.5\nbridge Query.search {\n\twith hereapi.geocode as gc\n\twith input as i\n\twith output as o\n\ngc.q <- i.search\no.results <- gc.items[] as item {\n\t.lat <- item.position.lat\n\t.lng <- item.position.lng\n}\n}\n", ).instructions.find((i) => i.kind === "bridge") as Bridge; assert.equal( - instr.wires.filter( + flatWires(instr.body).filter( (w) => w.sources[0]?.expr.type === "ref" && w.sources[0].expr.ref.element, ).length, @@ -1267,8 +1290,10 @@ describe("parser robustness", () => { o.name <- i.username # copy the name across } `).instructions.find((inst) => inst.kind === "bridge") as Bridge; - const wire = instr.wires.find((w) => w.sources[0]?.expr.type === "ref")!; - assert.equal(wire.to.path.join("."), "name"); + const wire = flatWires(instr.body).find( + (w) => w.sources[0]?.expr.type === "ref", + )!; + assert.equal(wire.target.path.join("."), "name"); const expr = wire.sources[0]!.expr; assert.equal( expr.type === "ref" ? expr.ref.path.join(".") : undefined, @@ -1284,9 +1309,9 @@ describe("parser robustness", () => { .url = "https://example.com/things#anchor" } `).instructions.find((inst) => inst.kind === "tool") as ToolDef; - const urlWire = tool.wires.find( + const urlWire = flatWires(tool.body).find( (w) => - w.sources[0]?.expr.type === "literal" && w.to.path.join(".") === "url", + w.sources[0]?.expr.type === "literal" && w.target.path.join(".") === "url", ); assert.ok(urlWire); assert.equal( diff --git a/packages/bridge-parser/test/bridge-printer-examples.test.ts b/packages/bridge-parser/test/bridge-printer-examples.test.ts index 5a9f10ca..56279896 100644 --- a/packages/bridge-parser/test/bridge-printer-examples.test.ts +++ b/packages/bridge-parser/test/bridge-printer-examples.test.ts @@ -12,23 +12,25 @@ import { bridge } from "@stackables/bridge-core"; * ============================================================================ */ -describe("formatBridge - full examples", () => { - test("simple tool declaration", () => { - const input = bridge` +describe( + "formatBridge - full examples", + () => { + test("simple tool declaration", () => { + const input = bridge` version 1.5 tool geo from std.httpCall `; - const expected = bridge` + const expected = bridge` version 1.5 tool geo from std.httpCall `; - assert.equal(formatSnippet(input), expected); - }); + assert.equal(formatSnippet(input), expected); + }); - test("tool with body", () => { - const input = bridge` + test("tool with body", () => { + const input = bridge` version 1.5 tool geo from std.httpCall{ @@ -36,7 +38,7 @@ describe("formatBridge - full examples", () => { .method=GET } `; - const expected = bridge` + const expected = bridge` version 1.5 tool geo from std.httpCall { @@ -45,11 +47,11 @@ describe("formatBridge - full examples", () => { } `; - assert.equal(formatSnippet(input), expected); - }); + assert.equal(formatSnippet(input), expected); + }); - test("bridge block with assignments", () => { - const input = bridge` + test("bridge block with assignments", () => { + const input = bridge` version 1.5 bridge Query.test{ @@ -58,7 +60,7 @@ describe("formatBridge - full examples", () => { o.value<-i.value } `; - const expected = bridge` + const expected = bridge` version 1.5 bridge Query.test { @@ -69,25 +71,25 @@ describe("formatBridge - full examples", () => { } `; - assert.equal(formatSnippet(input), expected); - }); + assert.equal(formatSnippet(input), expected); + }); - test("define block", () => { - const input = `define myHelper{ + test("define block", () => { + const input = `define myHelper{ with input as i o.x<-i.y }`; - const expected = `define myHelper { + const expected = `define myHelper { with input as i o.x <- i.y } `; - assert.equal(formatSnippet(input), expected); - }); + assert.equal(formatSnippet(input), expected); + }); - test("bridge with comment, tool handles, and pipes", () => { - const input = bridge` + test("bridge with comment, tool handles, and pipes", () => { + const input = bridge` version 1.5 bridge Query.greet { @@ -103,7 +105,7 @@ o.x<-i.y o.lower <- lc: i.name } `; - const expected = bridge` + const expected = bridge` version 1.5 bridge Query.greet { @@ -119,11 +121,11 @@ o.x<-i.y } `; - assert.equal(formatSnippet(input), expected); - }); + assert.equal(formatSnippet(input), expected); + }); - test("ternary expressions preserve formatting", () => { - const input = bridge` + test("ternary expressions preserve formatting", () => { + const input = bridge` version 1.5 bridge Query.pricing { @@ -141,12 +143,12 @@ o.x<-i.y } `; - // Should not change - assert.equal(formatSnippet(input), input); - }); + // Should not change + assert.equal(formatSnippet(input), input); + }); - test("blank line between top-level blocks", () => { - const input = bridge` + test("blank line between top-level blocks", () => { + const input = bridge` version 1.5 tool geo from std.httpCall @@ -161,7 +163,7 @@ o.x<-i.y with input as i } `; - const expected = bridge` + const expected = bridge` version 1.5 tool geo from std.httpCall @@ -181,22 +183,23 @@ o.x<-i.y } `; - assert.equal(formatSnippet(input), expected); - }); + assert.equal(formatSnippet(input), expected); + }); - test("not operator preserves space", () => { - const input = `o.requireMFA <- not i.verified + test("not operator preserves space", () => { + const input = `o.requireMFA <- not i.verified `; - // Should not change - assert.equal(formatSnippet(input), input); - }); + // Should not change + assert.equal(formatSnippet(input), input); + }); - test("blank lines between comments are preserved", () => { - const input = `#asdasd + test("blank lines between comments are preserved", () => { + const input = `#asdasd #sdasdsd `; - // Should not change - assert.equal(formatSnippet(input), input); - }); -}); + // Should not change + assert.equal(formatSnippet(input), input); + }); + }, +); diff --git a/packages/bridge-parser/test/bridge-printer.test.ts b/packages/bridge-parser/test/bridge-printer.test.ts index 4420d9ec..a716f289 100644 --- a/packages/bridge-parser/test/bridge-printer.test.ts +++ b/packages/bridge-parser/test/bridge-printer.test.ts @@ -14,245 +14,265 @@ import { bridge } from "@stackables/bridge-core"; * ============================================================================ */ -describe("formatBridge - spacing", () => { - test("operator spacing: '<-' gets spaces", () => { - const input = `o.x<-i.y`; - const expected = `o.x <- i.y\n`; - assert.equal(formatSnippet(input), expected); - }); - - test("operator spacing: '=' gets spaces", () => { - const input = `.baseUrl="https://example.com"`; - const expected = `.baseUrl = "https://example.com"\n`; - assert.equal(formatSnippet(input), expected); - }); - - test("brace spacing: space before '{'", () => { - const input = `bridge Query.test{`; - const expected = `bridge Query.test {\n`; - assert.equal(formatSnippet(input), expected); - }); - - test("no space after '.' in paths", () => { - const input = `o.foo.bar`; - const expected = `o.foo.bar\n`; - assert.equal(formatSnippet(input), expected); - }); - - test("no space around '.' even with 'from' as property name", () => { - const input = `c.from.station.id`; - const expected = `c.from.station.id\n`; - assert.equal(formatSnippet(input), expected); - }); - - test("'from' keyword gets spaces when used as keyword", () => { - const input = `tool geo from std.httpCall`; - const expected = `tool geo from std.httpCall\n`; - assert.equal(formatSnippet(input), expected); - }); - - test("safe navigation '?.' has no spaces", () => { - const input = `o.x?.y`; - const expected = `o.x?.y\n`; - assert.equal(formatSnippet(input), expected); - }); - - test("parentheses: no space inside", () => { - const input = `foo( a , b )`; - const expected = `foo(a, b)\n`; - assert.equal(formatSnippet(input), expected); - }); - - test("brackets: no space inside", () => { - const input = `arr[ 0 ]`; - const expected = `arr[0]\n`; - assert.equal(formatSnippet(input), expected); - }); -}); - -describe("formatBridge - indentation", () => { - test("bridge body is indented 2 spaces", () => { - const input = `bridge Query.test { +describe( + "formatBridge - spacing", + () => { + test("operator spacing: '<-' gets spaces", () => { + const input = `o.x<-i.y`; + const expected = `o.x <- i.y\n`; + assert.equal(formatSnippet(input), expected); + }); + + test("operator spacing: '=' gets spaces", () => { + const input = `.baseUrl="https://example.com"`; + const expected = `.baseUrl = "https://example.com"\n`; + assert.equal(formatSnippet(input), expected); + }); + + test("brace spacing: space before '{'", () => { + const input = `bridge Query.test{`; + const expected = `bridge Query.test {\n`; + assert.equal(formatSnippet(input), expected); + }); + + test("no space after '.' in paths", () => { + const input = `o.foo.bar`; + const expected = `o.foo.bar\n`; + assert.equal(formatSnippet(input), expected); + }); + + test("no space around '.' even with 'from' as property name", () => { + const input = `c.from.station.id`; + const expected = `c.from.station.id\n`; + assert.equal(formatSnippet(input), expected); + }); + + test("'from' keyword gets spaces when used as keyword", () => { + const input = `tool geo from std.httpCall`; + const expected = `tool geo from std.httpCall\n`; + assert.equal(formatSnippet(input), expected); + }); + + test("safe navigation '?.' has no spaces", () => { + const input = `o.x?.y`; + const expected = `o.x?.y\n`; + assert.equal(formatSnippet(input), expected); + }); + + test("parentheses: no space inside", () => { + const input = `foo( a , b )`; + const expected = `foo(a, b)\n`; + assert.equal(formatSnippet(input), expected); + }); + + test("brackets: no space inside", () => { + const input = `arr[ 0 ]`; + const expected = `arr[0]\n`; + assert.equal(formatSnippet(input), expected); + }); + }, +); + +describe( + "formatBridge - indentation", + () => { + test("bridge body is indented 2 spaces", () => { + const input = `bridge Query.test { with input as i o.x <- i.y }`; - const expected = `bridge Query.test { + const expected = `bridge Query.test { with input as i o.x <- i.y } `; - assert.equal(formatSnippet(input), expected); - }); + assert.equal(formatSnippet(input), expected); + }); - test("nested braces increase indentation", () => { - const input = `bridge Query.test { + test("nested braces increase indentation", () => { + const input = `bridge Query.test { on error { .retry = true } }`; - const expected = `bridge Query.test { + const expected = `bridge Query.test { on error { .retry = true } } `; - assert.equal(formatSnippet(input), expected); - }); -}); - -describe("formatBridge - blank lines", () => { - test("blank line after version", () => { - const input = bridge` + assert.equal(formatSnippet(input), expected); + }); + }, +); + +describe( + "formatBridge - blank lines", + () => { + test("blank line after version", () => { + const input = bridge` version 1.5 tool geo from std.httpCall `; - const expected = bridge` + const expected = bridge` version 1.5 tool geo from std.httpCall `; - assert.equal(formatSnippet(input), expected); - }); + assert.equal(formatSnippet(input), expected); + }); - test("preserve single blank line (user grouping)", () => { - const input = `bridge Query.test { + test("preserve single blank line (user grouping)", () => { + const input = `bridge Query.test { with input as i o.x <- i.y }`; - const expected = `bridge Query.test { + const expected = `bridge Query.test { with input as i o.x <- i.y } `; - assert.equal(formatSnippet(input), expected); - }); + assert.equal(formatSnippet(input), expected); + }); - test("collapse multiple blank lines to one", () => { - const input = `bridge Query.test { + test("collapse multiple blank lines to one", () => { + const input = `bridge Query.test { with input as i o.x <- i.y }`; - const expected = `bridge Query.test { + const expected = `bridge Query.test { with input as i o.x <- i.y } `; - assert.equal(formatSnippet(input), expected); - }); + assert.equal(formatSnippet(input), expected); + }); - test("at least a single blank line between wires", () => { - const input = `bridge Query.test { + test("at least a single blank line between wires", () => { + const input = `bridge Query.test { with input as i o.x <- i.y }`; - const expected = `bridge Query.test { + const expected = `bridge Query.test { with input as i o.x <- i.y } `; - assert.equal(formatSnippet(input), expected); - }); -}); - -describe("formatBridge - comments", () => { - test("standalone comment preserved", () => { - const input = `# This is a comment + assert.equal(formatSnippet(input), expected); + }); + }, +); + +describe( + "formatBridge - comments", + () => { + test("standalone comment preserved", () => { + const input = `# This is a comment tool geo from std.httpCall`; - const expected = `# This is a comment + const expected = `# This is a comment tool geo from std.httpCall `; - assert.equal(formatSnippet(input), expected); - }); + assert.equal(formatSnippet(input), expected); + }); - test("inline comment stays on same line", () => { - const input = `tool geo from std.httpCall # inline`; - const expected = `tool geo from std.httpCall # inline\n`; - assert.equal(formatSnippet(input), expected); - }); + test("inline comment stays on same line", () => { + const input = `tool geo from std.httpCall # inline`; + const expected = `tool geo from std.httpCall # inline\n`; + assert.equal(formatSnippet(input), expected); + }); - test("trailing comment on brace line", () => { - const input = `bridge Query.test { # comment + test("trailing comment on brace line", () => { + const input = `bridge Query.test { # comment }`; - const expected = `bridge Query.test { # comment + const expected = `bridge Query.test { # comment } `; - assert.equal(formatSnippet(input), expected); - }); -}); - -describe("formatBridge - on error blocks", () => { - test("on error with simple value", () => { - const input = `on error=null`; - const expected = `on error = null\n`; - assert.equal(formatSnippet(input), expected); - }); - - test("on error with JSON object stays on one line", () => { - const input = `on error = { "connections": [] }`; - const expected = `on error = {"connections": []}\n`; - assert.equal(formatSnippet(input), expected); - }); -}); - -describe("prettyPrintToSource - edge cases", () => { - test("empty input", () => { - assert.equal(formatSnippet(""), ""); - }); - - test("whitespace only input", () => { - assert.equal(formatSnippet(" \n \n"), ""); - }); - - test("throws on lexer errors", () => { - const invalid = `bridge @invalid { }`; - assert.throws(() => prettyPrintToSource(invalid)); - }); - - test("comment-only file", () => { - const input = `# comment 1 + assert.equal(formatSnippet(input), expected); + }); + }, +); + +describe( + "formatBridge - on error blocks", + () => { + test("on error with simple value", () => { + const input = `on error=null`; + const expected = `on error = null\n`; + assert.equal(formatSnippet(input), expected); + }); + + test("on error with JSON object stays on one line", () => { + const input = `on error = { "connections": [] }`; + const expected = `on error = {"connections": []}\n`; + assert.equal(formatSnippet(input), expected); + }); + }, +); + +describe( + "prettyPrintToSource - edge cases", + () => { + test("empty input", () => { + assert.equal(formatSnippet(""), ""); + }); + + test("whitespace only input", () => { + assert.equal(formatSnippet(" \n \n"), ""); + }); + + test("throws on lexer errors", () => { + const invalid = `bridge @invalid { }`; + assert.throws(() => prettyPrintToSource(invalid)); + }); + + test("comment-only file", () => { + const input = `# comment 1 # comment 2`; - const expected = `# comment 1 + const expected = `# comment 1 # comment 2 `; - assert.equal(formatSnippet(input), expected); - }); -}); - -describe("prettyPrintToSource - safety and options", () => { - test("is idempotent", () => { - const input = bridge` + assert.equal(formatSnippet(input), expected); + }); + }, +); + +describe( + "prettyPrintToSource - safety and options", + () => { + test("is idempotent", () => { + const input = bridge` version 1.5 bridge Query.test { with input as i o.x<-i.y } `; - const once = prettyPrintToSource(input); - const twice = prettyPrintToSource(once); - assert.equal(twice, once); - }); + const once = prettyPrintToSource(input); + const twice = prettyPrintToSource(once); + assert.equal(twice, once); + }); - test("throws on syntax errors", () => { - assert.throws(() => prettyPrintToSource(`bridge Query.test {`)); - }); + test("throws on syntax errors", () => { + assert.throws(() => prettyPrintToSource(`bridge Query.test {`)); + }); - test("uses tabSize when insertSpaces is true", () => { - const input = bridge` + test("uses tabSize when insertSpaces is true", () => { + const input = bridge` version 1.5 bridge Query.test { with input as i o.x<-i.y } `; - const expected = bridge` + const expected = bridge` version 1.5 bridge Query.test { @@ -262,21 +282,21 @@ describe("prettyPrintToSource - safety and options", () => { } `; - assert.equal( - prettyPrintToSource(input, { tabSize: 4, insertSpaces: true }), - expected, - ); - }); - - test("uses tabs when insertSpaces is false", () => { - const input = bridge` + assert.equal( + prettyPrintToSource(input, { tabSize: 4, insertSpaces: true }), + expected, + ); + }); + + test("uses tabs when insertSpaces is false", () => { + const input = bridge` version 1.5 bridge Query.test { with input as i o.x<-i.y } `; - const expected = bridge` + const expected = bridge` version 1.5 bridge Query.test { @@ -286,23 +306,26 @@ describe("prettyPrintToSource - safety and options", () => { } `; - assert.equal( - prettyPrintToSource(input, { tabSize: 4, insertSpaces: false }), - expected, - ); - }); -}); - -describe("formatBridge - line splitting and joining", () => { - test("content after '{' moves to new indented line", () => { - const input = `bridge Query.greet { + assert.equal( + prettyPrintToSource(input, { tabSize: 4, insertSpaces: false }), + expected, + ); + }); + }, +); + +describe( + "formatBridge - line splitting and joining", + () => { + test("content after '{' moves to new indented line", () => { + const input = `bridge Query.greet { with output as o o {.message <- i.name .upper <- uc:i.name } }`; - const expected = `bridge Query.greet { + const expected = `bridge Query.greet { with output as o o { @@ -311,11 +334,11 @@ describe("formatBridge - line splitting and joining", () => { } } `; - assert.equal(formatSnippet(input), expected); - }); + assert.equal(formatSnippet(input), expected); + }); - test("standalone 'as', identifier, and '{' merge with previous line", () => { - const input = `bridge Query.test { + test("standalone 'as', identifier, and '{' merge with previous line", () => { + const input = `bridge Query.test { with output as o o <- api.items[] @@ -325,7 +348,7 @@ describe("formatBridge - line splitting and joining", () => { .id <- c.id } }`; - const expected = `bridge Query.test { + const expected = `bridge Query.test { with output as o o <- api.items[] as c { @@ -333,31 +356,31 @@ describe("formatBridge - line splitting and joining", () => { } } `; - assert.equal(formatSnippet(input), expected); - }); + assert.equal(formatSnippet(input), expected); + }); - test("'as' in 'with' declaration is not merged incorrectly", () => { - // with lines should stay separate - const input = `bridge Query.test { + test("'as' in 'with' declaration is not merged incorrectly", () => { + // with lines should stay separate + const input = `bridge Query.test { with input as i with output as o }`; - const expected = `bridge Query.test { + const expected = `bridge Query.test { with input as i with output as o } `; - assert.equal(formatSnippet(input), expected); - }); + assert.equal(formatSnippet(input), expected); + }); - test("adjacent blocks on same line get separated with blank line", () => { - // }tool on same line should split into separate lines with blank line - const input = `tool a from std.httpCall { + test("adjacent blocks on same line get separated with blank line", () => { + // }tool on same line should split into separate lines with blank line + const input = `tool a from std.httpCall { .path = "/a" }tool b from std.httpCall { .path = "/b" }`; - const expected = `tool a from std.httpCall { + const expected = `tool a from std.httpCall { .path = "/a" } @@ -365,6 +388,7 @@ tool b from std.httpCall { .path = "/b" } `; - assert.equal(formatSnippet(input), expected); - }); -}); + assert.equal(formatSnippet(input), expected); + }); + }, +); diff --git a/packages/bridge-parser/test/expressions-parser.test.ts b/packages/bridge-parser/test/expressions-parser.test.ts index d747f584..2fe8eb7e 100644 --- a/packages/bridge-parser/test/expressions-parser.test.ts +++ b/packages/bridge-parser/test/expressions-parser.test.ts @@ -5,11 +5,50 @@ import { serializeBridge, } from "@stackables/bridge-parser"; import { bridge } from "@stackables/bridge-core"; - -// ── Parser desugaring tests ───────────────────────────────────────────────── +import { flatWires } from "./utils/parse-test-utils.ts"; + +// -- Helper: find a binary/unary expression in the body wires -- + +function exprContainsOp(expr: any, op: string): boolean { + if (!expr) return false; + if (expr.type === "binary" && expr.op === op) return true; + if (expr.type === "unary" && expr.op === op) return true; + if (expr.type === "binary") + return exprContainsOp(expr.left, op) || exprContainsOp(expr.right, op); + if (expr.type === "and") + return exprContainsOp(expr.left, op) || exprContainsOp(expr.right, op); + if (expr.type === "or") + return exprContainsOp(expr.left, op) || exprContainsOp(expr.right, op); + if (expr.type === "unary") return exprContainsOp(expr.operand, op); + if (expr.type === "ternary") + return ( + exprContainsOp(expr.cond, op) || + exprContainsOp(expr.then, op) || + exprContainsOp(expr.else, op) + ); + return false; +} + +function findBinaryOp( + doc: ReturnType, + op: string, +): boolean { + const instr = doc.instructions.find((i) => i.kind === "bridge")!; + const wires = flatWires(instr.body); + return wires.some((w) => exprContainsOp(w.sources[0]?.expr, op)); +} + +function getOutputExpr(doc: ReturnType): any { + const instr = doc.instructions.find((i) => i.kind === "bridge")!; + const wires = flatWires(instr.body); + const outputWire = wires.find((w) => w.target.path.includes("result")); + return outputWire?.sources[0]?.expr; +} + +// -- Parser desugaring tests -- describe("expressions: parser desugaring", () => { - test("o.cents <- i.dollars * 100 — desugars into synthetic tool wires", () => { + test("o.cents <- i.dollars * 100 -- produces binary expression", () => { const doc = parseBridge(bridge` version 1.5 bridge Query.convert { @@ -19,22 +58,15 @@ describe("expressions: parser desugaring", () => { o.cents <- i.dollars * 100 } `); - const instr = doc.instructions.find((i) => i.kind === "bridge")!; - assert.ok(!instr.wires.some((w) => "expr" in w), "no ExprWire in output"); - assert.ok(instr.pipeHandles!.length > 0, "has pipe handles"); - const exprHandle = instr.pipeHandles!.find((ph) => - ph.handle.startsWith("__expr_"), - ); - assert.ok(exprHandle, "has __expr_ pipe handle"); - assert.equal(exprHandle.baseTrunk.field, "multiply"); + assert.ok(findBinaryOp(doc, "mul"), "should have mul binary expression"); }); - test("all operators desugar to correct tool names", () => { + test("all operators produce correct expression nodes", () => { const ops: Record = { - "*": "multiply", - "/": "divide", + "*": "mul", + "/": "div", "+": "add", - "-": "subtract", + "-": "sub", "==": "eq", "!=": "neq", ">": "gt", @@ -42,7 +74,7 @@ describe("expressions: parser desugaring", () => { "<": "lt", "<=": "lte", }; - for (const [op, fn] of Object.entries(ops)) { + for (const [op, exprOp] of Object.entries(ops)) { const doc = parseBridge(bridge` version 1.5 bridge Query.test { @@ -52,12 +84,7 @@ describe("expressions: parser desugaring", () => { o.result <- i.value ${op} 1 } `); - const instr = doc.instructions.find((i) => i.kind === "bridge")!; - const exprHandle = instr.pipeHandles!.find((ph) => - ph.handle.startsWith("__expr_"), - ); - assert.ok(exprHandle, `${op} should create a pipe handle`); - assert.equal(exprHandle.baseTrunk.field, fn, `${op} → ${fn}`); + assert.ok(findBinaryOp(doc, exprOp), `${op} should produce ${exprOp}`); } }); @@ -71,17 +98,8 @@ describe("expressions: parser desugaring", () => { o.result <- i.times * 5 / 10 } `); - const instr = doc.instructions.find((i) => i.kind === "bridge")!; - const exprHandles = instr.pipeHandles!.filter((ph) => - ph.handle.startsWith("__expr_"), - ); - assert.equal( - exprHandles.length, - 2, - "two synthetic tools for chained expression", - ); - assert.equal(exprHandles[0].baseTrunk.field, "multiply"); - assert.equal(exprHandles[1].baseTrunk.field, "divide"); + assert.ok(findBinaryOp(doc, "mul"), "has mul"); + assert.ok(findBinaryOp(doc, "div"), "has div"); }); test("chained expression: i.times * 2 > 6", () => { @@ -94,13 +112,8 @@ describe("expressions: parser desugaring", () => { o.result <- i.times * 2 > 6 } `); - const instr = doc.instructions.find((i) => i.kind === "bridge")!; - const exprHandles = instr.pipeHandles!.filter((ph) => - ph.handle.startsWith("__expr_"), - ); - assert.equal(exprHandles.length, 2); - assert.equal(exprHandles[0].baseTrunk.field, "multiply"); - assert.equal(exprHandles[1].baseTrunk.field, "gt"); + assert.ok(findBinaryOp(doc, "mul"), "has mul"); + assert.ok(findBinaryOp(doc, "gt"), "has gt"); }); test("two source refs: i.price * i.qty", () => { @@ -113,15 +126,7 @@ describe("expressions: parser desugaring", () => { o.total <- i.price * i.qty } `); - const instr = doc.instructions.find((i) => i.kind === "bridge")!; - const bWire = instr.wires.find( - (w) => - w.sources[0]?.expr.type === "ref" && - w.to.path.length === 1 && - w.to.path[0] === "b", - ); - assert.ok(bWire, "should have a .b wire"); - assert.ok(bWire!.sources[0]?.expr.type === "ref"); + assert.ok(findBinaryOp(doc, "mul"), "has mul expression"); }); test("expression in array mapping element", () => { @@ -138,16 +143,11 @@ describe("expressions: parser desugaring", () => { } } `); - const instr = doc.instructions.find((i) => i.kind === "bridge")!; - const exprHandle = instr.pipeHandles!.find((ph) => - ph.handle.startsWith("__expr_"), - ); - assert.ok(exprHandle, "should have expression pipe handle"); - assert.equal(exprHandle.baseTrunk.field, "multiply"); + assert.ok(findBinaryOp(doc, "mul"), "has mul expression in array element"); }); }); -// ── Round-trip serialization tests ────────────────────────────────────────── +// -- Round-trip serialization tests -- describe("expressions: round-trip serialization", () => { test("multiply expression serializes and re-parses", () => { @@ -166,14 +166,11 @@ describe("expressions: round-trip serialization", () => { serialized.includes("i.dollars * 100"), `should contain expression: ${serialized}`, ); - const reparsed = parseBridge(serialized); - const instr = reparsed.instructions.find((i) => i.kind === "bridge")!; - const exprHandle = instr.pipeHandles!.find((ph) => - ph.handle.startsWith("__expr_"), + assert.ok( + findBinaryOp(reparsed, "mul"), + "re-parsed should contain mul expression", ); - assert.ok(exprHandle, "re-parsed should contain synthetic tool"); - assert.equal(exprHandle.baseTrunk.field, "multiply"); }); test("comparison expression round-trips", () => { @@ -225,10 +222,10 @@ describe("expressions: round-trip serialization", () => { }); }); -// ── Operator precedence: parser ─────────────────────────────────────────── +// -- Operator precedence: parser -- describe("expressions: operator precedence (parser)", () => { - test("i.base + i.tax * 2 — multiplication before addition", () => { + test("i.base + i.tax * 2 -- multiplication before addition", () => { const doc = parseBridge(bridge` version 1.5 bridge Query.calc { @@ -239,12 +236,17 @@ describe("expressions: operator precedence (parser)", () => { } `); const instr = doc.instructions.find((i) => i.kind === "bridge")!; - const exprHandles = instr.pipeHandles!.filter((ph) => - ph.handle.startsWith("__expr_"), + const wires = flatWires(instr.body); + const outputWire = wires.find((w) => w.target.path.includes("total")); + assert.ok(outputWire, "should have output wire"); + const expr = outputWire!.sources[0]?.expr; + assert.equal(expr.type, "binary"); + assert.equal(expr.op, "add", "outer op should be add"); + assert.equal( + expr.right.type === "binary" ? expr.right.op : null, + "mul", + "inner op should be mul", ); - assert.equal(exprHandles.length, 2, "two synthetic forks"); - assert.equal(exprHandles[0].baseTrunk.field, "multiply", "multiply first"); - assert.equal(exprHandles[1].baseTrunk.field, "add", "add second"); }); test("precedence round-trip: i.base + i.tax * 2 serializes correctly", () => { @@ -267,15 +269,11 @@ describe("expressions: operator precedence (parser)", () => { }); }); -// ── Boolean logic: parser desugaring ────────────────────────────────────────── +// -- Boolean logic: parser desugaring -- describe("boolean logic: parser desugaring", () => { - test("and / or desugar to condAnd/condOr wires", () => { - const boolOps: Record = { - and: "__and", - or: "__or", - }; - for (const [op, fn] of Object.entries(boolOps)) { + test("and / or produce correct expression types", () => { + for (const op of ["and", "or"]) { const doc = parseBridge(bridge` version 1.5 bridge Query.test { @@ -285,16 +283,13 @@ describe("boolean logic: parser desugaring", () => { o.result <- i.a ${op} i.b } `); - const instr = doc.instructions.find((i) => i.kind === "bridge")!; - const exprHandle = instr.pipeHandles!.find((ph) => - ph.handle.startsWith("__expr_"), - ); - assert.ok(exprHandle, `${op}: has __expr_ pipe handle`); - assert.equal(exprHandle.baseTrunk.field, fn, `${op}: maps to ${fn}`); + const expr = getOutputExpr(doc); + assert.ok(expr, `${op}: has output expr`); + assert.equal(expr.type, op, `${op}: expr type`); } }); - test("not prefix desugars to not tool fork", () => { + test("not prefix produces unary expression", () => { const doc = parseBridge(bridge` version 1.5 bridge Query.test { @@ -304,14 +299,13 @@ describe("boolean logic: parser desugaring", () => { o.result <- not i.trusted } `); - const instr = doc.instructions.find((i) => i.kind === "bridge")!; - const exprHandle = instr.pipeHandles!.find( - (ph) => ph.baseTrunk.field === "not", - ); - assert.ok(exprHandle, "has not pipe handle"); + const expr = getOutputExpr(doc); + assert.ok(expr); + assert.equal(expr.type, "unary"); + assert.equal(expr.op, "not"); }); - test('combined: (a > 18 and b) or c == "ADMIN"', () => { + test("combined boolean expression", () => { const doc = parseBridge(bridge` version 1.5 bridge Query.test { @@ -321,23 +315,13 @@ describe("boolean logic: parser desugaring", () => { o.result <- i.age > 18 and i.verified or i.role == "ADMIN" } `); - const instr = doc.instructions.find((i) => i.kind === "bridge")!; - const exprHandles = instr.pipeHandles!.filter((ph) => - ph.handle.startsWith("__expr_"), - ); - assert.ok( - exprHandles.length >= 4, - `has >= 4 expr handles, got ${exprHandles.length}`, - ); - const fields = exprHandles.map((ph) => ph.baseTrunk.field); - assert.ok(fields.includes("gt"), "has gt"); - assert.ok(fields.includes("__and"), "has __and"); - assert.ok(fields.includes("eq"), "has eq"); - assert.ok(fields.includes("__or"), "has __or"); + const expr = getOutputExpr(doc); + assert.ok(expr, "has output expr"); + assert.ok(exprContainsOp(expr, "gt"), "has gt in tree"); }); }); -// ── Boolean logic: serializer round-trip ────────────────────────────────────── +// -- Boolean logic: serializer round-trip -- describe("boolean logic: serializer round-trip", () => { test("and expression round-trips", () => { @@ -398,10 +382,10 @@ describe("boolean logic: serializer round-trip", () => { }); }); -// ── Parenthesized expressions: parser desugaring ───────────────────────────── +// -- Parenthesized expressions: parser desugaring -- describe("parenthesized expressions: parser desugaring", () => { - test("(A and B) or C — groups correctly", () => { + test("(A and B) or C -- groups correctly", () => { const doc = parseBridge(bridge` version 1.5 bridge Query.test { @@ -411,17 +395,12 @@ describe("parenthesized expressions: parser desugaring", () => { o.result <- (i.a and i.b) or i.c } `); - const instr = doc.instructions.find((i) => i.kind === "bridge")!; - const exprHandles = instr.pipeHandles!.filter((ph) => - ph.handle.startsWith("__expr_"), - ); - assert.ok(exprHandles.length >= 2, `has >= 2 expr handles`); - const fields = exprHandles.map((ph) => ph.baseTrunk.field); - assert.ok(fields.includes("__and"), "has __and"); - assert.ok(fields.includes("__or"), "has __or"); + const expr = getOutputExpr(doc); + assert.equal(expr.type, "or", "outer should be or"); + assert.equal(expr.left.type, "and", "left should be and"); }); - test("A or (B and C) — groups correctly", () => { + test("A or (B and C) -- groups correctly", () => { const doc = parseBridge(bridge` version 1.5 bridge Query.test { @@ -431,17 +410,12 @@ describe("parenthesized expressions: parser desugaring", () => { o.result <- i.a or (i.b and i.c) } `); - const instr = doc.instructions.find((i) => i.kind === "bridge")!; - const exprHandles = instr.pipeHandles!.filter((ph) => - ph.handle.startsWith("__expr_"), - ); - assert.ok(exprHandles.length >= 2, `has >= 2 expr handles`); - const fields = exprHandles.map((ph) => ph.baseTrunk.field); - assert.ok(fields.includes("__and"), "has __and"); - assert.ok(fields.includes("__or"), "has __or"); + const expr = getOutputExpr(doc); + assert.equal(expr.type, "or", "outer should be or"); + assert.equal(expr.right.type, "and", "right should be and"); }); - test("not (A and B) — not wraps grouped expr", () => { + test("not (A and B) -- not wraps grouped expr", () => { const doc = parseBridge(bridge` version 1.5 bridge Query.test { @@ -451,16 +425,13 @@ describe("parenthesized expressions: parser desugaring", () => { o.result <- not (i.a and i.b) } `); - const instr = doc.instructions.find((i) => i.kind === "bridge")!; - const exprHandles = instr.pipeHandles!.filter((ph) => - ph.handle.startsWith("__expr_"), - ); - const fields = exprHandles.map((ph) => ph.baseTrunk.field); - assert.ok(fields.includes("__and"), "has __and"); - assert.ok(fields.includes("not"), "has not"); + const expr = getOutputExpr(doc); + assert.equal(expr.type, "unary", "outer should be unary"); + assert.equal(expr.op, "not"); + assert.equal(expr.operand.type, "and", "operand should be and"); }); - test("(i.price + i.discount) * i.qty — math with parens", () => { + test("(i.price + i.discount) * i.qty -- math with parens", () => { const doc = parseBridge(bridge` version 1.5 bridge Query.test { @@ -470,17 +441,18 @@ describe("parenthesized expressions: parser desugaring", () => { o.result <- (i.price + i.discount) * i.qty } `); - const instr = doc.instructions.find((i) => i.kind === "bridge")!; - const exprHandles = instr.pipeHandles!.filter((ph) => - ph.handle.startsWith("__expr_"), + const expr = getOutputExpr(doc); + assert.equal(expr.type, "binary"); + assert.equal(expr.op, "mul", "outer should be mul"); + assert.equal( + expr.left.type === "binary" ? expr.left.op : null, + "add", + "inner should be add", ); - const fields = exprHandles.map((ph) => ph.baseTrunk.field); - assert.ok(fields.includes("add"), "has add (from parens)"); - assert.ok(fields.includes("multiply"), "has multiply"); }); }); -// ── Parenthesized expressions: serializer round-trip ────────────────────────── +// -- Parenthesized expressions: serializer round-trip -- describe("parenthesized expressions: serializer round-trip", () => { test("(A + B) * C round-trips with parentheses", () => { @@ -503,7 +475,7 @@ describe("parenthesized expressions: serializer round-trip", () => { assert.ok(reparsed.instructions.length > 0, "reparsed successfully"); }); - test("A or (B and C) round-trips correctly (parens optional since and binds tighter)", () => { + test("A or (B and C) round-trips correctly", () => { const src = bridge` version 1.5 @@ -524,7 +496,77 @@ describe("parenthesized expressions: serializer round-trip", () => { }); }); -// ── Keyword strings in serializer ───────────────────────────────────────────── +// -- Expressions in coalesce (|| / ??) fallback positions -- + +describe("expressions in coalesce fallback positions", () => { + test("binary expression in ?? fallback position parses correctly", () => { + const doc = parseBridge(bridge` + version 1.5 + bridge Query.test { + with input as i + with output as o + o.price <- i.a * 1 ?? i.b * 1 + } + `); + const instr = doc.instructions.find((i) => i.kind === "bridge")!; + const wire = flatWires(instr.body).find( + (w) => w.target.path[0] === "price", + )!; + assert.ok(wire, "has price wire"); + assert.equal( + wire.sources.length, + 2, + "has two sources (primary + fallback)", + ); + assert.equal(wire.sources[1]!.gate, "nullish", "fallback gate is nullish"); + assert.equal( + wire.sources[1]!.expr.type, + "binary", + "fallback expr is binary", + ); + }); + + test("binary expression in || fallback position parses correctly", () => { + const doc = parseBridge(bridge` + version 1.5 + bridge Query.test { + with input as i + with output as o + o.total <- i.subtotal || i.base + i.fee + } + `); + const instr = doc.instructions.find((i) => i.kind === "bridge")!; + const wire = flatWires(instr.body).find( + (w) => w.target.path[0] === "total", + )!; + assert.ok(wire, "has total wire"); + assert.equal(wire.sources[1]!.gate, "falsy", "fallback gate is falsy"); + assert.equal( + wire.sources[1]!.expr.type, + "binary", + "fallback expr is binary", + ); + }); + + test("multiple fallbacks with expressions all parse", () => { + const doc = parseBridge(bridge` + version 1.5 + bridge Query.test { + with input as i + with output as o + o.val <- i.a ?? i.b * 2 ?? i.c + 1 + } + `); + const instr = doc.instructions.find((i) => i.kind === "bridge")!; + const wire = flatWires(instr.body).find((w) => w.target.path[0] === "val")!; + assert.ok(wire, "has val wire"); + assert.equal(wire.sources.length, 3, "has three sources"); + assert.equal(wire.sources[1]!.expr.type, "binary"); + assert.equal(wire.sources[2]!.expr.type, "binary"); + }); +}); + +// -- Keyword strings in serializer -- describe("serializeBridge: keyword strings are quoted", () => { const keywords = [ @@ -571,13 +613,11 @@ describe("serializeBridge: keyword strings are quoted", () => { `Expected "${kw}" to be quoted in: ${serialized}`, ); const reparsed = parseBridge(serialized); - const instr = reparsed.instructions.find( - (i) => i.kind === "bridge", - ) as any; - const wire = instr.wires.find( - (w: any) => + const instr = reparsed.instructions.find((i) => i.kind === "bridge")!; + const wire = flatWires(instr.body).find( + (w) => w.sources?.[0]?.expr?.type === "literal" && - w.to?.path?.[0] === "result", + w.target?.path?.[0] === "result", ); assert.equal( wire?.sources[0]?.expr.type === "literal" diff --git a/packages/bridge-parser/test/force-wire-parser.test.ts b/packages/bridge-parser/test/force-wire-parser.test.ts index b17bcdf3..3cbfd8e7 100644 --- a/packages/bridge-parser/test/force-wire-parser.test.ts +++ b/packages/bridge-parser/test/force-wire-parser.test.ts @@ -6,7 +6,11 @@ import { } from "@stackables/bridge-parser"; import type { Bridge } from "@stackables/bridge-core"; import { SELF_MODULE } from "@stackables/bridge-core"; -import { assertDeepStrictEqualIgnoringLoc } from "./utils/parse-test-utils.ts"; +import { + assertDeepStrictEqualIgnoringLoc, + flatForces, + flatWires, +} from "./utils/parse-test-utils.ts"; import { bridge } from "@stackables/bridge-core"; // ── Parser: `force ` creates forces entries ───────────────────────── @@ -27,7 +31,7 @@ describe("parseBridge: force ", () => { } `).instructions.find((i): i is Bridge => i.kind === "bridge")!; - assert.equal(instr.forces, undefined); + assert.equal(flatForces(instr.body).length, 0); }); test("force statement creates a forces entry", () => { @@ -44,12 +48,13 @@ describe("parseBridge: force ", () => { } `).instructions.find((i): i is Bridge => i.kind === "bridge")!; - assert.ok(instr.forces, "should have forces"); - assert.equal(instr.forces!.length, 1); - assert.equal(instr.forces![0].handle, "lg"); - assert.equal(instr.forces![0].module, "logger"); - assert.equal(instr.forces![0].field, "log"); - assert.equal(instr.forces![0].instance, 1); + const forces = flatForces(instr.body); + assert.ok(forces.length > 0, "should have forces"); + assert.equal(forces.length, 1); + assert.equal(forces[0].handle, "lg"); + assert.equal(forces[0].module, "logger"); + assert.equal(forces[0].field, "log"); + assert.equal(forces[0].instance, 1); }); test("force and regular wires coexist", () => { @@ -70,10 +75,11 @@ describe("parseBridge: force ", () => { } `).instructions.find((i): i is Bridge => i.kind === "bridge")!; - assert.ok(instr.forces); - assert.equal(instr.forces!.length, 1); - assert.equal(instr.forces![0].handle, "audit"); - for (const w of instr.wires) { + const forces = flatForces(instr.body); + assert.ok(forces.length > 0); + assert.equal(forces.length, 1); + assert.equal(forces[0].handle, "audit"); + for (const w of flatWires(instr.body)) { assert.equal((w as any).force, undefined, "wires should not have force"); } }); @@ -95,10 +101,11 @@ describe("parseBridge: force ", () => { } `).instructions.find((i): i is Bridge => i.kind === "bridge")!; - assert.ok(instr.forces); - assert.equal(instr.forces!.length, 2); - assert.equal(instr.forces![0].handle, "lg"); - assert.equal(instr.forces![1].handle, "mt"); + const forces = flatForces(instr.body); + assert.ok(forces.length > 0); + assert.equal(forces.length, 2); + assert.equal(forces[0].handle, "lg"); + assert.equal(forces[1].handle, "mt"); }); test("force on undeclared handle throws", () => { @@ -135,12 +142,13 @@ describe("parseBridge: force ", () => { } `).instructions.find((i): i is Bridge => i.kind === "bridge")!; - assert.ok(instr.forces); - assert.equal(instr.forces!.length, 1); - assert.equal(instr.forces![0].handle, "t"); - assert.equal(instr.forces![0].module, SELF_MODULE); - assert.equal(instr.forces![0].type, "Tools"); - assert.equal(instr.forces![0].field, "myTool"); + const forces = flatForces(instr.body); + assert.ok(forces.length > 0); + assert.equal(forces.length, 1); + assert.equal(forces[0].handle, "t"); + assert.equal(forces[0].module, SELF_MODULE); + assert.equal(forces[0].type, "Tools"); + assert.equal(forces[0].field, "myTool"); }); test("force without any wires to the handle", () => { @@ -159,9 +167,10 @@ describe("parseBridge: force ", () => { } `).instructions.find((i): i is Bridge => i.kind === "bridge")!; - assert.ok(instr.forces); - assert.equal(instr.forces![0].handle, "se"); - assert.equal(instr.forces![0].catchError, undefined, "default is critical"); + const forces = flatForces(instr.body); + assert.ok(forces.length > 0); + assert.equal(forces[0].handle, "se"); + assert.equal(forces[0].catchError, undefined, "default is critical"); }); test("force catch null sets catchError flag", () => { @@ -180,10 +189,11 @@ describe("parseBridge: force ", () => { } `).instructions.find((i): i is Bridge => i.kind === "bridge")!; - assert.ok(instr.forces); - assert.equal(instr.forces!.length, 1); - assert.equal(instr.forces![0].handle, "ping"); - assert.equal(instr.forces![0].catchError, true); + const forces = flatForces(instr.body); + assert.ok(forces.length > 0); + assert.equal(forces.length, 1); + assert.equal(forces[0].handle, "ping"); + assert.equal(forces[0].catchError, true); }); test("mixed critical and fire-and-forget forces", () => { @@ -203,12 +213,13 @@ describe("parseBridge: force ", () => { } `).instructions.find((i): i is Bridge => i.kind === "bridge")!; - assert.ok(instr.forces); - assert.equal(instr.forces!.length, 2); - assert.equal(instr.forces![0].handle, "lg"); - assert.equal(instr.forces![0].catchError, undefined, "lg is critical"); - assert.equal(instr.forces![1].handle, "mt"); - assert.equal(instr.forces![1].catchError, true, "mt is fire-and-forget"); + const forces = flatForces(instr.body); + assert.ok(forces.length > 0); + assert.equal(forces.length, 2); + assert.equal(forces[0].handle, "lg"); + assert.equal(forces[0].catchError, undefined, "lg is critical"); + assert.equal(forces[1].handle, "mt"); + assert.equal(forces[1].catchError, true, "mt is fire-and-forget"); }); }); diff --git a/packages/bridge-parser/test/fuzz-parser.fuzz.ts b/packages/bridge-parser/test/fuzz-parser.fuzz.ts index af8c41e0..cd811d3b 100644 --- a/packages/bridge-parser/test/fuzz-parser.fuzz.ts +++ b/packages/bridge-parser/test/fuzz-parser.fuzz.ts @@ -8,6 +8,7 @@ import { prettyPrintToSource, } from "../src/index.ts"; import type { BridgeDocument } from "@stackables/bridge-core"; +import { flatWires } from "./utils/parse-test-utils.ts"; // ── Token-soup arbitrary ──────────────────────────────────────────────────── // Generates strings composed of a weighted mix of Bridge-like tokens and noise. @@ -255,8 +256,8 @@ describe("parser fuzz — serializer round-trip", () => { assert.equal(rt.kind, orig.kind, "instruction kind must match"); if (orig.kind === "bridge" && rt.kind === "bridge") { assert.equal( - rt.wires.length, - orig.wires.length, + flatWires(rt.body).length, + flatWires(orig.body).length, "wire count must match", ); } diff --git a/packages/bridge-parser/test/path-scoping-parser.test.ts b/packages/bridge-parser/test/path-scoping-parser.test.ts index a715def2..a3f73a63 100644 --- a/packages/bridge-parser/test/path-scoping-parser.test.ts +++ b/packages/bridge-parser/test/path-scoping-parser.test.ts @@ -4,8 +4,11 @@ import { parseBridgeFormat as parseBridge, serializeBridge, } from "../src/index.ts"; -import type { Bridge } from "@stackables/bridge-core"; -import { assertDeepStrictEqualIgnoringLoc } from "./utils/parse-test-utils.ts"; +import type { Bridge, WireAliasStatement } from "@stackables/bridge-core"; +import { + assertDeepStrictEqualIgnoringLoc, + flatWires, +} from "./utils/parse-test-utils.ts"; import { bridge } from "@stackables/bridge-core"; // ── Parser tests ──────────────────────────────────────────────────────────── @@ -28,15 +31,15 @@ describe("path scoping – parser", () => { (i): i is Bridge => i.kind === "bridge", )!; assert.ok(instr); - const constWires = instr.wires.filter( + const constWires = flatWires(instr.body).filter( (w) => w.sources[0]?.expr.type === "literal", ); assert.equal(constWires.length, 2); const theme = constWires.find( - (w) => w.to.path.join(".") === "settings.theme", + (w) => w.target.path.join(".") === "settings.theme", ); const lang = constWires.find( - (w) => w.to.path.join(".") === "settings.lang", + (w) => w.target.path.join(".") === "settings.lang", ); assert.ok(theme); assert.equal( @@ -71,13 +74,13 @@ describe("path scoping – parser", () => { const instr = result.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - const pullWires = instr.wires.filter( + const pullWires = flatWires(instr.body).filter( (w) => w.sources[0]?.expr.type === "ref", ); assert.equal(pullWires.length, 2); - const nameWire = pullWires.find((w) => w.to.path.join(".") === "user.name"); + const nameWire = pullWires.find((w) => w.target.path.join(".") === "user.name"); const emailWire = pullWires.find( - (w) => w.to.path.join(".") === "user.email", + (w) => w.target.path.join(".") === "user.email", ); assert.ok(nameWire); const nameExpr = nameWire.sources[0]!.expr; @@ -116,15 +119,15 @@ describe("path scoping – parser", () => { const instr = result.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - const wires = instr.wires; + const wires = flatWires(instr.body); // Pull wires const pullWires = wires.filter((w) => w.sources[0]?.expr.type === "ref"); const idWire = pullWires.find( - (w) => w.to.path.join(".") === "body.user.profile.id", + (w) => w.target.path.join(".") === "body.user.profile.id", ); const nameWire = pullWires.find( - (w) => w.to.path.join(".") === "body.user.profile.name", + (w) => w.target.path.join(".") === "body.user.profile.name", ); assert.ok(idWire, "id wire should exist"); assert.ok(nameWire, "name wire should exist"); @@ -144,10 +147,10 @@ describe("path scoping – parser", () => { (w) => w.sources[0]?.expr.type === "literal", ); const themeWire = constWires.find( - (w) => w.to.path.join(".") === "body.user.settings.theme", + (w) => w.target.path.join(".") === "body.user.settings.theme", ); const notifWire = constWires.find( - (w) => w.to.path.join(".") === "body.user.settings.notifications", + (w) => w.target.path.join(".") === "body.user.settings.notifications", ); assert.ok(themeWire); assert.equal( @@ -161,7 +164,7 @@ describe("path scoping – parser", () => { notifWire.sources[0]!.expr.type === "literal" ? notifWire.sources[0]!.expr.value : undefined, - "true", + true, ); }); @@ -183,7 +186,7 @@ describe("path scoping – parser", () => { const instr = result.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - assert.ok(instr.pipeHandles && instr.pipeHandles.length > 0); + assert.ok(flatWires(instr.body).length > 0, "should have wires in body"); }); test("scope block with fallback operators", () => { @@ -203,10 +206,10 @@ describe("path scoping – parser", () => { const instr = result.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - const pullWires = instr.wires.filter( + const pullWires = flatWires(instr.body).filter( (w) => w.sources[0]?.expr.type === "ref", ); - const nameWire = pullWires.find((w) => w.to.path.join(".") === "data.name"); + const nameWire = pullWires.find((w) => w.target.path.join(".") === "data.name"); assert.ok(nameWire); assert.equal(nameWire.sources.length, 2); assert.equal(nameWire.sources[1]!.gate, "falsy"); @@ -214,18 +217,18 @@ describe("path scoping – parser", () => { nameWire.sources[1]!.expr.type === "literal" ? nameWire.sources[1]!.expr.value : undefined, - '"anonymous"', + "anonymous", ); const valueWire = pullWires.find( - (w) => w.to.path.join(".") === "data.value", + (w) => w.target.path.join(".") === "data.value", ); assert.ok(valueWire); assert.equal( valueWire.catch && "value" in valueWire.catch ? valueWire.catch.value : undefined, - "0", + 0, ); }); @@ -246,7 +249,10 @@ describe("path scoping – parser", () => { const instr = result.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - assert.ok(instr.pipeHandles && instr.pipeHandles.length > 0); + const exprWires = flatWires(instr.body).filter( + (w) => w.sources[0]?.expr.type === "binary", + ); + assert.ok(exprWires.length > 0, "should have binary expression wires"); }); test("scope block with ternary", () => { @@ -266,7 +272,7 @@ describe("path scoping – parser", () => { const instr = result.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - const ternaryWires = instr.wires.filter( + const ternaryWires = flatWires(instr.body).filter( (w) => w.sources[0]?.expr.type === "ternary", ); assert.equal(ternaryWires.length, 2); @@ -289,7 +295,10 @@ describe("path scoping – parser", () => { const instr = result.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - assert.ok(instr.pipeHandles && instr.pipeHandles.length > 0); + const concatWires = flatWires(instr.body).filter( + (w) => w.sources[0]?.expr.type === "concat", + ); + assert.ok(concatWires.length > 0, "should have concat expression wires"); }); test("mixed flat wires and scope blocks", () => { @@ -311,13 +320,13 @@ describe("path scoping – parser", () => { const instr = result.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - const constWires = instr.wires.filter( + const constWires = flatWires(instr.body).filter( (w) => w.sources[0]?.expr.type === "literal", ); assert.equal(constWires.length, 3); - assert.ok(constWires.find((w) => w.to.path.join(".") === "method")); - assert.ok(constWires.find((w) => w.to.path.join(".") === "body.value")); - assert.ok(constWires.find((w) => w.to.path.join(".") === "status")); + assert.ok(constWires.find((w) => w.target.path.join(".") === "method")); + assert.ok(constWires.find((w) => w.target.path.join(".") === "body.value")); + assert.ok(constWires.find((w) => w.target.path.join(".") === "status")); }); test("scope block on tool handle", () => { @@ -344,12 +353,12 @@ describe("path scoping – parser", () => { const instr = result.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - const pullWires = instr.wires.filter( + const pullWires = flatWires(instr.body).filter( (w) => w.sources[0]?.expr.type === "ref", ); - const nameWire = pullWires.find((w) => w.to.path.join(".") === "body.name"); + const nameWire = pullWires.find((w) => w.target.path.join(".") === "body.name"); const emailWire = pullWires.find( - (w) => w.to.path.join(".") === "body.email", + (w) => w.target.path.join(".") === "body.email", ); assert.ok(nameWire, "name wire targeting api.body.name should exist"); assert.ok(emailWire, "email wire targeting api.body.email should exist"); @@ -395,7 +404,10 @@ describe("path scoping – parser", () => { (i): i is Bridge => i.kind === "bridge", )!; - assertDeepStrictEqualIgnoringLoc(scopedBridge.wires, flatBridge.wires); + assertDeepStrictEqualIgnoringLoc( + flatWires(scopedBridge.body), + flatWires(flatBridge.body), + ); }); test("scope block on tool input wires to tool correctly", () => { @@ -423,8 +435,10 @@ describe("path scoping – parser", () => { const br = parsed.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - const pullWires = br.wires.filter((w) => w.sources[0]?.expr.type === "ref"); - const qWire = pullWires.find((w) => w.to.path.join(".") === "q"); + const pullWires = flatWires(br.body).filter( + (w) => w.sources[0]?.expr.type === "ref", + ); + const qWire = pullWires.find((w) => w.target.path.join(".") === "q"); assert.ok(qWire, "wire to api.q should exist"); }); @@ -450,20 +464,33 @@ describe("path scoping – parser", () => { const br = parsed.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - const pullWires = br.wires.filter((w) => w.sources[0]?.expr.type === "ref"); - // Alias creates a __local wire - const localWire = pullWires.find( - (w) => w.to.module === "__local" && w.to.field === "upper", + const pullWires = flatWires(br.body).filter( + (w) => w.sources[0]?.expr.type === "ref", ); - assert.ok(localWire, "alias wire to __local:Shadow:upper should exist"); + // Alias creates an alias statement (V3: WireAliasStatement in body) + function findAlias( + stmts: Bridge["body"], + name: string, + ): WireAliasStatement | undefined { + for (const s of stmts) { + if (s.kind === "alias" && s.name === name) return s; + if (s.kind === "scope") { + const found = findAlias(s.body, name); + if (found) return found; + } + } + return undefined; + } + const aliasStmt = findAlias(br.body, "upper"); + assert.ok(aliasStmt, "alias statement for 'upper' should exist"); // displayName wire reads from alias const displayWire = pullWires.find( - (w) => w.to.path.join(".") === "info.displayName", + (w) => w.target.path.join(".") === "info.displayName", ); assert.ok(displayWire, "wire to o.info.displayName should exist"); const displayExpr = displayWire!.sources[0]!.expr; assert.equal( - displayExpr.type === "ref" ? displayExpr.ref.module : undefined, + displayExpr.type === "ref" ? displayExpr.ref.type : undefined, "__local", ); assert.equal( @@ -472,7 +499,7 @@ describe("path scoping – parser", () => { ); // email wire reads from input const emailWire = pullWires.find( - (w) => w.to.path.join(".") === "info.email", + (w) => w.target.path.join(".") === "info.email", ); assert.ok(emailWire, "wire to o.info.email should exist"); }); @@ -505,7 +532,10 @@ describe("path scoping – serializer round-trip", () => { const bridge2 = reparsed.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - assertDeepStrictEqualIgnoringLoc(bridge1.wires, bridge2.wires); + assertDeepStrictEqualIgnoringLoc( + flatWires(bridge1.body), + flatWires(bridge2.body), + ); }); test("deeply nested scope round-trips correctly", () => { @@ -537,7 +567,10 @@ describe("path scoping – serializer round-trip", () => { const bridge2 = reparsed.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - assertDeepStrictEqualIgnoringLoc(bridge1.wires, bridge2.wires); + assertDeepStrictEqualIgnoringLoc( + flatWires(bridge1.body), + flatWires(bridge2.body), + ); }); }); @@ -562,7 +595,7 @@ describe("path scoping – array mapper blocks", () => { const instr = result.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - const constWires = instr.wires.filter( + const constWires = flatWires(instr.body).filter( (w) => w.sources[0]?.expr.type === "literal", ); assert.equal(constWires.length, 1); @@ -571,10 +604,10 @@ describe("path scoping – array mapper blocks", () => { wire.sources[0]!.expr.type === "literal" ? wire.sources[0]!.expr.value : undefined, - "1", + 1, ); - assertDeepStrictEqualIgnoringLoc(wire.to.path, ["obj", "etc"]); - assert.equal(wire.to.element, true); + assertDeepStrictEqualIgnoringLoc(wire.target.path, ["obj", "etc"]); + assert.equal(wire.target.element, true); }); test("scope block with pull wire inside array mapper references iterator", () => { @@ -595,10 +628,10 @@ describe("path scoping – array mapper blocks", () => { const instr = result.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - const pullWires = instr.wires.filter( + const pullWires = flatWires(instr.body).filter( (w) => w.sources[0]?.expr.type === "ref", ); - const nameWire = pullWires.find((w) => w.to.path.join(".") === "obj.name"); + const nameWire = pullWires.find((w) => w.target.path.join(".") === "obj.name"); assert.ok(nameWire, "wire to obj.name should exist"); const nameExpr = nameWire!.sources[0]!.expr; assert.equal( @@ -631,12 +664,12 @@ describe("path scoping – array mapper blocks", () => { const instr = result.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - const constWires = instr.wires.filter( + const constWires = flatWires(instr.body).filter( (w) => w.sources[0]?.expr.type === "literal", ); assert.equal(constWires.length, 1); - assertDeepStrictEqualIgnoringLoc(constWires[0].to.path, ["a", "b", "c"]); - assert.equal(constWires[0].to.element, true); + assertDeepStrictEqualIgnoringLoc(constWires[0].target.path, ["a", "b", "c"]); + assert.equal(constWires[0].target.element, true); }); test("array mapper scope block and flat element lines coexist", () => { @@ -659,22 +692,22 @@ describe("path scoping – array mapper blocks", () => { const instr = result.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - const constWires = instr.wires.filter( + const constWires = flatWires(instr.body).filter( (w) => w.sources[0]?.expr.type === "literal", ); - const pullWires = instr.wires.filter( + const pullWires = flatWires(instr.body).filter( (w) => w.sources[0]?.expr.type === "ref", ); assert.ok( - constWires.find((w) => w.to.path.join(".") === "nested.x"), + constWires.find((w) => w.target.path.join(".") === "nested.x"), "nested.x constant should exist", ); assert.ok( - pullWires.find((w) => w.to.path.join(".") === "flat"), + pullWires.find((w) => w.target.path.join(".") === "flat"), "flat pull wire should exist", ); assert.ok( - pullWires.find((w) => w.to.path.join(".") === "nested.y"), + pullWires.find((w) => w.target.path.join(".") === "nested.y"), "nested.y pull wire should exist", ); }); @@ -702,10 +735,10 @@ describe("path scoping – spread syntax parser", () => { const instr = result.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - const pullWires = instr.wires.filter( + const pullWires = flatWires(instr.body).filter( (w) => w.sources[0]?.expr.type === "ref", ); - const spreadWire = pullWires.find((w) => w.to.path.length === 0); + const spreadWire = pullWires.find((w) => w.target.path.length === 0); assert.ok(spreadWire, "spread wire targeting tool root should exist"); assertDeepStrictEqualIgnoringLoc( spreadWire.sources[0]!.expr.type === "ref" @@ -735,18 +768,18 @@ describe("path scoping – spread syntax parser", () => { const instr = result.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - const pullWires = instr.wires.filter( + const pullWires = flatWires(instr.body).filter( (w) => w.sources[0]?.expr.type === "ref", ); - const constWires = instr.wires.filter( + const constWires = flatWires(instr.body).filter( (w) => w.sources[0]?.expr.type === "literal", ); assert.ok( - pullWires.find((w) => w.to.path.length === 0), + pullWires.find((w) => w.target.path.length === 0), "spread wire to tool root should exist", ); assert.ok( - constWires.find((w) => w.to.path.join(".") === "extra"), + constWires.find((w) => w.target.path.join(".") === "extra"), "constant wire for .extra should exist", ); }); @@ -770,10 +803,10 @@ describe("path scoping – spread syntax parser", () => { const instr = result.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - const pullWires = instr.wires.filter( + const pullWires = flatWires(instr.body).filter( (w) => w.sources[0]?.expr.type === "ref", ); - const spreadWire = pullWires.find((w) => w.to.path.length === 0); + const spreadWire = pullWires.find((w) => w.target.path.length === 0); assert.ok(spreadWire, "spread wire should exist"); assertDeepStrictEqualIgnoringLoc( spreadWire.sources[0]!.expr.type === "ref" @@ -800,12 +833,12 @@ describe("path scoping – spread syntax parser", () => { const instr = result.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - const pullWires = instr.wires.filter( + const pullWires = flatWires(instr.body).filter( (w) => w.sources[0]?.expr.type === "ref", ); const spreadWire = pullWires.find( (w) => - w.to.path.join(".") === "wrapper" && + w.target.path.join(".") === "wrapper" && (w.sources[0]!.expr.type === "ref" ? w.sources[0]!.expr.ref.path.length === 0 : false), @@ -832,10 +865,10 @@ describe("path scoping – spread syntax parser", () => { const instr = result.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - const pullWires = instr.wires.filter( + const pullWires = flatWires(instr.body).filter( (w) => w.sources[0]?.expr.type === "ref", ); - const spreadWire = pullWires.find((w) => w.to.path.join(".") === "nested"); + const spreadWire = pullWires.find((w) => w.target.path.join(".") === "nested"); assert.ok(spreadWire, "spread wire to tool.nested should exist"); assertDeepStrictEqualIgnoringLoc( spreadWire.sources[0]!.expr.type === "ref" diff --git a/packages/bridge-parser/test/resilience-parser.test.ts b/packages/bridge-parser/test/resilience-parser.test.ts index e78b300e..8f3d0250 100644 --- a/packages/bridge-parser/test/resilience-parser.test.ts +++ b/packages/bridge-parser/test/resilience-parser.test.ts @@ -5,7 +5,10 @@ import { serializeBridge, } from "@stackables/bridge-parser"; import type { Bridge, ConstDef, ToolDef } from "@stackables/bridge-core"; -import { assertDeepStrictEqualIgnoringLoc } from "./utils/parse-test-utils.ts"; +import { + assertDeepStrictEqualIgnoringLoc, + flatWires, +} from "./utils/parse-test-utils.ts"; import { bridge } from "@stackables/bridge-core"; // ══════════════════════════════════════════════════════════════════════════════ @@ -289,11 +292,13 @@ describe("parseBridge: wire fallback (catch)", () => { } `).instructions.find((i): i is Bridge => i.kind === "bridge")!; - const fbWire = instr.wires.find((w) => w.catch && "value" in w.catch); + const fbWire = flatWires(instr.body).find( + (w) => w.catch && "value" in w.catch, + ); assert.ok(fbWire, "should have a wire with catch"); assert.equal( "value" in fbWire!.catch! ? fbWire!.catch.value : undefined, - "0", + 0, ); }); @@ -311,11 +316,13 @@ describe("parseBridge: wire fallback (catch)", () => { } `).instructions.find((i): i is Bridge => i.kind === "bridge")!; - const fbWire = instr.wires.find((w) => w.catch && "value" in w.catch); + const fbWire = flatWires(instr.body).find( + (w) => w.catch && "value" in w.catch, + ); assert.ok(fbWire); - assert.equal( + assert.deepEqual( "value" in fbWire!.catch! ? fbWire!.catch.value : undefined, - `{"default":true}`, + { default: true }, ); }); @@ -333,11 +340,13 @@ describe("parseBridge: wire fallback (catch)", () => { } `).instructions.find((i): i is Bridge => i.kind === "bridge")!; - const fbWire = instr.wires.find((w) => w.catch && "value" in w.catch); + const fbWire = flatWires(instr.body).find( + (w) => w.catch && "value" in w.catch, + ); assert.ok(fbWire); assert.equal( "value" in fbWire!.catch! ? fbWire!.catch.value : undefined, - `"unknown"`, + "unknown", ); }); @@ -355,11 +364,13 @@ describe("parseBridge: wire fallback (catch)", () => { } `).instructions.find((i): i is Bridge => i.kind === "bridge")!; - const fbWire = instr.wires.find((w) => w.catch && "value" in w.catch); + const fbWire = flatWires(instr.body).find( + (w) => w.catch && "value" in w.catch, + ); assert.ok(fbWire); assert.equal( "value" in fbWire!.catch! ? fbWire!.catch.value : undefined, - "null", + null, ); }); @@ -377,11 +388,13 @@ describe("parseBridge: wire fallback (catch)", () => { } `).instructions.find((i): i is Bridge => i.kind === "bridge")!; - const fbWire = instr.wires.find((w) => w.catch && "value" in w.catch); + const fbWire = flatWires(instr.body).find( + (w) => w.catch && "value" in w.catch, + ); assert.ok(fbWire, "should have pipe output wire with catch"); assert.equal( "value" in fbWire!.catch! ? fbWire!.catch.value : undefined, - `"fallback"`, + "fallback", ); }); @@ -400,7 +413,7 @@ describe("parseBridge: wire fallback (catch)", () => { } `).instructions.find((i): i is Bridge => i.kind === "bridge")!; - for (const w of instr.wires) { + for (const w of flatWires(instr.body)) { assert.equal(w.catch, undefined, "no catch on regular wire"); } }); @@ -480,14 +493,14 @@ describe("parseBridge: wire || falsy-fallback", () => { const instr = doc.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - const wire = instr.wires[0]!; + const wire = flatWires(instr.body)[0]!; assert.equal(wire.sources.length, 2); assert.equal(wire.sources[1]!.gate, "falsy"); assert.equal( wire.sources[1]!.expr.type === "literal" ? wire.sources[1]!.expr.value : undefined, - '"World"', + "World", ); assert.equal(wire.catch, undefined); }); @@ -507,17 +520,17 @@ describe("parseBridge: wire || falsy-fallback", () => { const instr = doc.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - const wire = instr.wires[0]!; + const wire = flatWires(instr.body)[0]!; assert.equal(wire.sources.length, 2); assert.equal(wire.sources[1]!.gate, "falsy"); assert.equal( wire.sources[1]!.expr.type === "literal" ? wire.sources[1]!.expr.value : undefined, - '"World"', + "World", ); assert.ok(wire.catch && "value" in wire.catch); - assert.equal(wire.catch.value, '"Error"'); + assert.equal(wire.catch.value, "Error"); }); test("wire with || JSON object literal", () => { @@ -537,18 +550,18 @@ describe("parseBridge: wire || falsy-fallback", () => { const instr = doc.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - const wire = instr.wires.find( + const wire = flatWires(instr.body).find( (w) => w.sources[0]?.expr.type === "ref" && w.sources[0].expr.ref.path[0] === "data", )!; assert.equal(wire.sources.length, 2); assert.equal(wire.sources[1]!.gate, "falsy"); - assert.equal( + assert.deepEqual( wire.sources[1]!.expr.type === "literal" ? wire.sources[1]!.expr.value : undefined, - '{"lat":0,"lon":0}', + { lat: 0, lon: 0 }, ); }); @@ -567,7 +580,7 @@ describe("parseBridge: wire || falsy-fallback", () => { const instr = doc.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - const wire = instr.wires[0]!; + const wire = flatWires(instr.body)[0]!; assert.equal(wire.sources.length, 1, "should have no fallback sources"); }); @@ -587,11 +600,8 @@ describe("parseBridge: wire || falsy-fallback", () => { const instr = doc.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - const terminalWire = instr.wires.find( - (w) => - w.pipe && - w.sources[0]?.expr.type === "ref" && - w.sources[0].expr.ref.path.length === 0, + const terminalWire = flatWires(instr.body).find( + (w) => w.sources[0]?.expr.type === "pipe", )!; assert.equal(terminalWire.sources.length, 2); assert.equal(terminalWire.sources[1]!.gate, "falsy"); @@ -599,7 +609,7 @@ describe("parseBridge: wire || falsy-fallback", () => { terminalWire.sources[1]!.expr.type === "literal" ? terminalWire.sources[1]!.expr.value : undefined, - '"N/A"', + "N/A", ); }); }); @@ -681,7 +691,9 @@ describe("parseBridge: || source references", () => { const instr = doc.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - const labelWires = instr.wires.filter((w) => w.to.path[0] === "label"); + const labelWires = flatWires(instr.body).filter( + (w) => w.target.path[0] === "label", + ); assert.equal(labelWires.length, 1, "should be one wire, not two"); assert.ok( labelWires[0].sources.length >= 2, @@ -714,7 +726,9 @@ describe("parseBridge: || source references", () => { const instr = doc.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - const labelWires = instr.wires.filter((w) => w.to.path[0] === "label"); + const labelWires = flatWires(instr.body).filter( + (w) => w.target.path[0] === "label", + ); assert.equal(labelWires.length, 1); assert.ok( labelWires[0].sources.length >= 3, @@ -727,7 +741,7 @@ describe("parseBridge: || source references", () => { labelWires[0].sources[2]!.expr.type === "literal" ? labelWires[0].sources[2]!.expr.value : undefined, - '"default"', + "default", ); }); }); @@ -754,7 +768,7 @@ describe("parseBridge: catch source/pipe references", () => { const instr = doc.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - const wire = instr.wires.find((w) => w.to.path[0] === "label")!; + const wire = flatWires(instr.body).find((w) => w.target.path[0] === "label")!; assert.ok(wire.catch && "ref" in wire.catch, "should have catch ref"); assert.equal( wire.catch && "value" in wire.catch ? wire.catch.value : undefined, @@ -784,12 +798,12 @@ describe("parseBridge: catch source/pipe references", () => { const instr = doc.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - const wire = instr.wires.find((w) => !w.pipe && w.to.path[0] === "label")!; - assert.ok(wire.catch && "ref" in wire.catch, "should have catch ref"); - assert.deepEqual(wire.catch.ref.path, []); - assert.ok( - instr.pipeHandles && instr.pipeHandles.length > 0, - "should have pipe forks", + const wire = flatWires(instr.body).find((w) => w.target.path[0] === "label")!; + assert.ok(wire.catch && "expr" in wire.catch, "should have catch expr"); + assert.equal( + wire.catch.expr.type, + "pipe", + "catch should be a pipe expression", ); }); @@ -812,8 +826,8 @@ describe("parseBridge: catch source/pipe references", () => { const instr = doc.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - const labelWires = instr.wires.filter( - (w) => !w.pipe && w.to.path[0] === "label", + const labelWires = flatWires(instr.body).filter( + (w) => w.target.path[0] === "label", ); assert.equal(labelWires.length, 1); assert.ok( @@ -827,7 +841,7 @@ describe("parseBridge: catch source/pipe references", () => { labelWires[0].sources[2]!.expr.type === "literal" ? labelWires[0].sources[2]!.expr.value : undefined, - '"default"', + "default", ); assert.ok( labelWires[0].catch && "ref" in labelWires[0].catch, diff --git a/packages/bridge-parser/test/source-locations.test.ts b/packages/bridge-parser/test/source-locations.test.ts index b8013eb0..d7fb42a5 100644 --- a/packages/bridge-parser/test/source-locations.test.ts +++ b/packages/bridge-parser/test/source-locations.test.ts @@ -1,8 +1,9 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { parseBridgeChevrotain as parseBridge } from "../src/index.ts"; -import type { Bridge, Wire } from "@stackables/bridge-core"; +import type { Bridge, WireAliasStatement } from "@stackables/bridge-core"; import { bridge } from "@stackables/bridge-core"; +import { flatWires, type FlatWire } from "./utils/parse-test-utils.ts"; function getBridge(text: string): Bridge { const document = parseBridge(text); @@ -13,7 +14,7 @@ function getBridge(text: string): Bridge { return instr; } -function assertLoc(wire: Wire | undefined, line: number, column: number): void { +function assertLoc(wire: FlatWire | undefined, line: number, column: number): void { assert.ok(wire, "expected wire to exist"); assert.ok(wire.loc, "expected wire to carry a source location"); assert.equal(wire.loc.startLine, line); @@ -32,7 +33,7 @@ describe("parser source locations", () => { } `); - assertLoc(instr.wires[0], 5, 3); + assertLoc(flatWires(instr.body)[0], 5, 3); }); it("constant wire loc is populated", () => { @@ -44,7 +45,7 @@ describe("parser source locations", () => { } `); - assertLoc(instr.wires[0], 4, 3); + assertLoc(flatWires(instr.body)[0], 4, 3); }); it("ternary wire loc is populated", () => { @@ -57,19 +58,19 @@ describe("parser source locations", () => { } `); - const ternaryWire = instr.wires.find( + const ternaryWire = flatWires(instr.body).find( (wire) => wire.sources[0]?.expr.type === "ternary", ); assertLoc(ternaryWire, 5, 3); const ternaryExpr = ternaryWire!.sources[0]!.expr; assert.equal(ternaryExpr.type, "ternary"); if (ternaryExpr.type === "ternary") { - assert.equal(ternaryExpr.condLoc?.startLine, 5); - assert.equal(ternaryExpr.condLoc?.startColumn, 13); - assert.equal(ternaryExpr.thenLoc?.startLine, 5); - assert.equal(ternaryExpr.thenLoc?.startColumn, 22); - assert.equal(ternaryExpr.elseLoc?.startLine, 5); - assert.equal(ternaryExpr.elseLoc?.startColumn, 36); + assert.equal(ternaryExpr.cond.loc?.startLine, 5); + assert.equal(ternaryExpr.cond.loc?.startColumn, 13); + assert.equal(ternaryExpr.then.loc?.startLine, 5); + assert.equal(ternaryExpr.then.loc?.startColumn, 22); + assert.equal(ternaryExpr.else.loc?.startLine, 5); + assert.equal(ternaryExpr.else.loc?.startColumn, 36); } }); @@ -83,10 +84,10 @@ describe("parser source locations", () => { } `); - const concatPartWire = instr.wires.find( - (wire) => wire.to.field === "concat", + const concatWire = flatWires(instr.body).find( + (wire) => wire.sources[0]?.expr.type === "concat", ); - assertLoc(concatPartWire, 5, 3); + assertLoc(concatWire, 5, 3); }); it("fallback and catch refs carry granular locations", () => { @@ -100,22 +101,24 @@ describe("parser source locations", () => { } `); - const aliasWire = instr.wires.find((wire) => wire.to.field === "clean"); - assert.ok(aliasWire?.catch); - assert.equal(aliasWire.catch.loc?.startLine, 5); - assert.equal(aliasWire.catch.loc?.startColumn, 44); + const aliasStmt = instr.body.find( + (s): s is WireAliasStatement => s.kind === "alias" && s.name === "clean", + ); + assert.ok(aliasStmt?.catch); + assert.equal(aliasStmt.catch.loc?.startLine, 5); + assert.equal(aliasStmt.catch.loc?.startColumn, 44); - const messageWire = instr.wires.find( - (wire) => wire.to.path.join(".") === "message", + const messageWire = flatWires(instr.body).find( + (wire) => wire.target.path.join(".") === "message", ); assert.ok(messageWire && messageWire.sources.length >= 2); const msgExpr0 = messageWire.sources[0]!.expr; assert.equal( - msgExpr0.type === "ref" ? msgExpr0.refLoc?.startLine : undefined, + msgExpr0.type === "ref" ? msgExpr0.loc?.startLine : undefined, 6, ); assert.equal( - msgExpr0.type === "ref" ? msgExpr0.refLoc?.startColumn : undefined, + msgExpr0.type === "ref" ? msgExpr0.loc?.startColumn : undefined, 16, ); assert.equal(messageWire.sources[1]!.loc?.startLine, 6); @@ -143,38 +146,32 @@ describe("parser source locations", () => { } `); - const destinationIdWire = instr.wires.find( - (wire) => wire.to.path.join(".") === "legs.destination.station.id", + const destinationIdWire = flatWires(instr.body).find( + (wire) => wire.target.path.join(".") === "legs.destination.station.id", ); assertLoc(destinationIdWire, 8, 9); assert.ok(destinationIdWire); const idExpr = destinationIdWire.sources[0]!.expr; + assert.equal(idExpr.type === "ref" ? idExpr.loc?.startLine : undefined, 8); assert.equal( - idExpr.type === "ref" ? idExpr.refLoc?.startLine : undefined, - 8, - ); - assert.equal( - idExpr.type === "ref" ? idExpr.refLoc?.startColumn : undefined, + idExpr.type === "ref" ? idExpr.loc?.startColumn : undefined, 16, ); - const destinationPlannedTimeWire = instr.wires.find( - (wire) => wire.to.path.join(".") === "legs.destination.plannedTime", + const destinationPlannedTimeWire = flatWires(instr.body).find( + (wire) => wire.target.path.join(".") === "legs.destination.plannedTime", ); assertLoc(destinationPlannedTimeWire, 11, 7); assert.ok(destinationPlannedTimeWire); const ptExpr = destinationPlannedTimeWire.sources[0]!.expr; + assert.equal(ptExpr.type === "ref" ? ptExpr.loc?.startLine : undefined, 11); assert.equal( - ptExpr.type === "ref" ? ptExpr.refLoc?.startLine : undefined, - 11, - ); - assert.equal( - ptExpr.type === "ref" ? ptExpr.refLoc?.startColumn : undefined, + ptExpr.type === "ref" ? ptExpr.loc?.startColumn : undefined, 23, ); - const destinationDelayWire = instr.wires.find( - (wire) => wire.to.path.join(".") === "legs.destination.delayMinutes", + const destinationDelayWire = flatWires(instr.body).find( + (wire) => wire.target.path.join(".") === "legs.destination.delayMinutes", ); assert.ok(destinationDelayWire && destinationDelayWire.sources.length >= 2); assertLoc(destinationDelayWire, 12, 7); diff --git a/packages/bridge-parser/test/ternary-parser.test.ts b/packages/bridge-parser/test/ternary-parser.test.ts index 6a163bef..0dfccb68 100644 --- a/packages/bridge-parser/test/ternary-parser.test.ts +++ b/packages/bridge-parser/test/ternary-parser.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { describe, test } from "node:test"; import { parseBridgeFormat as parseBridge } from "@stackables/bridge-parser"; import { bridge } from "@stackables/bridge-core"; +import { flatWires } from "./utils/parse-test-utils.ts"; // ── Parser / desugaring tests for ternary syntax ────────────────────────── @@ -17,7 +18,7 @@ describe("ternary: parser", () => { } `); const instr = doc.instructions.find((inst) => inst.kind === "bridge")!; - const condWire = instr.wires.find( + const condWire = flatWires(instr.body).find( (w) => w.sources[0]?.expr.type === "ternary", ); assert.ok(condWire, "should have a conditional wire"); @@ -44,18 +45,18 @@ describe("ternary: parser", () => { } `); const instr = doc.instructions.find((inst) => inst.kind === "bridge")!; - const condWire = instr.wires.find( + const condWire = flatWires(instr.body).find( (w) => w.sources[0]?.expr.type === "ternary", ); assert.ok(condWire && condWire.sources[0].expr.type === "ternary"); const expr = condWire.sources[0].expr; assert.equal( expr.then.type === "literal" ? expr.then.value : undefined, - '"premium"', + "premium", ); assert.equal( expr.else.type === "literal" ? expr.else.value : undefined, - '"basic"', + "basic", ); }); @@ -70,19 +71,16 @@ describe("ternary: parser", () => { } `); const instr = doc.instructions.find((inst) => inst.kind === "bridge")!; - const condWire = instr.wires.find( + const condWire = flatWires(instr.body).find( (w) => w.sources[0]?.expr.type === "ternary", ); assert.ok(condWire && condWire.sources[0].expr.type === "ternary"); const expr = condWire.sources[0].expr; assert.equal( expr.then.type === "literal" ? expr.then.value : undefined, - "20", - ); - assert.equal( - expr.else.type === "literal" ? expr.else.value : undefined, - "0", + 20, ); + assert.equal(expr.else.type === "literal" ? expr.else.value : undefined, 0); }); test("boolean literal branches", () => { @@ -96,18 +94,18 @@ describe("ternary: parser", () => { } `); const instr = doc.instructions.find((inst) => inst.kind === "bridge")!; - const condWire = instr.wires.find( + const condWire = flatWires(instr.body).find( (w) => w.sources[0]?.expr.type === "ternary", ); assert.ok(condWire && condWire.sources[0].expr.type === "ternary"); const expr = condWire.sources[0].expr; assert.equal( expr.then.type === "literal" ? expr.then.value : undefined, - "true", + true, ); assert.equal( expr.else.type === "literal" ? expr.else.value : undefined, - "false", + false, ); }); @@ -122,7 +120,7 @@ describe("ternary: parser", () => { } `); const instr = doc.instructions.find((inst) => inst.kind === "bridge")!; - const condWire = instr.wires.find( + const condWire = flatWires(instr.body).find( (w) => w.sources[0]?.expr.type === "ternary", ); assert.ok(condWire && condWire.sources[0].expr.type === "ternary"); @@ -130,7 +128,7 @@ describe("ternary: parser", () => { assert.equal(expr.then.type, "ref"); assert.equal( expr.else.type === "literal" ? expr.else.value : undefined, - "null", + null, ); }); @@ -145,22 +143,16 @@ describe("ternary: parser", () => { } `); const instr = doc.instructions.find((inst) => inst.kind === "bridge")!; - const condWire = instr.wires.find( + const condWire = flatWires(instr.body).find( (w) => w.sources[0]?.expr.type === "ternary", ); assert.ok(condWire && condWire.sources[0].expr.type === "ternary"); const expr = condWire.sources[0].expr; - assert.ok( - expr.cond.type === "ref" && - expr.cond.ref.instance != null && - expr.cond.ref.instance >= 100000, - "cond should be an expression fork result", - ); - const exprHandle = instr.pipeHandles!.find((ph) => - ph.handle.startsWith("__expr_"), - ); - assert.ok(exprHandle, "should have expression fork"); - assert.equal(exprHandle.baseTrunk.field, "gte"); + // In body-based IR, >= becomes a binary expression node + assert.equal(expr.cond.type, "binary"); + if (expr.cond.type === "binary") { + assert.equal(expr.cond.op, "gte"); + } }); test("|| literal fallback stored on conditional wire", () => { @@ -174,7 +166,7 @@ describe("ternary: parser", () => { } `); const instr = doc.instructions.find((inst) => inst.kind === "bridge")!; - const condWire = instr.wires.find( + const condWire = flatWires(instr.body).find( (w) => w.sources[0]?.expr.type === "ternary", ); assert.ok(condWire && condWire.sources[0].expr.type === "ternary"); @@ -184,7 +176,7 @@ describe("ternary: parser", () => { condWire.sources[1].expr.type === "literal" ? condWire.sources[1].expr.value : undefined, - "0", + 0, ); }); @@ -199,11 +191,11 @@ describe("ternary: parser", () => { } `); const instr = doc.instructions.find((inst) => inst.kind === "bridge")!; - const condWire = instr.wires.find( + const condWire = flatWires(instr.body).find( (w) => w.sources[0]?.expr.type === "ternary", ); assert.ok(condWire && condWire.sources[0].expr.type === "ternary"); assert.ok(condWire.catch && "value" in condWire.catch); - assert.equal(condWire.catch.value, "-1"); + assert.equal(condWire.catch.value, -1); }); }); diff --git a/packages/bridge-parser/test/tool-self-wires.test.ts b/packages/bridge-parser/test/tool-self-wires.test.ts index 3c0e2457..294dd2dd 100644 --- a/packages/bridge-parser/test/tool-self-wires.test.ts +++ b/packages/bridge-parser/test/tool-self-wires.test.ts @@ -3,7 +3,10 @@ import { describe, test } from "node:test"; import { parseBridgeFormat as parseBridge } from "../src/index.ts"; import type { ToolDef } from "@stackables/bridge-core"; import { SELF_MODULE } from "@stackables/bridge-core"; -import { assertDeepStrictEqualIgnoringLoc } from "./utils/parse-test-utils.ts"; +import { + assertDeepStrictEqualIgnoringLoc, + flatWires, +} from "./utils/parse-test-utils.ts"; import { bridge } from "@stackables/bridge-core"; /** Shorthand to make a NodeRef for Tools */ @@ -64,8 +67,8 @@ describe("tool self-wires: constant (=)", () => { .baseUrl = "https://example.com" } `); - assertDeepStrictEqualIgnoringLoc(tool.wires[0], { - to: toolRef("api", ["baseUrl"]), + assertDeepStrictEqualIgnoringLoc(flatWires(tool.body)[0], { + target: toolRef("api", ["baseUrl"]), sources: [{ expr: { type: "literal", value: "https://example.com" } }], }); }); @@ -77,8 +80,8 @@ describe("tool self-wires: constant (=)", () => { .method = GET } `); - assertDeepStrictEqualIgnoringLoc(tool.wires[0], { - to: toolRef("api", ["method"]), + assertDeepStrictEqualIgnoringLoc(flatWires(tool.body)[0], { + target: toolRef("api", ["method"]), sources: [{ expr: { type: "literal", value: "GET" } }], }); }); @@ -90,8 +93,8 @@ describe("tool self-wires: constant (=)", () => { .headers.Content-Type = "application/json" } `); - assertDeepStrictEqualIgnoringLoc(tool.wires[0], { - to: toolRef("api", ["headers", "Content-Type"]), + assertDeepStrictEqualIgnoringLoc(flatWires(tool.body)[0], { + target: toolRef("api", ["headers", "Content-Type"]), sources: [{ expr: { type: "literal", value: "application/json" } }], }); }); @@ -106,8 +109,8 @@ describe("tool self-wires: simple pull (<-)", () => { .headers.Authorization <- context.auth.token } `); - assertDeepStrictEqualIgnoringLoc(tool.wires[0], { - to: toolRef("api", ["headers", "Authorization"]), + assertDeepStrictEqualIgnoringLoc(flatWires(tool.body)[0], { + target: toolRef("api", ["headers", "Authorization"]), sources: [{ expr: { type: "ref", ref: contextRef(["auth", "token"]) } }], }); }); @@ -121,8 +124,8 @@ describe("tool self-wires: simple pull (<-)", () => { .timeout <- const.timeout } `); - assertDeepStrictEqualIgnoringLoc(tool.wires[0], { - to: toolRef("api", ["timeout"]), + assertDeepStrictEqualIgnoringLoc(flatWires(tool.body)[0], { + target: toolRef("api", ["timeout"]), sources: [{ expr: { type: "ref", ref: constRef(["timeout"]) } }], }); }); @@ -138,8 +141,8 @@ describe("tool self-wires: simple pull (<-)", () => { .headers.Authorization <- auth.access_token } `); - assertDeepStrictEqualIgnoringLoc(tool.wires[0], { - to: toolRef("api", ["headers", "Authorization"]), + assertDeepStrictEqualIgnoringLoc(flatWires(tool.body)[0], { + target: toolRef("api", ["headers", "Authorization"]), sources: [ { expr: { @@ -160,14 +163,56 @@ describe('tool self-wires: plain string (<- "...")', () => { .format <- "json" } `); - assertDeepStrictEqualIgnoringLoc(tool.wires[0], { - to: toolRef("api", ["format"]), + assertDeepStrictEqualIgnoringLoc(flatWires(tool.body)[0], { + target: toolRef("api", ["format"]), sources: [{ expr: { type: "literal", value: "json" } }], }); }); }); describe('tool self-wires: string interpolation (<- "...{ref}...")', () => { + test("string interpolation with pipe expression in template", () => { + const doc = parseBridge(bridge` + version 1.5 + tool upper from std.str.toUpperCase {} + tool api from httpCall { + with upper + with context + .path <- "/{upper:context.userId}/profile" + } + `); + const tool = doc.instructions + .filter((i): i is ToolDef => i.kind === "tool") + .at(-1)!; + const pathWire = flatWires(tool.body).find( + (w) => w.target.path[0] === "path", + )!; + assert.ok(pathWire, "Expected a wire targeting .path"); + assert.equal( + pathWire.sources[0]!.expr.type, + "concat", + "Expected a concat expression", + ); + const concatExpr = pathWire.sources[0]!.expr; + if (concatExpr.type === "concat") { + // parts: literal "/", pipe(ref(userId)), literal "/profile" + const pipePartIdx = concatExpr.parts.findIndex((p) => p.type === "pipe"); + assert.ok( + pipePartIdx !== -1, + "Expected a pipe expression part in concat", + ); + const pipePart = concatExpr.parts[pipePartIdx]!; + if (pipePart.type === "pipe") { + assert.equal(pipePart.handle, "upper", "pipe handle should be 'upper'"); + assert.equal( + pipePart.source.type, + "ref", + "pipe source should be a ref", + ); + } + } + }); + test("string interpolation with const ref", () => { const tool = parseTool(bridge` version 1.5 @@ -177,21 +222,16 @@ describe('tool self-wires: string interpolation (<- "...{ref}...")', () => { .path <- "/api/{const.apiVer}/search" } `); - // Should produce a concat fork + pipeHandle, similar to bridge blocks - const pathWire = tool.wires.find((w) => w.to.path[0] === "path")!; + // V3: concat expressions are first-class expression nodes + const pathWire = flatWires(tool.body).find( + (w) => w.target.path[0] === "path", + )!; assert.ok(pathWire, "Expected a wire targeting .path"); assert.equal( pathWire.sources[0]!.expr.type, - "ref", - "Expected a pull wire, not constant", - ); - // The from ref should be the concat fork output - const pathExpr = pathWire.sources[0]!.expr; - assert.equal( - pathExpr.type === "ref" ? pathExpr.ref.field : undefined, "concat", + "Expected a concat expression", ); - assert.ok(pathWire.pipe, "Expected pipe flag on interpolation wire"); }); test("string interpolation with context ref", () => { @@ -202,17 +242,14 @@ describe('tool self-wires: string interpolation (<- "...{ref}...")', () => { .path <- "/users/{context.userId}/profile" } `); - const pathWire = tool.wires.find((w) => w.to.path[0] === "path")!; + const pathWire = flatWires(tool.body).find( + (w) => w.target.path[0] === "path", + )!; assert.ok(pathWire, "Expected a wire targeting .path"); assert.equal( pathWire.sources[0]!.expr.type, - "ref", - "Expected a pull wire, not constant", - ); - const ctxPathExpr = pathWire.sources[0]!.expr; - assert.equal( - ctxPathExpr.type === "ref" ? ctxPathExpr.ref.field : undefined, "concat", + "Expected a concat expression", ); }); @@ -246,15 +283,15 @@ describe("tool self-wires: expression chain (<- ref + expr)", () => { .limit <- const.one + 1 } `); - const limitWire = tool.wires.find((w) => w.to.path[0] === "limit")!; + const limitWire = flatWires(tool.body).find( + (w) => w.target.path[0] === "limit", + )!; assert.ok(limitWire, "Expected a wire targeting .limit"); assert.equal( limitWire.sources[0]!.expr.type, - "ref", - "Expected a pull wire", + "binary", + "Expected a binary expression", ); - // Expression chains produce a pipe fork (desugared to internal.add/compare/etc.) - assert.ok(limitWire.pipe, "Expected pipe flag on expression wire"); }); test("expression with > operator", () => { @@ -266,10 +303,15 @@ describe("tool self-wires: expression chain (<- ref + expr)", () => { .verbose <- const.threshold > 5 } `); - const wire = tool.wires.find((w) => w.to.path[0] === "verbose")!; + const wire = flatWires(tool.body).find( + (w) => w.target.path[0] === "verbose", + )!; assert.ok(wire, "Expected a wire targeting .verbose"); - assert.equal(wire.sources[0]!.expr.type, "ref", "Expected a pull wire"); - assert.ok(wire.pipe, "Expected pipe flag on expression wire"); + assert.equal( + wire.sources[0]!.expr.type, + "binary", + "Expected a binary expression", + ); }); }); @@ -283,7 +325,9 @@ describe("tool self-wires: ternary (<- cond ? then : else)", () => { .method <- const.flag ? "POST" : "GET" } `); - const wire = tool.wires.find((w) => w.to.path[0] === "method")!; + const wire = flatWires(tool.body).find( + (w) => w.target.path[0] === "method", + )!; assert.ok(wire, "Expected a wire targeting .method"); // Ternary wires have sources[0].expr.type === "ternary" assert.equal( @@ -295,11 +339,11 @@ describe("tool self-wires: ternary (<- cond ? then : else)", () => { if (expr.type === "ternary") { assert.equal( expr.then.type === "literal" ? expr.then.value : undefined, - '"POST"', + "POST", ); assert.equal( expr.else.type === "literal" ? expr.else.value : undefined, - '"GET"', + "GET", ); } }); @@ -315,7 +359,9 @@ describe("tool self-wires: ternary (<- cond ? then : else)", () => { .baseUrl <- const.flag ? const.urlA : const.urlB } `); - const wire = tool.wires.find((w) => w.to.path[0] === "baseUrl")!; + const wire = flatWires(tool.body).find( + (w) => w.target.path[0] === "baseUrl", + )!; assert.ok(wire, "Expected a wire targeting .baseUrl"); assert.equal( wire.sources[0]!.expr.type, @@ -339,7 +385,9 @@ describe("tool self-wires: coalesce (<- ref ?? fallback)", () => { .timeout <- context.settings.timeout ?? "5000" } `); - const wire = tool.wires.find((w) => w.to.path[0] === "timeout")!; + const wire = flatWires(tool.body).find( + (w) => w.target.path[0] === "timeout", + )!; assert.ok(wire, "Expected a wire targeting .timeout"); assert.equal(wire.sources[0]!.expr.type, "ref", "Expected a pull wire"); assert.ok(wire.sources.length >= 2, "Expected fallbacks for coalesce"); @@ -348,7 +396,7 @@ describe("tool self-wires: coalesce (<- ref ?? fallback)", () => { wire.sources[1]!.expr.type === "literal" ? wire.sources[1]!.expr.value : undefined, - '"5000"', + "5000", ); }); @@ -360,7 +408,9 @@ describe("tool self-wires: coalesce (<- ref ?? fallback)", () => { .format <- context.settings.format || "json" } `); - const wire = tool.wires.find((w) => w.to.path[0] === "format")!; + const wire = flatWires(tool.body).find( + (w) => w.target.path[0] === "format", + )!; assert.ok(wire, "Expected a wire targeting .format"); assert.ok(wire.sources.length >= 2, "Expected fallbacks for coalesce"); assert.equal(wire.sources[1]!.gate, "falsy"); @@ -376,13 +426,13 @@ describe("tool self-wires: catch fallback", () => { .path <- context.settings.path catch "/default" } `); - const wire = tool.wires.find((w) => w.to.path[0] === "path")!; + const wire = flatWires(tool.body).find((w) => w.target.path[0] === "path")!; assert.ok(wire, "Expected a wire targeting .path"); assert.equal(wire.sources[0]!.expr.type, "ref", "Expected a pull wire"); assert.ok(wire.catch, "Expected catch handler"); assert.equal( "value" in wire.catch ? wire.catch.value : undefined, - '"/default"', + "/default", ); }); }); @@ -397,11 +447,15 @@ describe("tool self-wires: not prefix", () => { .silent <- not const.debug } `); - const wire = tool.wires.find((w) => w.to.path[0] === "silent")!; + const wire = flatWires(tool.body).find( + (w) => w.target.path[0] === "silent", + )!; assert.ok(wire, "Expected a wire targeting .silent"); - assert.equal(wire.sources[0]!.expr.type, "ref", "Expected a pull wire"); - // `not` produces a pipe fork through the negation tool - assert.ok(wire.pipe, "Expected pipe flag on not wire"); + assert.equal( + wire.sources[0]!.expr.type, + "unary", + "Expected a unary expression", + ); }); }); @@ -422,9 +476,9 @@ describe("tool self-wires: integration", () => { assert.equal(tool.fn, "std.httpCall"); // 3 constants + expression fork wires (input to fork + constant operand + pipe output) assert.ok( - tool.wires.length >= 4, - `Expected at least 4 wires, got ${tool.wires.length}: ${JSON.stringify( - tool.wires.map((w) => + flatWires(tool.body).length >= 4, + `Expected at least 4 wires, got ${flatWires(tool.body).length}: ${JSON.stringify( + flatWires(tool.body).map((w) => w.sources[0]?.expr.type === "literal" ? w.sources[0].expr.value : "pull", @@ -435,8 +489,8 @@ describe("tool self-wires: integration", () => { ); // First 3 are constants - assertDeepStrictEqualIgnoringLoc(tool.wires[0], { - to: toolRef("geo", ["baseUrl"]), + assertDeepStrictEqualIgnoringLoc(flatWires(tool.body)[0], { + target: toolRef("geo", ["baseUrl"]), sources: [ { expr: { @@ -446,25 +500,24 @@ describe("tool self-wires: integration", () => { }, ], }); - assertDeepStrictEqualIgnoringLoc(tool.wires[1], { - to: toolRef("geo", ["path"]), + assertDeepStrictEqualIgnoringLoc(flatWires(tool.body)[1], { + target: toolRef("geo", ["path"]), sources: [{ expr: { type: "literal", value: "/search" } }], }); - assertDeepStrictEqualIgnoringLoc(tool.wires[2], { - to: toolRef("geo", ["format"]), + assertDeepStrictEqualIgnoringLoc(flatWires(tool.body)[2], { + target: toolRef("geo", ["format"]), sources: [{ expr: { type: "literal", value: "json" } }], }); // Expression wire targets .limit (with internal fork wires before it) - const limitWire = tool.wires.find( - (w) => w.to.field === "geo" && w.to.path?.[0] === "limit", + const limitWire = flatWires(tool.body).find( + (w) => w.target.field === "geo" && w.target.path?.[0] === "limit", ); assert.ok(limitWire, "Expected a wire targeting geo.limit"); assert.equal( limitWire.sources[0]!.expr.type, - "ref", - "Expected limit wire to be a pull wire", + "binary", + "Expected limit wire to have a binary expression", ); - assert.ok(limitWire.pipe, "Expected pipe flag on expression wire"); }); }); diff --git a/packages/bridge-parser/test/utils/parse-test-utils.ts b/packages/bridge-parser/test/utils/parse-test-utils.ts index 118c68a0..8809734e 100644 --- a/packages/bridge-parser/test/utils/parse-test-utils.ts +++ b/packages/bridge-parser/test/utils/parse-test-utils.ts @@ -1,4 +1,15 @@ import assert from "node:assert/strict"; +import type { Statement, WireSourceEntry, WireCatch, NodeRef, SourceLocation } from "@stackables/bridge-core"; +import type { ForceStatement } from "@stackables/bridge-core"; + +/** Flattened wire result — mirrors WireStatement but with path prefix folded into target. */ +export type FlatWire = { + target: NodeRef; + sources: WireSourceEntry[]; + catch?: WireCatch; + loc?: SourceLocation; + spread?: true; +}; function omitLoc(value: unknown): unknown { if (Array.isArray(value)) { @@ -12,7 +23,8 @@ function omitLoc(value: unknown): unknown { key === "loc" || key.endsWith("Loc") || key === "source" || - key === "filename" + key === "filename" || + key === "body" ) { continue; } @@ -31,3 +43,81 @@ export function assertDeepStrictEqualIgnoringLoc( ): void { assert.deepStrictEqual(omitLoc(actual), omitLoc(expected), message); } + +/** + * Extract Wire-compatible objects from a body Statement[] tree. + * Folds scope path prefixes into each wire's target path. + */ +export function flatWires( + stmts: Statement[], + pathPrefix: string[] = [], + isElement?: boolean, +): FlatWire[] { + const result: FlatWire[] = []; + for (const s of stmts) { + if (s.kind === "wire") { + const target = + pathPrefix.length > 0 || isElement + ? { + ...s.target, + path: [...pathPrefix, ...s.target.path], + ...(isElement ? { element: true } : {}), + } + : s.target; + const w: FlatWire = { target, sources: s.sources }; + if (s.catch) w.catch = s.catch; + if (s.loc) w.loc = s.loc; + result.push(w); + // Recurse into array expression bodies — children are element wires + for (const src of s.sources) { + if (src.expr.type === "array" && src.expr.body) { + result.push( + ...flatWires( + src.expr.body, + [...pathPrefix, ...s.target.path], + true, + ), + ); + } + } + } else if (s.kind === "spread") { + const target = + pathPrefix.length > 0 + ? { module: "", type: "", field: "", path: [...pathPrefix] } + : { module: "", type: "", field: "" as string, path: [] as string[] }; + const w: FlatWire = { + target, + sources: s.sources, + spread: true, + }; + if (s.catch) w.catch = s.catch; + if (s.loc) w.loc = s.loc; + result.push(w); + } else if (s.kind === "scope") { + result.push( + ...flatWires( + s.body, + [...pathPrefix, ...s.target.path], + isElement || s.target.element, + ), + ); + } + } + return result; +} + +/** + * Extract ForceStatement entries from a body Statement[] tree. + * Returns them in declaration order so tests can assert by index. + */ +export function flatForces(stmts: Statement[]): ForceStatement[] { + const result: ForceStatement[] = []; + for (const s of stmts) { + if (s.kind === "force") { + result.push(s); + } else if (s.kind === "scope") { + result.push(...flatForces(s.body)); + } + } + return result; +} diff --git a/packages/bridge/test/alias.test.ts b/packages/bridge/test/alias.test.ts index 32888f18..b186b9ae 100644 --- a/packages/bridge/test/alias.test.ts +++ b/packages/bridge/test/alias.test.ts @@ -14,19 +14,94 @@ regressionTest("alias keyword", { bridge: bridge` version 1.5 - bridge Alias.syntax { - with test.multitool as object - with input as i + bridge Array.is_wire { with output as o + with context as c + with test.multitool as echo - # Simple alias with fallback and catch - alias user_info <- object?.user.info || i.info catch "Unknown" + echo.items <- c.items + + o.arrayWithFallback <- echo.items[] as i { + .value <- i.value || "Fallback 1" + } || c.realArray[] as i { + .value <- i.value || "Fallback 2" + } catch [] - o.info <- user_info } + `, tools: tools, scenarios: { - "Alias.syntax": {}, + "Array.is_wire": { + "primary tool array present — uses first mapping": { + allowDowngrade: true, + context: { + items: [{ value: "A" }, { value: undefined }], + realArray: [{ value: "should not appear" }], + }, + input: {}, + assertData: { + arrayWithFallback: [{ value: "A" }, { value: "Fallback 1" }], + }, + assertTraces: 1, + }, + "primary tool returns null — falls through to second array": { + allowDowngrade: true, + context: { + items: undefined, + realArray: [{ value: "Real value" }, { value: undefined }], + }, + input: {}, + assertData: { + arrayWithFallback: [{ value: "Real value" }, { value: "Fallback 2" }], + }, + assertTraces: 1, + }, + "primary is empty array — stays empty (truthy)": { + allowDowngrade: true, + context: { + items: [], + realArray: [{ value: "B" }], + }, + input: {}, + assertData: { + arrayWithFallback: [], + }, + assertTraces: 1, + }, + "both null — result is null": { + allowDowngrade: true, + context: { + items: undefined, + realArray: undefined, + }, + input: {}, + assertData: { + arrayWithFallback: null, + }, + assertTraces: 1, + }, + "tool errors — catch fires": { + allowDowngrade: true, + context: { + items: "will cause _error", + realArray: undefined, + }, + tools: { + "test.multitool": (() => { + const fn = () => { + throw new Error("forced"); + }; + fn.bridge = { sync: true }; + return fn; + })(), + }, + input: {}, + assertData: { + arrayWithFallback: [], + }, + assertTraces: 1, + }, + }, }, }); diff --git a/packages/bridge/test/bugfixes/fallback-bug.test.ts b/packages/bridge/test/bugfixes/fallback-bug.test.ts index dd75c3c3..41288ece 100644 --- a/packages/bridge/test/bugfixes/fallback-bug.test.ts +++ b/packages/bridge/test/bugfixes/fallback-bug.test.ts @@ -42,7 +42,7 @@ regressionTest("string interpolation || fallback priority", { a: { displayName: "Alice (alice@test.com)" }, name: "Alice", }, - allowDowngrade: true, + assertData: { flat: "Alice (alice@test.com)", scoped: "Alice (alice@test.com)", @@ -52,21 +52,21 @@ regressionTest("string interpolation || fallback priority", { }, "a null → flat and scoped fall back to i.name": { input: { a: {}, name: "Alice" }, - allowDowngrade: true, + fields: ["flat", "scoped"], assertData: { flat: "Alice", scoped: "Alice" }, assertTraces: 1, }, "a null → second tool fires in chained": { input: { a: {}, b: { displayName: "ALICE" } }, - allowDowngrade: true, + fields: ["chained"], assertData: { chained: "ALICE" }, assertTraces: 2, }, "all sources null → literal fires on chained": { input: { a: {}, b: {} }, - allowDowngrade: true, + fields: ["chained"], assertData: { chained: "test" }, assertTraces: 2, diff --git a/packages/bridge/test/bugfixes/nested-scope-in-array.test.ts b/packages/bridge/test/bugfixes/nested-scope-in-array.test.ts new file mode 100644 index 00000000..3beb651e --- /dev/null +++ b/packages/bridge/test/bugfixes/nested-scope-in-array.test.ts @@ -0,0 +1,133 @@ +import { regressionTest } from "../utils/regression.ts"; + +// ═══════════════════════════════════════════════════════════════════════════ +// Scope block inside nested array body — regression +// +// Bug report (v1.5): When a scope block (e.g. `.destination { .station { } }`) +// appears inside a nested array body (e.g. `[] as s { ... }`), the wires +// inside the scope block are not flagged as element-scoped. They write to the +// root output instead of the per-element output, so the path-scoped section +// is entirely missing from results. +// +// A secondary symptom is that pipe expressions (`uc:s.departure.station.name`) +// whose handle is declared at bridge scope (not inside the array body) don't +// show up as "used" in tracing when the pipe is inside scope blocks in arrays. +// ═══════════════════════════════════════════════════════════════════════════ + +regressionTest("nested scope block inside nested array body", { + bridge: ` + version 1.5 + + bridge Query.searchTrains { + with context as ctx + with output as o + with std.str.toUpperCase as uc + + o <- ctx.connections[] as c { + .legs <- c.sections[] as s { + .origin.station.id <- s.departure.station.id + .origin.station.name <- uc:s.departure.station.name + + .destination { + .station { + .id <- s.arrival.station.id + .name <- uc:s.arrival.station.name + } + } + } + } + } + `, + scenarios: { + "Query.searchTrains": { + "scope block inside inner array body produces destination section": { + input: {}, + context: { + connections: [ + { + sections: [ + { + departure: { station: { id: "dep1", name: "bern" } }, + arrival: { station: { id: "arr1", name: "zurich" } }, + }, + ], + }, + ], + }, + assertData: [ + { + legs: [ + { + origin: { station: { id: "dep1", name: "BERN" } }, + destination: { station: { id: "arr1", name: "ZURICH" } }, + }, + ], + }, + ], + assertTraces: 0, + }, + "empty connections array": { + input: {}, + context: { connections: [] }, + assertData: [], + assertTraces: 0, + }, + "connection with empty sections": { + input: {}, + context: { connections: [{ sections: [] }] }, + assertData: [{ legs: [] }], + assertTraces: 0, + }, + "multiple connections and sections": { + input: {}, + context: { + connections: [ + { + sections: [ + { + departure: { station: { id: "a", name: "alpha" } }, + arrival: { station: { id: "b", name: "beta" } }, + }, + { + departure: { station: { id: "c", name: "gamma" } }, + arrival: { station: { id: "d", name: "delta" } }, + }, + ], + }, + { + sections: [ + { + departure: { station: { id: "e", name: "epsilon" } }, + arrival: { station: { id: "f", name: "zeta" } }, + }, + ], + }, + ], + }, + assertData: [ + { + legs: [ + { + origin: { station: { id: "a", name: "ALPHA" } }, + destination: { station: { id: "b", name: "BETA" } }, + }, + { + origin: { station: { id: "c", name: "GAMMA" } }, + destination: { station: { id: "d", name: "DELTA" } }, + }, + ], + }, + { + legs: [ + { + origin: { station: { id: "e", name: "EPSILON" } }, + destination: { station: { id: "f", name: "ZETA" } }, + }, + ], + }, + ], + assertTraces: 0, + }, + }, + }, +}); diff --git a/packages/bridge/test/bugfixes/overdef-input-race.test.ts b/packages/bridge/test/bugfixes/overdef-input-race.test.ts new file mode 100644 index 00000000..2a4984a8 --- /dev/null +++ b/packages/bridge/test/bugfixes/overdef-input-race.test.ts @@ -0,0 +1,81 @@ +import { regressionTest } from "../utils/regression.ts"; +import { tools } from "../utils/bridge-tools.ts"; +import { bridge } from "@stackables/bridge"; + +// ═══════════════════════════════════════════════════════════════════════════ +// Overdefined tool-input race condition regression test +// +// When two wires target the same tool-input path (overdefinition), the engine +// must try them in cost order and short-circuit on the first non-nullish value. +// +// BUG: callTool (and evaluatePipeExpression / executeDefine) fired ALL input +// wires in parallel via `Promise.all`. When `weather.lat <- i.latitude` and +// `weather.lat <- geo.lat` both existed, the geo tool was triggered even when +// `i.latitude` was provided — and `geo.q <- i.city || panic "need city"` +// panicked because city was not in the input. +// +// Two failing inputs: +// 1. { latitude: 47.37, longitude: 8.55 } — coords provided, geo panics +// 2. { city: "Zurich" } — no coords, geo should fire +// ═══════════════════════════════════════════════════════════════════════════ + +regressionTest("overdefined tool-input: panic race condition", { + bridge: bridge` + version 1.5 + + const coords = { + "lat": 47, + "lon": 8 + } + + bridge CoordOverdef.lookup { + with test.multitool as geo + with test.multitool as weather + with const + with input as i + with output as o + + # geo requires city — panics when absent + geo.q <- i.city || panic "city is required for geocoding" + # Feed const coords so multitool echoes them back as geo.lat / geo.lon + geo.lat <- const.coords.lat + geo.lon <- const.coords.lon + + # Overdefined: direct input (cost 0) beats geo tool ref (cost 2) + weather.lat <- i.latitude + weather.lat <- geo.lat + + weather.lon <- i.longitude + weather.lon <- geo.lon + + o.lat <- weather.lat + o.lon <- weather.lon + } + `, + tools: tools, + scenarios: { + "CoordOverdef.lookup": { + "direct coords provided — geo must not fire (would panic)": { + input: { latitude: 10, longitude: 20 }, + assertData: { lat: 10, lon: 20 }, + assertTraces: 1, // only weather tool called, geo skipped + }, + "city provided — geo fires, coords come from geo result": { + input: { city: "Zurich" }, + assertData: { lat: 47, lon: 8 }, + assertTraces: 2, // geo + weather + }, + "both provided — direct coords win (cheaper), geo skipped": { + input: { latitude: 1, longitude: 2, city: "Zurich" }, + assertData: { lat: 1, lon: 2 }, + assertTraces: 1, // only weather + }, + "neither coords nor city — panic fires": { + input: {}, + assertError: /city is required for geocoding/, + assertTraces: 0, + assertGraphql: () => {}, + }, + }, + }, +}); diff --git a/packages/bridge/test/bugfixes/passthrough-define-input.test.ts b/packages/bridge/test/bugfixes/passthrough-define-input.test.ts new file mode 100644 index 00000000..85d64f90 --- /dev/null +++ b/packages/bridge/test/bugfixes/passthrough-define-input.test.ts @@ -0,0 +1,96 @@ +import { regressionTest } from "../utils/regression.ts"; +import { bridge } from "@stackables/bridge"; + +// ═══════════════════════════════════════════════════════════════════════════ +// Passthrough bridge + define: lazy input resolution regression test +// +// The `bridge Query.X with defineName` syntax creates a passthrough bridge +// that delegates entirely to a define block. The define's inputs are +// registered as lazy factories under an empty pathKey (""). +// +// BUG 1: resolveLazyInput parent-path lookup used `len >= 1`, so the loop +// never reached `len = 0` to find the lazy factory at key "" — meaning the +// define's inputs were never hydrated. +// +// BUG 2: `"".split(".")` returns `[""]` not `[]`, so `setPath(selfInput, +// [""], value)` set `selfInput[""] = value` instead of merging the define's +// resolved value into the root `selfInput` object. +// +// Result: passthrough bridges silently dropped all input fields — the define +// block received an empty object. +// ═══════════════════════════════════════════════════════════════════════════ + +regressionTest("passthrough bridge with define: lazy input resolution", { + bridge: bridge` + version 1.5 + + define weatherLookup { + with weatherApi as w + with input as i + with output as o + + w.lat <- i.latitude + w.lon <- i.longitude + + o.temperature <- w.temp + o.lat <- i.latitude + o.lon <- i.longitude + } + + # Passthrough: entire bridge forwards to the define + bridge Query.weatherPassthrough with weatherLookup + + # Control: same define used with explicit wiring (always worked) + bridge Query.weatherExplicit { + with weatherLookup as wl + with input as i + with output as o + + wl.latitude <- i.latitude + wl.longitude <- i.longitude + o <- wl + } + `, + scenarios: { + "Query.weatherPassthrough": { + "passthrough forwards all input fields to define": { + input: { latitude: 47.37, longitude: 8.55 }, + tools: { + weatherApi: async (input: any) => ({ + temp: 18.5, + lat: input.lat, + lon: input.lon, + }), + }, + assertData: { temperature: 18.5, lat: 47.37, lon: 8.55 }, + assertTraces: 1, + }, + "passthrough with nested input fields": { + input: { latitude: -33.87, longitude: 151.21 }, + tools: { + weatherApi: async (input: any) => ({ + temp: 25.0, + lat: input.lat, + lon: input.lon, + }), + }, + assertData: { temperature: 25.0, lat: -33.87, lon: 151.21 }, + assertTraces: 1, + }, + }, + "Query.weatherExplicit": { + "explicit wiring works (control case)": { + input: { latitude: 47.37, longitude: 8.55 }, + tools: { + weatherApi: async (input: any) => ({ + temp: 18.5, + lat: input.lat, + lon: input.lon, + }), + }, + assertData: { temperature: 18.5, lat: 47.37, lon: 8.55 }, + assertTraces: 1, + }, + }, + }, +}); diff --git a/packages/bridge/test/bugfixes/trace-tooldef-names.test.ts b/packages/bridge/test/bugfixes/trace-tooldef-names.test.ts index b42b6b66..eba8f990 100644 --- a/packages/bridge/test/bugfixes/trace-tooldef-names.test.ts +++ b/packages/bridge/test/bugfixes/trace-tooldef-names.test.ts @@ -18,10 +18,22 @@ import { regressionTest, type AssertContext } from "../utils/regression.ts"; function assertTraceShape(traces: ToolTrace[]) { for (const t of traces) { - assert.ok(typeof t.tool === "string" && t.tool.length > 0, "tool field must be a non-empty string"); - assert.ok(typeof t.fn === "string" && t.fn.length > 0, "fn field must be a non-empty string"); - assert.ok(typeof t.durationMs === "number" && t.durationMs >= 0, "durationMs must be non-negative"); - assert.ok(typeof t.startedAt === "number" && t.startedAt >= 0, "startedAt must be non-negative"); + assert.ok( + typeof t.tool === "string" && t.tool.length > 0, + "tool field must be a non-empty string", + ); + assert.ok( + typeof t.fn === "string" && t.fn.length > 0, + "fn field must be a non-empty string", + ); + assert.ok( + typeof t.durationMs === "number" && t.durationMs >= 0, + "durationMs must be non-negative", + ); + assert.ok( + typeof t.startedAt === "number" && t.startedAt >= 0, + "startedAt must be non-negative", + ); // full trace level → input + output present on success assert.ok("input" in t, "input field must be present at full trace level"); assert.ok("output" in t || "error" in t, "output or error must be present"); @@ -57,8 +69,16 @@ regressionTest("trace: ToolDef name preserved in trace", { assert.equal(traces.length, 1); assertTraceShape(traces); const t = traces[0]!; - assert.equal(t.tool, "apiA", `[${ctx.engine}] tool field should be ToolDef name "apiA"`); - assert.equal(t.fn, "test.multitool", `[${ctx.engine}] fn field should be underlying function "test.multitool"`); + assert.equal( + t.tool, + "apiA", + `[${ctx.engine}] tool field should be ToolDef name "apiA"`, + ); + assert.equal( + t.fn, + "test.multitool", + `[${ctx.engine}] fn field should be underlying function "test.multitool"`, + ); }, }, }, @@ -105,10 +125,24 @@ regressionTest("trace: multiple ToolDefs from same fn are distinguishable", { assertTraceShape(traces); const alphaTrace = traces.find((t) => t.tool === "alpha"); const betaTrace = traces.find((t) => t.tool === "beta"); - assert.ok(alphaTrace, `[${ctx.engine}] expected trace with tool="alpha"`); - assert.ok(betaTrace, `[${ctx.engine}] expected trace with tool="beta"`); - assert.equal(alphaTrace.fn, "test.multitool", `[${ctx.engine}] alpha.fn`); - assert.equal(betaTrace.fn, "test.multitool", `[${ctx.engine}] beta.fn`); + assert.ok( + alphaTrace, + `[${ctx.engine}] expected trace with tool="alpha"`, + ); + assert.ok( + betaTrace, + `[${ctx.engine}] expected trace with tool="beta"`, + ); + assert.equal( + alphaTrace.fn, + "test.multitool", + `[${ctx.engine}] alpha.fn`, + ); + assert.equal( + betaTrace.fn, + "test.multitool", + `[${ctx.engine}] beta.fn`, + ); }, }, }, @@ -186,8 +220,16 @@ regressionTest("trace: ToolDef in define block preserves name", { assert.equal(traces.length, 1); assertTraceShape(traces); const t = traces[0]!; - assert.equal(t.tool, "enricher", `[${ctx.engine}] tool field should be "enricher"`); - assert.equal(t.fn, "test.multitool", `[${ctx.engine}] fn field should be "test.multitool"`); + assert.equal( + t.tool, + "enricher", + `[${ctx.engine}] tool field should be "enricher"`, + ); + assert.equal( + t.fn, + "test.multitool", + `[${ctx.engine}] fn field should be "test.multitool"`, + ); }, }, }, diff --git a/packages/bridge/test/builtin-tools.test.ts b/packages/bridge/test/builtin-tools.test.ts index a0978be9..3449b953 100644 --- a/packages/bridge/test/builtin-tools.test.ts +++ b/packages/bridge/test/builtin-tools.test.ts @@ -3,6 +3,7 @@ import { describe } from "node:test"; import { std } from "@stackables/bridge-stdlib"; import { regressionTest } from "./utils/regression.ts"; import { bridge } from "@stackables/bridge"; +import { assertRuntimeErrorAt } from "./utils/error-utils.ts"; // ── String builtins ───────────────────────────────────────────────────────── // Single bridge exercises toUpperCase, toLowerCase, trim, length all at once. @@ -58,7 +59,7 @@ describe("builtin tools", () => { tools: { std: { somethingElse: () => ({}) }, }, - assertError: /BridgeRuntimeError/, + assertError: assertRuntimeErrorAt("up:i.text"), assertTraces: 0, }, "uppercase tool failure propagates": { @@ -164,7 +165,7 @@ describe("builtin tools", () => { throw new Error("db.users error"); }, }, - assertError: /BridgeRuntimeError/, + assertError: assertRuntimeErrorAt("db.users"), assertTraces: 1, }, }, @@ -212,7 +213,7 @@ describe("builtin tools", () => { throw new Error("db.users error"); }, }, - assertError: /BridgeRuntimeError/, + assertError: assertRuntimeErrorAt("db.users"), assertTraces: 1, }, "find tool failure propagates to projected fields": { @@ -268,7 +269,7 @@ describe("builtin tools", () => { }, }, }, - assertError: /BridgeRuntimeError/, + assertError: assertRuntimeErrorAt("pf:i.items"), assertTraces: 1, }, }, diff --git a/packages/bridge/test/coalesce-cost.test.ts b/packages/bridge/test/coalesce-cost.test.ts index cfeb0c03..ca522032 100644 --- a/packages/bridge/test/coalesce-cost.test.ts +++ b/packages/bridge/test/coalesce-cost.test.ts @@ -1,6 +1,7 @@ import { regressionTest } from "./utils/regression.ts"; import { tools } from "./utils/bridge-tools.ts"; import { bridge } from "@stackables/bridge"; +import { assertRuntimeErrorAt } from "./utils/error-utils.ts"; // ═══════════════════════════════════════════════════════════════════════════ // Coalesce & cost-based resolution @@ -42,7 +43,6 @@ regressionTest("|| fallback chains", { "Fallback.lookup": { "a truthy → short-circuits all chains": { input: { a: { label: "A" } }, - allowDowngrade: true, assertData: { twoSource: "A", threeSource: "A", @@ -53,7 +53,6 @@ regressionTest("|| fallback chains", { }, "a null, b truthy → b wins": { input: { b: { label: "B" } }, - allowDowngrade: true, assertData: { twoSource: "B", threeSource: "B", @@ -64,7 +63,6 @@ regressionTest("|| fallback chains", { }, "all null → literal / third source fire": { input: { c: { label: "C" } }, - allowDowngrade: true, assertData: { threeSource: "C", withLiteral: "default", @@ -74,15 +72,13 @@ regressionTest("|| fallback chains", { }, "a throws → error propagates on twoSource, catch fires on withCatch": { input: { a: { _error: "boom" } }, - allowDowngrade: true, fields: ["withCatch"], assertData: { withCatch: "error-default" }, assertTraces: 1, }, "a throws → uncaught wires fail": { input: { a: { _error: "boom" } }, - allowDowngrade: true, - assertError: /BridgeRuntimeError/, + assertError: assertRuntimeErrorAt("a.label"), assertTraces: 1, assertGraphql: { twoSource: /boom/i, @@ -93,8 +89,7 @@ regressionTest("|| fallback chains", { }, "b throws → fallback error propagates": { input: { b: { _error: "boom" } }, - allowDowngrade: true, - assertError: /BridgeRuntimeError/, + assertError: assertRuntimeErrorAt("b.label"), assertTraces: 2, assertGraphql: { twoSource: /boom/i, @@ -105,8 +100,7 @@ regressionTest("|| fallback chains", { }, "c throws → third-position fallback error": { input: { c: { _error: "boom" } }, - allowDowngrade: true, - assertError: /BridgeRuntimeError/, + assertError: assertRuntimeErrorAt("c.label"), assertTraces: 3, assertGraphql: { twoSource: null, @@ -193,14 +187,12 @@ regressionTest("overdefinition: cost-based prioritization", { }, "same-cost tools use authored order": { input: { a: { label: "from-A" }, b: { label: "from-B" } }, - allowDowngrade: true, fields: ["sameCost"], assertData: { sameCost: "from-A" }, assertTraces: 1, }, "first same-cost null → second fires": { input: { b: { label: "from-B" } }, - allowDowngrade: true, fields: ["sameCost"], assertData: { sameCost: "from-B" }, assertTraces: 2, @@ -208,22 +200,21 @@ regressionTest("overdefinition: cost-based prioritization", { "api throws → error when no cheaper override": { input: { api: { _error: "boom" } }, fields: ["inputBeats"], - assertError: /BridgeRuntimeError/, + assertError: assertRuntimeErrorAt("api.label"), assertTraces: 1, assertGraphql: () => {}, }, "api throws → contextBeats error": { input: { api: { _error: "boom" } }, fields: ["contextBeats"], - assertError: /BridgeRuntimeError/, + assertError: assertRuntimeErrorAt("api.label"), assertTraces: 1, assertGraphql: () => {}, }, "a throws → sameCost error": { input: { a: { _error: "boom" } }, - allowDowngrade: true, fields: ["sameCost"], - assertError: /BridgeRuntimeError/, + assertError: assertRuntimeErrorAt("a.label"), assertTraces: 2, assertGraphql: { sameCost: /boom/i, @@ -231,9 +222,8 @@ regressionTest("overdefinition: cost-based prioritization", { }, "a null, b throws → sameCost fails": { input: { b: { _error: "boom" } }, - allowDowngrade: true, fields: ["sameCost"], - assertError: /BridgeRuntimeError/, + assertError: assertRuntimeErrorAt("b.label"), assertTraces: 2, assertGraphql: { sameCost: /boom/i, @@ -243,20 +233,17 @@ regressionTest("overdefinition: cost-based prioritization", { "AliasOverdef.lookup": { "alias treated as zero-cost": { input: { api: { label: "expensive" }, hint: "cached" }, - allowDowngrade: true, assertData: { label: "cached" }, assertTraces: 0, }, "alias null → tool fires": { input: { api: { label: "from-api" } }, - allowDowngrade: true, assertData: { label: "from-api" }, assertTraces: 1, }, "api throws → error when alias null": { input: { api: { _error: "boom" } }, - allowDowngrade: true, - assertError: /BridgeRuntimeError/, + assertError: assertRuntimeErrorAt("api.label"), assertTraces: 1, assertGraphql: { label: /boom/i, @@ -290,14 +277,12 @@ regressionTest("overdefinition: sync beats async", { "SyncAsync.lookup": { "sync tool (cost 1) tried before async (cost 2)": { input: { data: { label: "hello" } }, - allowDowngrade: true, assertData: { label: "hello" }, // sync tool fires first (cost 1) and succeeds → async never called assertTraces: 1, }, "sync null → async fires": { input: { data: {} }, - allowDowngrade: true, assertData: { label: undefined }, assertTraces: 2, }, @@ -327,13 +312,11 @@ regressionTest("overdefinition: explicit cost override", { "ExplCost.lookup": { "cost-0 tool tried before async tool": { input: { data: { label: "win" } }, - allowDowngrade: true, assertData: { label: "win" }, assertTraces: 1, }, "cost-0 null → async fires": { input: { data: {} }, - allowDowngrade: true, assertData: { label: undefined }, assertTraces: 2, }, @@ -365,7 +348,7 @@ regressionTest("?. safe execution modifier", { o.bare <- a?.label o.withLiteral <- a?.label || "fallback" o.withToolFallback <- a?.label || b.label || "last-resort" - o.constChained <- const.lorem.ipsums?.kala || "A" || "B" + o.constChained <- const.lorem.ipsums?.kala || "A" o.constMixed <- const.lorem.kala || const.lorem.ipsums?.mees ?? "C" } `, @@ -374,7 +357,6 @@ regressionTest("?. safe execution modifier", { "Safe.lookup": { "tool throws → ?. swallows, fallbacks fire": { input: { a: { _error: "HTTP 500" } }, - allowDowngrade: true, fields: ["bare", "withLiteral", "withToolFallback"], assertData: { withLiteral: "fallback", @@ -384,7 +366,6 @@ regressionTest("?. safe execution modifier", { }, "tool succeeds → value passes through": { input: { a: { label: "OK" } }, - allowDowngrade: true, fields: ["bare", "withLiteral", "withToolFallback"], assertData: { bare: "OK", @@ -395,7 +376,6 @@ regressionTest("?. safe execution modifier", { }, "?. on non-existent const paths": { input: {}, - allowDowngrade: true, fields: ["constChained", "constMixed"], assertData: { constChained: "A", @@ -405,9 +385,8 @@ regressionTest("?. safe execution modifier", { }, "b throws in fallback position → error propagates": { input: { a: { _error: "any" }, b: { _error: "boom" } }, - allowDowngrade: true, fields: ["withToolFallback"], - assertError: /BridgeRuntimeError/, + assertError: assertRuntimeErrorAt("b.label"), assertTraces: 2, assertGraphql: { withToolFallback: /boom/i, @@ -444,7 +423,6 @@ regressionTest("mixed || and ?? chains", { "Mixed.lookup": { "a truthy → all chains short-circuit": { input: { a: { label: "A" } }, - allowDowngrade: true, assertData: { nullishThenFalsy: "A", falsyThenNullish: "A", @@ -454,7 +432,6 @@ regressionTest("mixed || and ?? chains", { }, "a null, b truthy → b wins nullish/falsy gates": { input: { b: { label: "B" } }, - allowDowngrade: true, fields: ["nullishThenFalsy", "falsyThenNullish"], assertData: { nullishThenFalsy: "B", @@ -464,7 +441,6 @@ regressionTest("mixed || and ?? chains", { }, "a null, b falsy → both chains fall through ?? but diverge at ||": { input: { b: { label: "" } }, - allowDowngrade: true, fields: ["nullishThenFalsy", "falsyThenNullish"], assertData: { nullishThenFalsy: "fallback", // ?? passes b="", then || drops it @@ -474,7 +450,6 @@ regressionTest("mixed || and ?? chains", { }, 'a="", b null → ?? keeps a but || still drops it': { input: { a: { label: "" } }, - allowDowngrade: true, fields: ["nullishThenFalsy", "falsyThenNullish"], assertData: { nullishThenFalsy: "fallback", // ?? keeps "", but || drops it @@ -484,22 +459,19 @@ regressionTest("mixed || and ?? chains", { }, "four-item: all fall through → literal": { input: { b: { label: 0 } }, - allowDowngrade: true, fields: ["fourItem"], assertData: { fourItem: "last" }, assertTraces: 3, }, "four-item: c truthy → stops at c": { input: { b: { label: 0 }, c: { label: "C" } }, - allowDowngrade: true, fields: ["fourItem"], assertData: { fourItem: "C" }, assertTraces: 3, }, "a throws → error on all wires": { input: { a: { _error: "boom" } }, - allowDowngrade: true, - assertError: /BridgeRuntimeError/, + assertError: assertRuntimeErrorAt("a.label"), assertTraces: 1, assertGraphql: { nullishThenFalsy: /boom/i, @@ -509,8 +481,7 @@ regressionTest("mixed || and ?? chains", { }, "b throws → fallback error": { input: { b: { _error: "boom" } }, - allowDowngrade: true, - assertError: /BridgeRuntimeError/, + assertError: assertRuntimeErrorAt("b.label"), assertTraces: 2, assertGraphql: { nullishThenFalsy: /boom/i, @@ -520,9 +491,8 @@ regressionTest("mixed || and ?? chains", { }, "c throws → fallback:1 error on fourItem": { input: { c: { _error: "boom" } }, - allowDowngrade: true, fields: ["fourItem"], - assertError: /BridgeRuntimeError/, + assertError: assertRuntimeErrorAt("c.label"), assertTraces: 3, assertGraphql: { fourItem: /boom/i, diff --git a/packages/bridge/test/control-flow.test.ts b/packages/bridge/test/control-flow.test.ts index b7507545..73e967b4 100644 --- a/packages/bridge/test/control-flow.test.ts +++ b/packages/bridge/test/control-flow.test.ts @@ -131,6 +131,7 @@ regressionTest("panic control flow", { }, "null name → basic panics, tool fields succeed": { input: { a: { name: "ok" } }, + assertError: (err: any) => { assert.ok(err instanceof BridgePanicError); assert.equal(err.message, "fatal error"); diff --git a/packages/bridge/test/execute-bridge.test.ts b/packages/bridge/test/execute-bridge.test.ts index 997e0c36..73cadeca 100644 --- a/packages/bridge/test/execute-bridge.test.ts +++ b/packages/bridge/test/execute-bridge.test.ts @@ -340,7 +340,6 @@ regressionTest("nested structures: scope blocks and nested arrays", { "Query.scopeBlock": { "scope block produces nested object": { input: { w: { temperature: 25 }, city: "Berlin" }, - allowDowngrade: true, assertData: { decision: true, why: { temperature: 25, city: "Berlin" }, @@ -349,7 +348,6 @@ regressionTest("nested structures: scope blocks and nested arrays", { }, "scope block with false decision": { input: { w: { temperature: 15 }, city: "Oslo" }, - allowDowngrade: true, assertData: { decision: false, why: { temperature: 15, city: "Oslo" }, @@ -358,7 +356,6 @@ regressionTest("nested structures: scope blocks and nested arrays", { }, "temperature null → ?? fallback fires": { input: { w: { temperature: null }, city: "Null" }, - allowDowngrade: true, assertData: { decision: false, why: { temperature: 0, city: "Null" }, @@ -367,7 +364,6 @@ regressionTest("nested structures: scope blocks and nested arrays", { }, "tool error → catch fires for decision": { input: { w: { _error: "fail" }, city: "Error" }, - allowDowngrade: true, fields: ["decision"], assertData: { decision: false }, assertTraces: 1, @@ -521,7 +517,6 @@ regressionTest("alias: iterator-scoped aliases", { items: [{ nested: { a: 1, b: 2 } }, { nested: { a: 3, b: 4 } }], }, }, - allowDowngrade: true, assertData: [ { x: 1, y: 2 }, { x: 3, y: 4 }, @@ -530,7 +525,6 @@ regressionTest("alias: iterator-scoped aliases", { }, "empty items": { input: { api: { items: [] } }, - allowDowngrade: true, assertData: [], assertTraces: 1, }, @@ -625,7 +619,6 @@ regressionTest("alias: top-level aliases", { result: { data: { name: "Alice", email: "alice@test.com" } }, }, }, - allowDowngrade: true, assertData: { name: "Alice", email: "alice@test.com" }, assertTraces: 1, }, @@ -770,13 +763,11 @@ regressionTest("alias: expressions and modifiers", { "AliasOr.test": { "nickname present": { input: { nickname: "Alice" }, - allowDowngrade: true, assertData: { name: "Alice" }, assertTraces: 0, }, "nickname missing → fallback": { input: {}, - allowDowngrade: true, assertData: { name: "Guest" }, assertTraces: 0, }, @@ -784,13 +775,11 @@ regressionTest("alias: expressions and modifiers", { "AliasNullish.test": { "value present": { input: { score: 42 }, - allowDowngrade: true, assertData: { score: 42 }, assertTraces: 0, }, "value missing → fallback": { input: {}, - allowDowngrade: true, assertData: { score: 0 }, assertTraces: 0, }, @@ -798,13 +787,11 @@ regressionTest("alias: expressions and modifiers", { "AliasCatch.test": { "tool throws → catch provides fallback": { input: { api: { _error: "Service unavailable" } }, - allowDowngrade: true, assertData: { result: 99 }, assertTraces: 1, }, "tool succeeds → value used": { input: { api: { value: 42 } }, - allowDowngrade: true, assertData: { result: 42 }, assertTraces: 1, }, @@ -812,13 +799,11 @@ regressionTest("alias: expressions and modifiers", { "AliasSafe.test": { "tool throws → ?. returns undefined, || picks fallback": { input: { api: { _error: "Service unavailable" } }, - allowDowngrade: true, assertData: { result: "fallback" }, assertTraces: 1, }, "tool succeeds → value used": { input: { api: { value: "real" } }, - allowDowngrade: true, assertData: { result: "real" }, assertTraces: 1, }, diff --git a/packages/bridge/test/expressions.test.ts b/packages/bridge/test/expressions.test.ts index 359ec963..e51472b3 100644 --- a/packages/bridge/test/expressions.test.ts +++ b/packages/bridge/test/expressions.test.ts @@ -416,7 +416,7 @@ regressionTest("expressions: catch error fallback", { "expression with catch error fallback: api.price * 100 catch -1": { input: { dollars: 5 }, assertData: { cents: -1 }, - allowDowngrade: true, + assertTraces: 1, }, }, @@ -632,6 +632,77 @@ regressionTest("safe flag on right operand expressions", { }, }); +// ── Arithmetic null/undefined propagation through ?? fallback ─────────────── +// +// Bug: `undefined * N ?? fallback` returned NaN instead of the fallback value. +// When an arithmetic operand is null/undefined (e.g. from a mistyped path +// accessed via `?.`), the result should propagate null so that `??` can +// correctly fall through to the next source in the chain. + +regressionTest("expressions: arithmetic null/undefined propagates through ??", { + bridge: bridge` + version 1.5 + + bridge Query.undefinedArith { + with test.multitool as api + with input as i + with output as o + + api <- i.api + o.price <- api?.price * 100 ?? -1 + } + + bridge Query.toolFallback { + with test.multitool as primary + with test.multitool as backup + with input as i + with output as o + + primary <- i.primary + backup <- i.backup + o.price <- primary?.amount * 1 ?? backup.price + } + `, + tools: tools, + scenarios: { + "Query.undefinedArith": { + "undefined * 100 ?? -1 → fallback fires (safe api crash)": { + input: { api: { _error: "HTTP 500" } }, + assertData: { price: -1 }, + assertTraces: 1, + }, + "null * 100 ?? -1 → fallback fires (null field from tool)": { + input: { api: { price: null } }, + assertData: { price: -1 }, + assertTraces: 1, + }, + "5 * 100 ?? -1 → arithmetic value returned (api succeeds)": { + input: { api: { price: 5 } }, + assertData: { price: 500 }, + assertTraces: 1, + }, + }, + "Query.toolFallback": { + "primary?.amount missing (typo path) → fallback to backup.price": { + input: { + primary: { wrongKey: 65000 }, + backup: { price: 99 }, + }, + assertData: { price: 99 }, + assertTraces: 2, + }, + "primary?.amount present → use calculated value": { + input: { + primary: { amount: 65000 }, + backup: { price: 99 }, + }, + assertData: { price: 65000 }, + assertTraces: 1, + }, + }, + }, +}); + // ── Short-circuit data correctness ────────────────────────────────────────── regressionTest("and/or short-circuit data correctness", { @@ -687,3 +758,70 @@ regressionTest("and/or short-circuit data correctness", { }, }, }); + +// ── Expressions in coalesce (|| / ??) fallback positions ──────────────────── + +regressionTest("expressions in coalesce fallback positions", { + bridge: bridge` + version 1.5 + + bridge Query.binaryInNullish { + with input as i + with output as o + + o.price <- i.a ?? i.b * 1 + } + + bridge Query.binaryInFalsy { + with input as i + with output as o + + o.total <- i.subtotal || i.base + i.fee + } + + bridge Query.multipleFallbacks { + with input as i + with output as o + + o.val <- i.a ?? i.b * 2 ?? i.c + 1 + } + `, + scenarios: { + "Query.binaryInNullish": { + "primary null → fallback binary expression executes": { + input: { a: null, b: 3 }, + assertData: { price: 3 }, + assertTraces: 0, + }, + "primary non-null → fallback not evaluated": { + input: { a: 42, b: 3 }, + assertData: { price: 42 }, + assertTraces: 0, + }, + }, + "Query.binaryInFalsy": { + "primary falsy → fallback binary expression executes": { + input: { subtotal: 0, base: 10, fee: 5 }, + assertData: { total: 15 }, + assertTraces: 0, + }, + "primary truthy → fallback not evaluated": { + input: { subtotal: 99, base: 10, fee: 5 }, + assertData: { total: 99 }, + assertTraces: 0, + }, + }, + "Query.multipleFallbacks": { + "first fallback evaluates when primary is null": { + input: { a: null, b: 4, c: 10 }, + assertData: { val: 8 }, + assertTraces: 0, + }, + "second fallback evaluates when first is also null": { + input: { a: null, b: null, c: 10 }, + assertData: { val: 11 }, + assertTraces: 0, + }, + }, + }, +}); diff --git a/packages/bridge/test/interpolation-universal.test.ts b/packages/bridge/test/interpolation-universal.test.ts index f699cb3b..a44d51c1 100644 --- a/packages/bridge/test/interpolation-universal.test.ts +++ b/packages/bridge/test/interpolation-universal.test.ts @@ -45,7 +45,7 @@ regressionTest("universal interpolation: fallback", { nickname: "Ally", src: { fallbackDisplay: "unused", fallbackLabel: "unused" }, }, - allowDowngrade: true, + assertData: { displayName: "alice@test.com", label: "Ally" }, assertTraces: 0, }, @@ -58,7 +58,7 @@ regressionTest("universal interpolation: fallback", { fallbackLabel: "Jane Doe", }, }, - allowDowngrade: true, + assertData: { displayName: "Jane Doe (jane@test.com)", label: "Jane Doe", @@ -70,8 +70,18 @@ regressionTest("universal interpolation: fallback", { "|| fallback inside array mapping": { input: { items: [ - { id: "1", name: "Widget", customLabel: null, defaultLabel: "Widget (#1)" }, - { id: "2", name: "Gadget", customLabel: "Custom", defaultLabel: "Gadget (#2)" }, + { + id: "1", + name: "Widget", + customLabel: null, + defaultLabel: "Widget (#1)", + }, + { + id: "2", + name: "Gadget", + customLabel: "Custom", + defaultLabel: "Gadget (#2)", + }, ], }, assertData: [{ label: "Widget (#1)" }, { label: "Custom" }], diff --git a/packages/bridge/test/path-scoping.test.ts b/packages/bridge/test/path-scoping.test.ts index 5443f6fc..abb1905f 100644 --- a/packages/bridge/test/path-scoping.test.ts +++ b/packages/bridge/test/path-scoping.test.ts @@ -184,7 +184,7 @@ regressionTest("path scoping: alias inside nested scope", { assertData: { info: { title: "Article", author: "Alice", tags: ["a", "b"] }, }, - allowDowngrade: true, + assertTraces: 1, }, }, @@ -398,6 +398,9 @@ regressionTest("path scoping: spread syntax", { bridge: bridge` version 1.5 + const array = [1, 2, 3] + const object = { "a": "A", "b": "B" } + bridge Query.spreadBasic { with api as a with output as o @@ -408,6 +411,39 @@ regressionTest("path scoping: spread syntax", { } } + bridge Query.inArray { + with api as a + with output as o + + # Direct array + o.array1 <- a.items[] as i { + .iter <- i + ... <- a + } + + o { + ... <- a + .array2 <- a.items[] as i { + .iter <- i + ... <- a + } + } + } + + tool arrayTool from test.multitool { + with const as c + ... <- c.object + } + + bridge Query.tools { + with arrayTool as t + with output as o + + o { + ... <- t + } + } + bridge Query.spreadWithConst { with api as a with output as o @@ -431,6 +467,7 @@ regressionTest("path scoping: spread syntax", { scenarios: { "Query.spreadBasic": { "top-level spread copies all tool fields": { + allowDowngrade: true, input: {}, tools: { api: () => ({ name: "Alice", age: 30 }), @@ -439,8 +476,58 @@ regressionTest("path scoping: spread syntax", { assertTraces: 1, }, }, + "Query.inArray": { + "spread inside array mapper merges tool fields per element": { + allowDowngrade: true, + input: {}, + tools: { + api: () => ({ items: [1, 2, 3], x: 10 }), + }, + assertData: { + items: [1, 2, 3], + x: 10, + array1: [ + { iter: 1, items: [1, 2, 3], x: 10 }, + { iter: 2, items: [1, 2, 3], x: 10 }, + { iter: 3, items: [1, 2, 3], x: 10 }, + ], + array2: [ + { iter: 1, items: [1, 2, 3], x: 10 }, + { iter: 2, items: [1, 2, 3], x: 10 }, + { iter: 3, items: [1, 2, 3], x: 10 }, + ], + }, + assertTraces: 1, + }, + "empty array produces empty results": { + allowDowngrade: true, + input: {}, + tools: { + api: () => ({ items: [], x: 10 }), + }, + assertData: { + items: [], + x: 10, + array1: [], + array2: [], + }, + assertTraces: 1, + }, + }, + "Query.tools": { + "spread in tool definition merges const fields into tool input": { + allowDowngrade: true, + input: {}, + tools: { + "test.multitool": (p: any) => p, + }, + assertData: { a: "A", b: "B" }, + assertTraces: 1, + }, + }, "Query.spreadWithConst": { "spread + constants combine correctly": { + allowDowngrade: true, input: {}, tools: { api: () => ({ data: { x: 1, y: 2 } }), @@ -451,6 +538,7 @@ regressionTest("path scoping: spread syntax", { }, "Query.spreadSubPath": { "spread with sub-path source": { + allowDowngrade: true, input: {}, tools: { api: () => ({ metadata: { author: "Bob", year: 2024 } }), diff --git a/packages/bridge/test/resilience.test.ts b/packages/bridge/test/resilience.test.ts index 02fd050d..1e55de12 100644 --- a/packages/bridge/test/resilience.test.ts +++ b/packages/bridge/test/resilience.test.ts @@ -228,7 +228,7 @@ regressionTest("resilience: wire catch", { assertData: { result: "chainCaught" }, // first throws, second never called; catch kicks in assertTraces: 1, - allowDowngrade: true, + }, }, }, @@ -428,7 +428,7 @@ regressionTest("resilience: multi-wire null-coalescing", { }, assertData: { value: "from-primary" }, assertTraces: 1, - allowDowngrade: true, + }, "backup used when primary returns null": { input: {}, @@ -438,7 +438,7 @@ regressionTest("resilience: multi-wire null-coalescing", { }, assertData: { value: "from-backup" }, assertTraces: 2, - allowDowngrade: true, + }, }, "Query.secondUsed": { @@ -450,7 +450,7 @@ regressionTest("resilience: multi-wire null-coalescing", { }, assertData: { value: "from-backup" }, assertTraces: 2, - allowDowngrade: true, + }, }, "Query.multiWithFalsy": { @@ -462,7 +462,7 @@ regressionTest("resilience: multi-wire null-coalescing", { }, assertData: { value: "terminal" }, assertTraces: 2, - allowDowngrade: true, + }, "primary wins when non-null": { input: {}, @@ -472,7 +472,7 @@ regressionTest("resilience: multi-wire null-coalescing", { }, assertData: { value: "primary-val" }, assertTraces: 1, - allowDowngrade: true, + }, }, }, @@ -544,7 +544,7 @@ regressionTest("resilience: || source + catch source (COALESCE)", { }, assertData: { value: "from-backup" }, assertTraces: 2, - allowDowngrade: true, + }, }, "Query.backupSkipped": { @@ -558,7 +558,7 @@ regressionTest("resilience: || source + catch source (COALESCE)", { }, assertData: { value: "has-value" }, assertTraces: 1, - allowDowngrade: true, + }, "primary null → backup provides value": { input: {}, @@ -568,7 +568,7 @@ regressionTest("resilience: || source + catch source (COALESCE)", { }, assertData: { value: "backup-result" }, assertTraces: 2, - allowDowngrade: true, + }, }, "Query.bothNull": { @@ -580,7 +580,7 @@ regressionTest("resilience: || source + catch source (COALESCE)", { }, assertData: { value: "literal" }, assertTraces: 2, - allowDowngrade: true, + }, }, "Query.catchSourcePath": { @@ -606,7 +606,7 @@ regressionTest("resilience: || source + catch source (COALESCE)", { }, assertData: { value: "direct-value" }, assertTraces: 1, - allowDowngrade: true, + }, "catch pipes fallback through tool": { input: {}, @@ -619,7 +619,7 @@ regressionTest("resilience: || source + catch source (COALESCE)", { }, assertData: { value: "RECOVERY" }, assertTraces: 3, - allowDowngrade: true, + }, }, "Query.fullCoalesce": { @@ -635,7 +635,7 @@ regressionTest("resilience: || source + catch source (COALESCE)", { assertData: (data: any) => { assert.ok(data.value !== undefined); }, - allowDowngrade: true, + assertTraces: (traces: any[]) => { assert.ok(traces.length >= 1); }, diff --git a/packages/bridge/test/runtime-error-format.test.ts b/packages/bridge/test/runtime-error-format.test.ts index 7eba8c8d..98ff0048 100644 --- a/packages/bridge/test/runtime-error-format.test.ts +++ b/packages/bridge/test/runtime-error-format.test.ts @@ -170,7 +170,7 @@ regressionTest("error formatting – ternary branch", { `, scenarios: { "Query.greet": { - "ternary branch errors underline only the failing branch": { + "ternary branch errors underline the full wire": { input: { isPro: false }, assertError: (err: any) => { const formatted = formatBridgeError(err, { filename: FN }); @@ -178,12 +178,15 @@ regressionTest("error formatting – ternary branch", { formatted, /Bridge Execution Error: Cannot read properties of undefined \(reading 'asd'\)/, ); - assert.match(formatted, /playground\.bridge:7:32/); + assert.match(formatted, /playground\.bridge:7:3/); assert.match( formatted, /o\.discount <- i\.isPro \? 20 : i\.asd\.asd\.asd/, ); - assert.equal(maxCaretCount(formatted), "i.asd.asd.asd".length); + assert.equal( + maxCaretCount(formatted), + "o.discount <- i.isPro ? 20 : i.asd.asd.asd".length, + ); }, assertTraces: 0, }, @@ -288,7 +291,7 @@ regressionTest("error formatting – ternary condition", { `, scenarios: { "Query.pricing": { - "ternary condition errors point at condition and missing segment": { + "ternary condition errors underline the full wire": { input: { isPro: false, proPrice: 49.99, basicPrice: 9.99 }, assertError: (err: any) => { const formatted = formatBridgeError(err, { filename: FN }); @@ -296,12 +299,15 @@ regressionTest("error formatting – ternary condition", { formatted, /Bridge Execution Error: Cannot read properties of false \(reading 'fail'\)/, ); - assert.match(formatted, /playground\.bridge:9:14/); + assert.match(formatted, /playground\.bridge:9:3/); assert.match( formatted, /o\.price <- i\.isPro\.fail\.asd \? i\.proPrice : i\.basicPrice/, ); - assert.equal(maxCaretCount(formatted), "i.isPro.fail.asd".length); + assert.equal( + maxCaretCount(formatted), + "o.price <- i.isPro.fail.asd ? i.proPrice : i.basicPrice".length, + ); }, assertTraces: 0, }, @@ -352,11 +358,12 @@ regressionTest("error formatting – coalesce fallback", { formatted, /Bridge Execution Error: Cannot read properties of undefined \(reading 'array'\)/, ); - assert.match(formatted, /playground\.bridge:9:16/); + assert.match(formatted, /playground\.bridge:9:40/); assert.match( formatted, /o\.message <- i\.empty\.array\?\.error \?\? i\.empty\.array\.error/, ); + assert.equal(maxCaretCount(formatted), "i.empty.array.error".length); }, // engines may produce different trace counts depending on scheduling assertTraces: (t) => assert.ok(t.length >= 0), @@ -377,6 +384,65 @@ regressionTest("error formatting – coalesce fallback", { }, }); +regressionTest("error formatting – falsy fallback branch", { + bridge: bridge` + version 1.5 + + bridge Query.searchTrains { + with input as i + with output as o + + o.trainName <- i.journey.nmame || i.jomurney.category || "Walk" + } + `, + scenarios: { + "Query.searchTrains": { + "falsy fallback errors highlight the failing fallback branch": { + input: { + journey: {}, + }, + assertError: (err: any) => { + const formatted = formatBridgeError(err, { filename: FN }); + assert.match( + formatted, + /Bridge Execution Error: Cannot read properties of undefined \(reading 'category'\)/, + ); + assert.match(formatted, /playground\.bridge:7:37/); + assert.match( + formatted, + /o\.trainName <- i\.journey\.nmame \|\| i\.jomurney\.category \|\| "Walk"/, + ); + assert.equal(maxCaretCount(formatted), "i.jomurney.category".length); + }, + assertTraces: 0, + }, + "truthy primary short-circuits the fallback": { + input: { + journey: { nmame: "IC 5" }, + }, + assertData: { trainName: "IC 5" }, + assertTraces: 0, + }, + "second fallback supplies the train name": { + input: { + journey: { nmame: "" }, + jomurney: { category: "IC 3" }, + }, + assertData: { trainName: "IC 3" }, + assertTraces: 0, + }, + "literal fallback supplies the train name": { + input: { + journey: { nmame: "" }, + jomurney: { category: "" }, + }, + assertData: { trainName: "Walk" }, + assertTraces: 0, + }, + }, + }, +}); + regressionTest("error formatting – tool input cycle", { bridge: bridge` version 1.5 diff --git a/packages/bridge/test/scheduling.test.ts b/packages/bridge/test/scheduling.test.ts index 420a3833..aa862301 100644 --- a/packages/bridge/test/scheduling.test.ts +++ b/packages/bridge/test/scheduling.test.ts @@ -42,11 +42,7 @@ function assertParallel( /** * Assert that tool B started only after tool A finished. */ -function assertSequential( - traces: ToolTrace[], - before: string, - after: string, -) { +function assertSequential(traces: ToolTrace[], before: string, after: string) { const a = traces.find((t) => t.tool === before); const b = traces.find((t) => t.tool === after); assert.ok(a, `expected trace for ${before}`); @@ -323,7 +319,7 @@ regressionTest("scheduling: A||B parallel with C depending on A", { assertData: { coalesced: "from-A", fromC: 84 }, // toolA returns non-null val → toolB short-circuited (2 traces: A + C) assertTraces: 2, - allowDowngrade: true, + }, "A null → B fallback used": { input: { x: 7 }, @@ -334,7 +330,7 @@ regressionTest("scheduling: A||B parallel with C depending on A", { }, assertData: { coalesced: "B-7", fromC: 14 }, assertTraces: 3, - allowDowngrade: true, + }, }, }, diff --git a/packages/bridge/test/shared-parity.test.ts b/packages/bridge/test/shared-parity.test.ts index 0313a446..c40efb93 100644 --- a/packages/bridge/test/shared-parity.test.ts +++ b/packages/bridge/test/shared-parity.test.ts @@ -255,7 +255,6 @@ regressionTest("parity: fallback operators", { backup: () => ({ val: "from-backup" }), }, assertData: { value: "from-backup" }, - allowDowngrade: true, assertTraces: 2, }, }, @@ -1262,7 +1261,6 @@ regressionTest("parity: alias declarations", { api: async () => ({ result: { data: { name: "hello" } } }), }, assertData: { value: "hello" }, - allowDowngrade: true, assertTraces: 1, }, }, @@ -1362,7 +1360,6 @@ regressionTest("parity: overdefinition", { svcB: async () => ({ label: "from-B" }), }, assertData: { label: "from-A" }, - allowDowngrade: true, assertTraces: 1, }, "second tool used when first returns undefined": { @@ -1372,7 +1369,6 @@ regressionTest("parity: overdefinition", { svcB: async () => ({ label: "from-B" }), }, assertData: { label: "from-B" }, - allowDowngrade: true, assertTraces: 2, }, }, @@ -1749,7 +1745,6 @@ regressionTest("parity: sparse fieldsets — wildcard and chains", { }, fields: ["fromA"], assertData: { fromA: 20 }, - allowDowngrade: true, assertTraces: 1, }, "A||B→C: requesting only fromB skips A, calls B and fallback C": { @@ -1763,7 +1758,6 @@ regressionTest("parity: sparse fieldsets — wildcard and chains", { }, fields: ["fromB"], assertData: { fromB: 25 }, - allowDowngrade: true, assertTraces: 2, }, }, diff --git a/packages/bridge/test/string-interpolation.test.ts b/packages/bridge/test/string-interpolation.test.ts index 6b0b7276..f193dad8 100644 --- a/packages/bridge/test/string-interpolation.test.ts +++ b/packages/bridge/test/string-interpolation.test.ts @@ -96,3 +96,49 @@ regressionTest("string interpolation", { }, }, }); + +regressionTest("string interpolation: pipe expression in template", { + bridge: bridge` + version 1.5 + + bridge Interpolation.pipeInTemplate { + with std.str.toUpperCase as toUpper + with input as i + with output as o + + o.symbol <- "{toUpper:i.symbol}USDT" + } + + bridge Interpolation.pipeInTemplateMixed { + with std.str.toUpperCase as toUpper + with input as i + with output as o + + o.label <- "Asset: {toUpper:i.name} (v2)" + } + `, + tools: { + std: { + str: { + toUpperCase: (input: { in: string }) => + typeof input.in === "string" ? input.in.toUpperCase() : "", + }, + }, + }, + scenarios: { + "Interpolation.pipeInTemplate": { + "pipe expression inside template placeholder": { + input: { symbol: "btc" }, + assertData: { symbol: "BTCUSDT" }, + assertTraces: 1, + }, + }, + "Interpolation.pipeInTemplateMixed": { + "pipe expression mixed with surrounding text": { + input: { name: "btc" }, + assertData: { label: "Asset: BTC (v2)" }, + assertTraces: 1, + }, + }, + }, +}); diff --git a/packages/bridge/test/tmp-debug-codegen.ts b/packages/bridge/test/tmp-debug-codegen.ts new file mode 100644 index 00000000..bdb170a7 --- /dev/null +++ b/packages/bridge/test/tmp-debug-codegen.ts @@ -0,0 +1,60 @@ +import { bridge, parseBridgeFormat } from "@stackables/bridge"; +import { compileBridge } from "@stackables/bridge-compiler"; + +function show(name: string, source: string, operation: string) { + const doc = parseBridgeFormat(source); + const result = compileBridge(doc, { operation }); + console.log(`\n=== ${name} (${operation}) ===`); + console.log(result.functionBody); +} + +show( + "Overdefinition", + bridge` + version 1.5 + + bridge Overdef.lookup { + with test.multitool as api + with test.multitool as a + with test.multitool as b + with context as ctx + with input as i + with output as o + + api <- i.api + a <- i.a + b <- i.b + + o.inputBeats <- api.label + o.inputBeats <- i.hint + + o.contextBeats <- api.label + o.contextBeats <- ctx.defaultLabel + + o.sameCost <- a.label + o.sameCost <- b.label + } +`, + "Overdef.lookup", +); + +show( + "SyncAsync", + bridge` + version 1.5 + + bridge SyncAsync.lookup { + with test.async.multitool as slow + with test.sync.multitool as fast + with input as i + with output as o + + slow <- i.data + fast <- i.data + + o.label <- slow.label + o.label <- fast.label + } +`, + "SyncAsync.lookup", +); diff --git a/packages/bridge/test/tool-error-location.test.ts b/packages/bridge/test/tool-error-location.test.ts index 83bf16bb..f4d17968 100644 --- a/packages/bridge/test/tool-error-location.test.ts +++ b/packages/bridge/test/tool-error-location.test.ts @@ -3,23 +3,7 @@ import { regressionTest } from "./utils/regression.ts"; import { tools } from "./utils/bridge-tools.ts"; import { BridgeRuntimeError } from "@stackables/bridge-core"; import { bridge } from "@stackables/bridge"; - -/** - * Returns the source text segment that would be underlined with ^^^^^ carets - * in the formatted error output. Uses the `bridgeLoc` + `bridgeSource` - * attached to the error by the execution engine. - */ -function locatedSegment( - err: BridgeRuntimeError & { bridgeSource?: string }, -): string { - const loc = err.bridgeLoc; - const source = err.bridgeSource; - if (!loc || !source) return ""; - const line = source.split("\n")[loc.startLine - 1] ?? ""; - return loc.endLine === loc.startLine - ? line.slice(loc.startColumn - 1, loc.endColumn) - : line.slice(loc.startColumn - 1); -} +import { assertRuntimeErrorAt } from "./utils/error-utils.ts"; // ══════════════════════════════════════════════════════════════════════════════ // Tool error location @@ -105,12 +89,7 @@ regressionTest("tool error location", { "Query.outputWire": { "tool error points at the output wire that pulls from it": { input: { _error: "Failed to fetch" }, - assertError: (err: any) => { - assert.ok(err instanceof BridgeRuntimeError); - assert.ok(err.bridgeLoc, "Expected bridgeLoc on tool error"); - // The caret underlines the `api.body` source reference in `o.result <- api.body` - assert.equal(locatedSegment(err), "api.body"); - }, + assertError: assertRuntimeErrorAt("api.body"), // Error scenarios: the tool always throws so no traces are guaranteed assertTraces: (t) => assert.ok(t.length >= 0), }, @@ -118,12 +97,8 @@ regressionTest("tool error location", { "Query.chainError": { "tool error in chain points at the closest pulling wire": { input: { _error: "Failed to fetch" }, - assertError: (err: any) => { - assert.ok(err instanceof BridgeRuntimeError); - assert.ok(err.bridgeLoc, "Expected bridgeLoc on tool error"); - // The caret underlines `api` in `e <- api`, not `e` in `o.result <- e` - assert.equal(locatedSegment(err), "api"); - }, + assertError: assertRuntimeErrorAt("api"), + // Error scenarios: the tool always throws so no traces are guaranteed assertTraces: (t) => assert.ok(t.length >= 0), }, diff --git a/packages/bridge/test/tool-features.test.ts b/packages/bridge/test/tool-features.test.ts index 790da411..1bf5f65b 100644 --- a/packages/bridge/test/tool-features.test.ts +++ b/packages/bridge/test/tool-features.test.ts @@ -204,7 +204,6 @@ regressionTest("tool features: tool-to-tool dependency", { }), }, assertData: { status: "token=fallback-token" }, - allowDowngrade: true, assertTraces: 2, }, }, @@ -291,7 +290,6 @@ regressionTest("tool features: pipe with extra ToolDef params", { }, assertData: { priceAny: 5 }, assertTraces: 1, - allowDowngrade: true, }, }, }, @@ -354,7 +352,88 @@ regressionTest("tool features: named pipe input field", { }, assertData: { converted: 5 }, assertTraces: 1, - allowDowngrade: true, + }, + }, + }, +}); + +// ── 9. Scope blocks in ToolDef ────────────────────────────────────────────── + +regressionTest("tool features: scope blocks in tool body", { + bridge: bridge` + version 1.5 + + tool myApi from httpCall { + .headers { + .auth = "Bearer 123" + .contentType = "application/json" + } + .baseUrl = "https://api.example.com" + } + + bridge Query.scopeTool { + with myApi as api + with input as i + with output as o + + api.q <- i.q + o.result <- api.data + } + `, + scenarios: { + "Query.scopeTool": { + "scope block sets nested tool config": { + input: { q: "test" }, + tools: { + httpCall: (p: any) => ({ + data: `${p.q}:${p.headers.auth}:${p.headers.contentType}:${p.baseUrl}`, + }), + }, + assertData: { + result: "test:Bearer 123:application/json:https://api.example.com", + }, + assertTraces: 1, + }, + }, + }, +}); + +// ── 10. Nested scope blocks in ToolDef ────────────────────────────────────── + +regressionTest("tool features: nested scope blocks in tool body", { + bridge: bridge` + version 1.5 + + tool myApi from httpCall { + .config { + .retry { + .attempts = 3 + .delay = 1000 + } + .timeout = 5000 + } + } + + bridge Query.nestedScope { + with myApi as api + with input as i + with output as o + + api.url <- i.url + o.result <- api.data + } + `, + scenarios: { + "Query.nestedScope": { + "nested scope blocks build deep config": { + input: { url: "/test" }, + tools: { + httpCall: (p: any) => ({ + data: `${p.config.retry.attempts}:${p.config.retry.delay}:${p.config.timeout}`, + }), + }, + assertData: { result: "3:1000:5000" }, + assertTraces: 1, }, }, }, diff --git a/packages/bridge/test/utils/error-utils.ts b/packages/bridge/test/utils/error-utils.ts new file mode 100644 index 00000000..e625fa91 --- /dev/null +++ b/packages/bridge/test/utils/error-utils.ts @@ -0,0 +1,23 @@ +import { BridgeRuntimeError } from "@stackables/bridge-core"; +import assert from "node:assert/strict"; + +function locatedSegment( + err: BridgeRuntimeError & { bridgeSource?: string }, +): string { + const loc = err.bridgeLoc; + const source = err.bridgeSource; + if (!loc || !source) return ""; + const line = source.split("\n")[loc.startLine - 1] ?? ""; + return loc.endLine === loc.startLine + ? line.slice(loc.startColumn - 1, loc.endColumn) + : line.slice(loc.startColumn - 1); +} + +export function assertRuntimeErrorAt(location: string) { + return (err: any) => { + assert.ok(err instanceof BridgeRuntimeError); + assert.ok(err.bridgeLoc, "Expected bridgeLoc on tool error"); + // The caret underlines the `api.body` source reference in `o.result <- api.body` + assert.equal(locatedSegment(err), location); + }; +} diff --git a/packages/bridge/test/utils/regression.ts b/packages/bridge/test/utils/regression.ts index 9351bd04..96208b9d 100644 --- a/packages/bridge/test/utils/regression.ts +++ b/packages/bridge/test/utils/regression.ts @@ -50,23 +50,15 @@ import { type GraphQLSchema, type TypeNode, } from "graphql"; -import type { Bridge } from "@stackables/bridge-core"; +import type { Bridge, Statement } from "@stackables/bridge-core"; import { omitLoc } from "./parse-test-utils.ts"; import { GraphQLSchemaObserver } from "./observed-schema/index.ts"; // ── Round-trip normalisation ──────────────────────────────────────────────── -/** Strip locations and sort wire arrays so order differences don't fail. */ +/** Strip locations so structural differences don't fail. */ function normalizeDoc(doc: unknown): unknown { - const stripped = omitLoc(doc) as any; - for (const instr of stripped?.instructions ?? []) { - if (Array.isArray(instr.wires)) { - instr.wires.sort((a: any, b: any) => - JSON.stringify(a) < JSON.stringify(b) ? -1 : 1, - ); - } - } - return stripped; + return omitLoc(doc); } // ── Log capture ───────────────────────────────────────────────────────────── @@ -360,7 +352,7 @@ function collectFieldsRequiringJSONObject( const allPaths = new Set(); for (const name of scenarioNames) { const scenario = scenarios[name]!; - if (scenario.disable?.includes("graphql") || !scenario.fields) continue; + if (isDisabled(scenario.disable, "graphql") || !scenario.fields) continue; for (const field of scenario.fields) { if (!field.endsWith(".*")) { allPaths.add(field); @@ -703,21 +695,29 @@ function getOperationOutputFieldOrder( const seen = new Set(); const orderedFields: string[] = []; - for (const wire of bridge.wires) { - if ( - wire.to.module === SELF_MODULE && - wire.to.type === type && - wire.to.field === field && - wire.to.path.length > 0 - ) { - const topLevel = wire.to.path[0]!; - if (!seen.has(topLevel)) { - seen.add(topLevel); - orderedFields.push(topLevel); + function walkStatements(statements: Statement[]): void { + for (const stmt of statements) { + if (stmt.kind === "wire") { + if ( + stmt.target.module === SELF_MODULE && + stmt.target.type === type && + stmt.target.field === field && + stmt.target.path.length > 0 + ) { + const topLevel = stmt.target.path[0]!; + if (!seen.has(topLevel)) { + seen.add(topLevel); + orderedFields.push(topLevel); + } + } + } else if (stmt.kind === "scope") { + walkStatements(stmt.body); } } } + walkStatements(bridge.body); + return orderedFields; } @@ -857,10 +857,13 @@ export type Scenario = { assertLogs?: RegExp | ((logs: LogEntry[], ctx: AssertContext) => void); assertTraces: number | ((traces: ToolTrace[], ctx: AssertContext) => void); /** - * Temporarily disable specific test aspects for this scenario. - * The test is still defined (not removed) but will be skipped. + * Disable specific engines for this scenario. + * + * - `true` — skip this scenario entirely + * - explicit array — only listed engines are disabled; unlisted ones run + * - omitted — defaults apply (compiled, parser are off) */ - disable?: ("runtime" | "compiled" | "graphql")[]; + disable?: true | ("runtime" | "compiled" | "graphql" | "parser")[]; }; export type RegressionTest = { @@ -869,6 +872,14 @@ export type RegressionTest = { context?: Record; /** Tool-level timeout in ms (default: 5 000). */ toolTimeoutMs?: number; + /** + * Disable specific engines for all scenarios in this test. + * + * - `true` — skip this test entirely + * - explicit array — only listed engines are disabled; unlisted ones run + * - omitted — defaults apply (compiled, parser are off) + */ + disable?: true | ("runtime" | "compiled" | "graphql" | "parser")[]; scenarios: Record>; }; @@ -1097,14 +1108,36 @@ export function assertGraphqlExpectation( // ── Harness ───────────────────────────────────────────────────────────────── +function isDisabled( + disable: true | ("runtime" | "compiled" | "graphql" | "parser")[] | undefined, + check: "runtime" | "compiled" | "graphql" | "parser", +): boolean { + if (disable === true) return true; + + // Explicit array: trust exactly what the user listed + if (Array.isArray(disable)) return disable.includes(check); + + // Not set: defaults — all is enabled + return [""].includes(check); +} + export function regressionTest(name: string, data: RegressionTest) { + if (data.disable === true) { + describe.skip(name, () => {}); + return; + } + describe(name, () => { const document: BridgeDocument = parseBridge(data.bridge); // Per-operation accumulated runtime trace bitmasks for coverage check const traceMasks = new Map(); - test("parse → serialise → parse", () => { + test("parse → serialise → parse", (t) => { + if (isDisabled(data.disable, "parser")) { + t.skip("disabled"); + return; + } const serialised = serializeBridge(JSON.parse(JSON.stringify(document))); const parsed = parseBridge(serialised); @@ -1123,7 +1156,8 @@ export function regressionTest(name: string, data: RegressionTest) { output: unknown; }> = []; let pendingRuntimeTests = scenarioNames.filter( - (name) => !scenarios[name]!.disable?.includes("runtime"), + (name) => + !isDisabled(scenarios[name]!.disable ?? data.disable, "runtime"), ).length; let resolveRuntimeCollection!: () => void; @@ -1135,13 +1169,11 @@ export function regressionTest(name: string, data: RegressionTest) { }); afterEach((t) => { - if (t.name !== "runtime") { - return; - } - - pendingRuntimeTests -= 1; - if (pendingRuntimeTests === 0) { - resolveRuntimeCollection(); + if (t.name === "runtime") { + pendingRuntimeTests -= 1; + if (pendingRuntimeTests === 0) { + resolveRuntimeCollection(); + } } }); @@ -1152,7 +1184,10 @@ export function regressionTest(name: string, data: RegressionTest) { for (const { name: engineName, execute } of engines) { test(engineName, async (t) => { - if (scenario.disable?.includes(engineName)) { + // Scenario-level disable overrides test-level when set; + // otherwise test-level (or defaults) apply. + const effectiveDisable = scenario.disable ?? data.disable; + if (isDisabled(effectiveDisable, engineName)) { t.skip("disabled"); return; } @@ -1203,20 +1238,17 @@ export function regressionTest(name: string, data: RegressionTest) { scenarioName, output: resultData, }); - } - - if (scenario.assertError) { - assert.fail("Expected an error but execution succeeded"); - } - - // Accumulate runtime trace coverage - if (engineName === "runtime") { + // Accumulate runtime trace coverage traceMasks.set( operation, (traceMasks.get(operation) ?? 0n) | executionTraceId, ); } + if (scenario.assertError) { + assert.fail("Expected an error but execution succeeded"); + } + assertDataExpectation( scenario.assertData, resultData, @@ -1298,7 +1330,7 @@ export function regressionTest(name: string, data: RegressionTest) { ); const allGraphqlDisabled = scenarioNames.every((name) => - scenarios[name]!.disable?.includes("graphql"), + isDisabled(scenarios[name]!.disable ?? data.disable, "graphql"), ); if (scenarioNames.length > 0) { @@ -1383,7 +1415,7 @@ export function regressionTest(name: string, data: RegressionTest) { for (const scenarioName of scenarioNames) { test(scenarioName, async (t) => { const scenario = scenarios[scenarioName]!; - if (scenario.disable?.includes("graphql")) { + if (isDisabled(scenario.disable ?? data.disable, "graphql")) { t.skip("disabled"); return; } @@ -1443,6 +1475,7 @@ export function regressionTest(name: string, data: RegressionTest) { signalMapper: (ctx) => ctx.__bridgeSignal, toolTimeoutMs: data.toolTimeoutMs ?? 5_000, trace: "full", + partialSuccess: true, }, ); const source = buildGraphQLOperationSource( @@ -1545,7 +1578,7 @@ export function regressionTest(name: string, data: RegressionTest) { // After all scenarios for this operation, verify traversal coverage test("traversal coverage", async (t) => { const allRuntimeDisabled = scenarioNames.every((name) => - scenarios[name]!.disable?.includes("runtime"), + isDisabled(scenarios[name]!.disable ?? data.disable, "runtime"), ); if (allRuntimeDisabled) { t.skip("all scenarios have runtime disabled"); diff --git a/packages/docs-site/src/content/docs/reference/20-structural-blocks.mdx b/packages/docs-site/src/content/docs/reference/20-structural-blocks.mdx index 5eed1b8e..b4e713c8 100644 --- a/packages/docs-site/src/content/docs/reference/20-structural-blocks.mdx +++ b/packages/docs-site/src/content/docs/reference/20-structural-blocks.mdx @@ -24,7 +24,7 @@ bridge Query.getUser { ``` - + ### Handle Declarations (`with`) @@ -90,7 +90,7 @@ tool fragileApi from std.httpCall { ``` - + ## 3. Tool Inheritance @@ -115,7 +115,7 @@ tool baseApi.createUser from baseApi { ``` - + ## 4. Const Blocks @@ -134,7 +134,7 @@ const config = { To access them in your graph, bring them into scope: `with const as c`, then wire them using dot notation: `o.lat <- c.fallbackGeo.lat`. - + ## 5. Define Blocks @@ -177,4 +177,4 @@ bridge Query.me { and injects its wires directly into the host bridge during compilation. - + diff --git a/packages/docs-site/src/content/docs/reference/30-wiring-routing.mdx b/packages/docs-site/src/content/docs/reference/30-wiring-routing.mdx index 4158126f..513ac657 100644 --- a/packages/docs-site/src/content/docs/reference/30-wiring-routing.mdx +++ b/packages/docs-site/src/content/docs/reference/30-wiring-routing.mdx @@ -34,7 +34,7 @@ o.country = "Germany" ``` + ## 5. Forcing Execution (`force`) diff --git a/packages/docs-site/src/content/docs/reference/40-using-tools-pipes.mdx b/packages/docs-site/src/content/docs/reference/40-using-tools-pipes.mdx index 499c487c..5762cd39 100644 --- a/packages/docs-site/src/content/docs/reference/40-using-tools-pipes.mdx +++ b/packages/docs-site/src/content/docs/reference/40-using-tools-pipes.mdx @@ -40,7 +40,7 @@ bridge Query.processCatalog { } ``` - + ## 1. Explicit Wiring @@ -64,7 +64,7 @@ bridge Query.convertCurrency { ``` - + ## 2. The Pipe Operator (`:`) @@ -78,7 +78,7 @@ o.name <- upperCase:i.rawName ``` - + ### Chained Pipes @@ -120,7 +120,7 @@ bridge Query.convert { In this scenario, the `convert` tool receives *both* the explicitly wired `currency` parameter and the piped `rawPrice` payload simultaneously. - + ## 3. How Pipes Execute (Implicit Forking) diff --git a/packages/docs-site/src/content/docs/reference/50-fallbacks-resilience.mdx b/packages/docs-site/src/content/docs/reference/50-fallbacks-resilience.mdx index 86987627..eec20c82 100644 --- a/packages/docs-site/src/content/docs/reference/50-fallbacks-resilience.mdx +++ b/packages/docs-site/src/content/docs/reference/50-fallbacks-resilience.mdx @@ -44,7 +44,7 @@ If the `user` API **throws a 500 Network Error**: - **Scenario 1:** The `?.` is on the root tool. It intercepts the network crash and yields `undefined`. - **Scenario 2:** The root tool is evaluated strictly. The network error **crashes the field!** - + ## 2. Layer 2: Data-Level Routing (`||` and `??`) @@ -112,7 +112,7 @@ o.secureData <- authApi.token ?? panic "CRITICAL: Unauthorized access" ``` - + ## 5. Cost-Aware Overdefinition @@ -143,4 +143,4 @@ When a target is overdefined, the engine evaluates the sources **cheapest first* _(Note: The engine respects falsy values like `0`, `""`, and `false`. If a cheap source returns `0`, the engine accepts it and short-circuits, saving the cost of evaluating the more expensive sources.)_ - + diff --git a/packages/docs-site/src/content/docs/reference/60-expressions-formatting.mdx b/packages/docs-site/src/content/docs/reference/60-expressions-formatting.mdx index f71d3381..7965e3f7 100644 --- a/packages/docs-site/src/content/docs/reference/60-expressions-formatting.mdx +++ b/packages/docs-site/src/content/docs/reference/60-expressions-formatting.mdx @@ -31,7 +31,7 @@ bridge Query.userOrders { ``` - + ### Constant Wires vs. Pull Wires @@ -61,7 +61,7 @@ o.isActive <- i.status == "active" ``` - + ### Supported Operators @@ -92,7 +92,7 @@ o.total <- (i.price + i.discount) * i.qty ``` - + ### Precedence and Chaining @@ -127,7 +127,7 @@ o.amount <- i.isPro ? i.proPrice : i.basicPrice ``` - + The condition can be a direct boolean reference (`i.isPro`), or a full inline expression (`i.age >= 18`). The branches can be tool references, input variables, or literal values (`"premium"`, `true`, `20`). diff --git a/packages/docs-site/src/content/docs/reference/70-array-mapping.mdx b/packages/docs-site/src/content/docs/reference/70-array-mapping.mdx index 0dca56dd..61944621 100644 --- a/packages/docs-site/src/content/docs/reference/70-array-mapping.mdx +++ b/packages/docs-site/src/content/docs/reference/70-array-mapping.mdx @@ -28,7 +28,7 @@ bridge Query.getJourneys { ``` - + ### Shadow Scopes @@ -62,7 +62,7 @@ o.items <- api.items[] as item { } ``` - + Memoization is scoped to that declared handle. A nested loop can declare its own memoized handle without sharing the parent cache. @@ -102,7 +102,7 @@ o.items <- billingApi.items[] as item { ``` - + ### Halting the Array (`break`) @@ -179,7 +179,7 @@ bridge Query.getActiveAdmins { ``` - + ### Use Case 2: Fanout (API Call Per Element) @@ -216,4 +216,4 @@ bridge Query.getEnrichedUsers { If duplicate inputs are common, add `memoize` to the loop-scoped handle so repeated IDs can reuse the earlier result within that handle's cache. - + diff --git a/packages/docs-site/src/deploy-worker.ts b/packages/docs-site/src/deploy-worker.ts new file mode 100644 index 00000000..d1201d21 --- /dev/null +++ b/packages/docs-site/src/deploy-worker.ts @@ -0,0 +1,112 @@ +/** + * Slim Cloudflare Worker for production deployment. + * + * Handles /api/share endpoints (KV-backed) directly. + * Everything else is served as static assets by the ASSETS binding + * (Cloudflare Workers Static Assets). + * + * Note: `astro dev` and `astro preview` do NOT use this file — they use + * the @astrojs/cloudflare adapter's own entrypoints. This is only used + * by `wrangler deploy`. + */ + +interface Env { + SHARES: KVNamespace; + ASSETS: Fetcher; +} + +interface SharePayload { + schema: string; + bridge: string; + queries: { name: string; query: string }[]; + context: string; + /** @deprecated Legacy single-query field — kept for backward compat */ + query?: string; +} + +const JSON_HEADERS: HeadersInit = { + "Content-Type": "application/json", +}; + +/** 90-day TTL */ +const TTL_SECONDS = 60 * 60 * 24 * 90; + +async function handlePost(request: Request, env: Env): Promise { + let body: SharePayload; + try { + body = (await request.json()) as SharePayload; + } catch { + return new Response(JSON.stringify({ error: "invalid JSON" }), { + status: 400, + headers: JSON_HEADERS, + }); + } + + if ( + typeof body.schema !== "string" || + typeof body.bridge !== "string" || + typeof body.context !== "string" || + (!Array.isArray(body.queries) && typeof body.query !== "string") + ) { + return new Response(JSON.stringify({ error: "invalid payload" }), { + status: 400, + headers: JSON_HEADERS, + }); + } + + const size = JSON.stringify(body).length; + if (size > 128 * 1024) { + return new Response(JSON.stringify({ error: "payload too large" }), { + status: 413, + headers: JSON_HEADERS, + }); + } + + const id = crypto.randomUUID().replace(/-/g, "").slice(0, 12); + await env.SHARES.put(id, JSON.stringify(body), { + expirationTtl: TTL_SECONDS, + }); + + return new Response(JSON.stringify({ id }), { headers: JSON_HEADERS }); +} + +async function handleGet(request: Request, env: Env): Promise { + const url = new URL(request.url); + const id = url.searchParams.get("id"); + + if (!id || id.length > 64) { + return new Response(JSON.stringify({ error: "invalid id" }), { + status: 400, + headers: JSON_HEADERS, + }); + } + + const value = await env.SHARES.get(id); + if (value === null) { + return new Response(JSON.stringify({ error: "not found" }), { + status: 404, + headers: JSON_HEADERS, + }); + } + + return new Response(value, { headers: JSON_HEADERS }); +} + +export default { + async fetch( + request: Request, + env: Env, + _ctx: ExecutionContext, + ): Promise { + const url = new URL(request.url); + + if (url.pathname === "/api/share") { + if (request.method === "POST") return handlePost(request, env); + if (request.method === "GET") return handleGet(request, env); + return new Response(null, { status: 405 }); + } + + // Everything else: serve from static assets + return env.ASSETS.fetch(request); + }, +}; diff --git a/packages/docs-site/src/pages/api/share.ts b/packages/docs-site/src/pages/api/share.ts index a31f2d2d..8d6cfd76 100644 --- a/packages/docs-site/src/pages/api/share.ts +++ b/packages/docs-site/src/pages/api/share.ts @@ -67,7 +67,7 @@ export async function GET(context: any) { const request = context.request as Request; const url = new URL(request.url); - const id = url.searchParams.get('id') + const id = url.searchParams.get("id"); if (!id || id.length > 64) { return new Response(JSON.stringify({ error: "invalid id" }), { diff --git a/packages/docs-site/worker-configuration.d.ts b/packages/docs-site/worker-configuration.d.ts index f9d9dc1f..2acd6382 100644 --- a/packages/docs-site/worker-configuration.d.ts +++ b/packages/docs-site/worker-configuration.d.ts @@ -1,7 +1,10 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 3687270ff097b92829087e49bb8b5282) -// Runtime types generated with workerd@1.20260312.1 2026-02-24 global_fetch_strictly_public,nodejs_compat +// Generated by Wrangler by running `wrangler types` (hash: 6709868f27f4e7916be808b884dbdee1) +// Runtime types generated with workerd@1.20260312.1 2026-02-24 nodejs_compat declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./src/deploy-worker"); + } interface Env { SHARES: KVNamespace; ASSETS: Fetcher; diff --git a/packages/docs-site/wrangler.jsonc b/packages/docs-site/wrangler.jsonc index 07804318..f649aee6 100644 --- a/packages/docs-site/wrangler.jsonc +++ b/packages/docs-site/wrangler.jsonc @@ -1,19 +1,22 @@ { - "main": "dist/_worker.js/index.js", + // Deploy worker — only handles /api/share; static pages served by ASSETS. + // Note: `astro dev` and `astro preview` ignore this — they use the adapter's + // own entrypoints. This main is only used by `wrangler deploy`. + "main": "src/deploy-worker.ts", "name": "bridge", "compatibility_date": "2026-02-24", - "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], + "compatibility_flags": ["nodejs_compat"], "assets": { "binding": "ASSETS", - "directory": "./dist" + "directory": "./dist", }, "observability": { - "enabled": true + "enabled": true, }, "kv_namespaces": [ { "binding": "SHARES", - "id": "9972f6f87b064a409102e66949d8564f" - } - ] + "id": "9972f6f87b064a409102e66949d8564f", + }, + ], } diff --git a/packages/playground/package.json b/packages/playground/package.json index a01c2d9c..557d6ba4 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -8,7 +8,8 @@ }, "scripts": { "dev": "vite", - "build": "tsc -p tsconfig.json --noEmit && vite build", + "build:full": "tsc -p tsconfig.json --noEmit && vite build", + "build": "BRIDGE_ENABLE_COMPILER_PREVIEW=false tsc -p tsconfig.json --noEmit && vite build", "test": "node --experimental-transform-types --test test/*.test.ts", "preview": "vite preview" }, @@ -17,6 +18,7 @@ "@codemirror/commands": "^6.10.3", "@codemirror/lang-json": "^6.0.2", "@codemirror/language": "^6.12.2", + "@codemirror/lang-javascript": "^6.2.2", "@codemirror/lint": "^6.9.5", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.40.0", @@ -26,6 +28,7 @@ "@radix-ui/react-select": "2.2.6", "@radix-ui/react-slot": "1.2.4", "@stackables/bridge": "workspace:*", + "@stackables/bridge-compiler": "workspace:*", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cm6-graphql": "^0.2.1", diff --git a/packages/playground/src/Playground.tsx b/packages/playground/src/Playground.tsx index c2b33b41..399ea39c 100644 --- a/packages/playground/src/Playground.tsx +++ b/packages/playground/src/Playground.tsx @@ -1,4 +1,12 @@ -import { useState, useCallback, useRef, useMemo } from "react"; +import { + useState, + useCallback, + useRef, + useMemo, + useEffect, + lazy, + Suspense, +} from "react"; import { Panel, Group, @@ -17,6 +25,19 @@ import { cn } from "@/lib/utils"; import { collectInactiveTraversalLocations } from "@/lib/trace-highlighting"; import type { PlaygroundMode } from "./share"; +declare const __BRIDGE_COMPILER_PREVIEW__: boolean; + +const COMPILER_PREVIEW_ENABLED = + typeof __BRIDGE_COMPILER_PREVIEW__ === "boolean" + ? __BRIDGE_COMPILER_PREVIEW__ + : true; +const LazyCompiledPanel = COMPILER_PREVIEW_ENABLED + ? lazy(async () => { + const module = await import("./components/CompiledPanel"); + return { default: module.CompiledPanel }; + }) + : null; + // ── resize handle — transparent hit area, no visual indicator ──────────────── function ResizeHandle({ direction }: { direction: "horizontal" | "vertical" }) { return ( @@ -219,58 +240,6 @@ function QueryTabBar({ ); } -// ── bridge DSL header with optional trace badge ───────────────────────────── -function BridgeDslHeader({ - executionTraceId, - onClearExecutionTraceId, -}: { - executionTraceId?: bigint; - onClearExecutionTraceId?: () => void; -}) { - const hasTrace = executionTraceId != null && executionTraceId > 0n; - return ( -
- - Bridge DSL - - {hasTrace && ( - - - trace-id 0x{executionTraceId.toString(16)} - - {onClearExecutionTraceId && ( - - )} - - )} -
- ); -} - // ── schema panel header with mode toggle ───────────────────────────────────── function SchemaHeader({ mode, @@ -318,6 +287,79 @@ function SchemaHeader({ import { getTraversalManifest, decodeExecutionTrace } from "./engine"; +// ── DSL panel tab bar (Bridge DSL | Compiled) ───────────────────────────────── +function DslPanelTabBar({ + dslTab, + onDslTabChange, + compilerPreviewEnabled, + executionTraceId, + onClearExecutionTraceId, +}: { + dslTab: "bridge" | "compiled"; + onDslTabChange: (tab: "bridge" | "compiled") => void; + compilerPreviewEnabled: boolean; + executionTraceId?: bigint; + onClearExecutionTraceId?: () => void; +}) { + const hasTrace = + dslTab === "bridge" && executionTraceId != null && executionTraceId > 0n; + const tabs = compilerPreviewEnabled + ? (["bridge", "compiled"] as const) + : (["bridge"] as const); + return ( +
+ {tabs.map((tab) => ( + + ))} + {hasTrace && ( + + + trace-id 0x{executionTraceId!.toString(16)} + + {onClearExecutionTraceId && ( + + )} + + )} +
+ ); +} + function getInactiveTraversalLocations( bridge: string, operation: string, @@ -423,6 +465,25 @@ export function Playground({ const leftVLayout = useDefaultLayout({ id: "bridge-playground-left-v" }); const rightVLayout = useDefaultLayout({ id: "bridge-playground-right-v" }); + // ── Bridge DSL panel tabs ───────────────────────────────────────────────── + const [dslTab, setDslTab] = useState<"bridge" | "compiled">("bridge"); + const [compiledOperation, setCompiledOperation] = useState(() => + bridgeOperations.length > 0 ? bridgeOperations[0]!.label : "", + ); + + useEffect(() => { + if (!COMPILER_PREVIEW_ENABLED && dslTab === "compiled") { + setDslTab("bridge"); + } + }, [dslTab]); + + // Keep compiledOperation consistent with available operations + const resolvedCompiledOperation = + compiledOperation && + bridgeOperations.some((op) => op.label === compiledOperation) + ? compiledOperation + : (bridgeOperations[0]?.label ?? ""); + const activeQuery = queries.find((q) => q.id === activeTabId); const isStandalone = mode === "standalone"; @@ -478,20 +539,41 @@ export function Playground({ {/* Bridge DSL panel */}
-
- + {dslTab === "bridge" ? ( + + ) : LazyCompiledPanel ? ( + + Loading compiled preview... +
+ } + > + setCompiledOperation(op)} + autoHeight + /> + + ) : null}
@@ -606,19 +688,39 @@ export function Playground({ )} -
- + {dslTab === "bridge" ? ( + + ) : LazyCompiledPanel ? ( + + Loading compiled preview... +
+ } + > + setCompiledOperation(op)} + /> + + ) : null}
@@ -654,19 +756,39 @@ export function Playground({ {/* Bridge DSL panel */} -
- + {dslTab === "bridge" ? ( + + ) : LazyCompiledPanel ? ( + + Loading compiled preview... +
+ } + > + setCompiledOperation(op)} + /> + + ) : null}
diff --git a/packages/playground/src/components/CompiledPanel.tsx b/packages/playground/src/components/CompiledPanel.tsx new file mode 100644 index 00000000..834562be --- /dev/null +++ b/packages/playground/src/components/CompiledPanel.tsx @@ -0,0 +1,49 @@ +import { useMemo } from "react"; +import type { BridgeOperation } from "../engine"; +import { parseBridgeDiagnostics } from "@stackables/bridge"; +import { compileBridge } from "@stackables/bridge-compiler"; +import { Editor } from "./Editor"; + +type Props = { + bridge: string; + operations: BridgeOperation[]; + selectedOperation: string; + onOperationChange: (op: string) => void; + autoHeight?: boolean; +}; + +export function CompiledPanel({ + bridge, + selectedOperation, + autoHeight = false, +}: Props) { + const compiledCode = useMemo(() => { + if (!selectedOperation) return "// Select a bridge operation to compile."; + try { + const { document } = parseBridgeDiagnostics(bridge, { + filename: "playground.bridge", + }); + const result = compileBridge(document, { operation: selectedOperation }); + return result.code; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return `// Error: ${msg}`; + } + }, [bridge, selectedOperation]); + + return ( +
+ {/* Compiled JS output */} +
+ {}} + language="javascript" + readOnly + autoHeight={autoHeight} + /> +
+
+ ); +} diff --git a/packages/playground/src/components/Editor.tsx b/packages/playground/src/components/Editor.tsx index 3626ee8c..e336232a 100644 --- a/packages/playground/src/components/Editor.tsx +++ b/packages/playground/src/components/Editor.tsx @@ -5,6 +5,7 @@ import { keymap } from "@codemirror/view"; import { indentWithTab } from "@codemirror/commands"; import { diagnosticCount, lintGutter } from "@codemirror/lint"; import { json } from "@codemirror/lang-json"; +import { javascript } from "@codemirror/lang-javascript"; import { graphql, graphqlLanguageSupport, updateSchema } from "cm6-graphql"; import type { GraphQLSchema } from "graphql"; import type { SourceLocation } from "@stackables/bridge"; @@ -24,6 +25,7 @@ import { cn } from "@/lib/utils"; * - "graphql" — GraphQL SDL highlighting + inline schema linting * - "graphql-query" — GraphQL query editing with schema-aware autocomplete + linting (cm6-graphql) * - "json" — JSON highlighting (context panel) + * - "javascript" — JavaScript highlighting (read-only compiled output) * - "plain" — no language support (fallback) */ export type EditorLanguage = @@ -31,6 +33,7 @@ export type EditorLanguage = | "graphql" | "graphql-query" | "json" + | "javascript" | "plain"; type Props = { @@ -62,6 +65,8 @@ function languageExtension( return [...graphql(graphqlSchema), lintGutter()]; case "json": return [json()]; + case "javascript": + return [javascript()]; case "plain": return []; } diff --git a/packages/playground/src/engine.ts b/packages/playground/src/engine.ts index 8bb6bfef..a52c5e46 100644 --- a/packages/playground/src/engine.ts +++ b/packages/playground/src/engine.ts @@ -22,6 +22,8 @@ import type { Logger, CacheStore, TraversalEntry, + Statement, + Expression, } from "@stackables/bridge"; import { bridgeTransform, @@ -346,21 +348,36 @@ export function extractOutputFields( const pathSet = new Set(); - for (const wire of bridge.wires) { - if ( - wire.to.module === "_" && - wire.to.type === type && - wire.to.field === field && - wire.to.path.length > 0 - ) { - // Add the full path - pathSet.add(wire.to.path.join(".")); - // Add all intermediate ancestor paths - for (let i = 1; i < wire.to.path.length; i++) { - pathSet.add(wire.to.path.slice(0, i).join(".")); + const addPath = (fullPath: string[]): void => { + if (fullPath.length > 0) { + pathSet.add(fullPath.join(".")); + for (let i = 1; i < fullPath.length; i++) { + pathSet.add(fullPath.slice(0, i).join(".")); } } - } + }; + + // Recursively walk body statements to collect output target paths + const walkOutputPaths = (stmts: Statement[], prefix: string[]): void => { + for (const s of stmts) { + if (s.kind === "wire") { + const t = s.target; + if (t.module === "_" && t.type === type && t.field === field) { + const fullPath = [...prefix, ...t.path]; + addPath(fullPath); + } + // Walk into array expression bodies (e.g. `o <- src[] as x { ... }`) + for (const src of s.sources) { + if (src.expr.type === "array") { + walkOutputPaths(src.expr.body, [...prefix, ...s.target.path]); + } + } + } else if (s.kind === "scope") { + walkOutputPaths(s.body, [...prefix, ...s.target.path]); + } + } + }; + walkOutputPaths(bridge.body!, []); const allPaths = [...pathSet].sort((a, b) => { const aParts = a.split("."); @@ -420,9 +437,8 @@ export function extractInputSkeleton( // SELF_MODULE but have `element: true` — those are tool response fields, not inputs. const inputPaths: string[][] = []; - const collectRef = (ref: NodeRef | undefined) => { + const collectRef = (ref: NodeRef) => { if ( - ref && ref.module === "_" && ref.type === type && ref.field === field && @@ -433,21 +449,50 @@ export function extractInputSkeleton( } }; - for (const wire of bridge.wires) { - if ("from" in wire) { - collectRef(wire.from); - } else if ("cond" in wire) { - collectRef(wire.cond); - collectRef(wire.thenRef); - collectRef(wire.elseRef); - } else if ("condAnd" in wire) { - collectRef(wire.condAnd.leftRef); - collectRef(wire.condAnd.rightRef); - } else if ("condOr" in wire) { - collectRef(wire.condOr.leftRef); - collectRef(wire.condOr.rightRef); + const collectExprRefs = (expr: Expression): void => { + switch (expr.type) { + case "ref": + collectRef(expr.ref); + break; + case "ternary": + collectExprRefs(expr.cond); + collectExprRefs(expr.then); + collectExprRefs(expr.else); + break; + case "and": + case "or": + case "binary": + collectExprRefs(expr.left); + collectExprRefs(expr.right); + break; + case "unary": + collectExprRefs(expr.operand); + break; + case "concat": + for (const p of expr.parts) collectExprRefs(p); + break; + case "pipe": + collectExprRefs(expr.source); + break; + case "array": + collectExprRefs(expr.source); + walkInputRefs(expr.body); + break; } - } + }; + + const walkInputRefs = (stmts: Statement[]): void => { + for (const s of stmts) { + if (s.kind === "wire" || s.kind === "alias" || s.kind === "spread") { + for (const src of s.sources) { + collectExprRefs(src.expr); + } + } else if (s.kind === "scope") { + walkInputRefs(s.body); + } + } + }; + walkInputRefs(bridge.body!); if (inputPaths.length === 0) return "{}"; diff --git a/packages/playground/src/examples.ts b/packages/playground/src/examples.ts index 3ffe7c43..f4e064d2 100644 --- a/packages/playground/src/examples.ts +++ b/packages/playground/src/examples.ts @@ -1602,4 +1602,164 @@ bridge Mutation.deepseekChat { ], context: `{ "DEEPSEEK_API_KEY": "" }`, }, + { + id: "crypto-price-failover", + name: "Crypto Price Failover", + description: + "Query Coinbase as the primary price source and fall back to Binance automatically using ?? — a single bridge handles both APIs", + schema: ` +type Query { + assetPrice(symbol: String!): AssetPrice +} + +type AssetPrice { + price: Float + source: String + symbol: String +} + `, + bridge: `version 1.5 + +tool binance from std.httpCall { + .baseUrl = "https://api.binance.com" + .path = "/api/v3/ticker/price" + .method = "GET" +} + +tool coinbase from std.httpCall { + .baseUrl = "https://api.coinbase.com" + .method = "GET" +} + +bridge Query.assetPrice { + with coinbase + with binance + with input as i + with output as o + with std.str.toUpperCase as toUpper + + # 1. Normalize input to uppercase + alias symbol <- toUpper:i.symbol + + # 2. Dynamically build the path using native string interpolation + coinbase.path <- "/v2/prices/{symbol}-USD/spot" + binance.symbol <- "{symbol}USDT" + + # 3. Map the nested output (Coinbase returns {"data": {"amount": "65000.00"}}) + # Fall back to Binance price when Coinbase returns null or errors + o.price <- coinbase?.data?.amount ?? binance.price + o.source <- coinbase?.data?.amount? "coinbase" : "binance" + o.symbol <- symbol +}`, + queries: [ + { + name: "BTC price", + query: `{ + assetPrice(symbol: "BTC") { + price + source + symbol + } +}`, + }, + { + name: "ETH price", + query: `{ + assetPrice(symbol: "ETH") { + price + source + symbol + } +}`, + }, + ], + standaloneQueries: [ + { + operation: "Query.assetPrice", + outputFields: "price", + input: { symbol: "BTC" }, + }, + { + operation: "Query.assetPrice", + outputFields: "price", + input: { symbol: "ETH" }, + }, + ], + context: `{}`, + }, + { + id: "crypto-price-multi", + name: "Crypto Prices (Multi-Symbol)", + description: + "Loop over an array of symbols and fan out Coinbase/Binance calls in parallel — falling back from Coinbase to Binance per symbol", + schema: ` +type Query { + assetPrice(symbols: [String!]!): AssetPriceResult +} + +type AssetPriceResult { + prices: [PriceItem!]! +} + +type PriceItem { + symbol: String + price: String +} + `, + bridge: `version 1.5 + +tool binance from std.httpCall { + .baseUrl = "https://api.binance.com" + .path = "/api/v3/ticker/price" + .method = "GET" +} + +tool coinbase from std.httpCall { + .baseUrl = "https://api.coinbase.com" + .method = "GET" +} + +bridge Query.assetPrice { + with input as i + with output as o + with std.str.toUpperCase as toUpper + + o.prices <- i.symbols[] as s { + with coinbase + with binance + + # 1. Normalize input to uppercase + alias symbol <- toUpper:s + + # 2. Dynamically build the path using native string interpolation + coinbase.path <- "/v2/prices/{symbol}-USD/spot" + binance.symbol <- "{symbol}USDT" + + .symbol <- symbol + .price <- coinbase?.data?.amount ?? binance.price + } + +}`, + queries: [ + { + name: "ETH + BTC prices", + query: `{ + assetPrice(symbols: ["ETH", "btc"]) { + prices { + symbol + price + } + } +}`, + }, + ], + standaloneQueries: [ + { + operation: "Query.assetPrice", + outputFields: "prices", + input: { symbols: ["ETH", "btc"] }, + }, + ], + context: `{}`, + }, ]; diff --git a/packages/playground/src/lib/trace-highlighting.ts b/packages/playground/src/lib/trace-highlighting.ts index ca98db97..05b92574 100644 --- a/packages/playground/src/lib/trace-highlighting.ts +++ b/packages/playground/src/lib/trace-highlighting.ts @@ -40,24 +40,34 @@ function isSupersededByActiveLocation( loc: SourceLocation, activeLocations: SourceLocation[], ): boolean { - return activeLocations.some((activeLoc) => containsLocation(loc, activeLoc)); + return activeLocations.some((activeLoc) => containsLocation(activeLoc, loc)); } export function collectInactiveTraversalLocations( manifest: TraversalEntry[], activeIds: ReadonlySet, ): SourceLocation[] { - const wireGroups = new Map(); - for (const entry of manifest) { - let group = wireGroups.get(entry.wireIndex); + // Scope marker entries (bitIndex: -1, kind: "scope") are handled separately: + // they are "active" if any active location falls within their block, not via a bit. + const scopeEntries = manifest.filter((e) => e.kind === "scope"); + const wireEntries = manifest.filter((e) => e.kind !== "scope"); + + // Group entries by their wireLoc (the source span of the entire wire statement). + // All traversal entries belonging to the same wire share the same wireLoc. + // Note: the body-based enumeration assigns wireIndex: -1 to every entry, so + // grouping by wireIndex would incorrectly lump all entries together. + const wireGroups = new Map(); + for (const entry of wireEntries) { + const key = entry.wireLoc ? locationKey(entry.wireLoc) : `\x00${entry.id}`; + let group = wireGroups.get(key); if (!group) { group = []; - wireGroups.set(entry.wireIndex, group); + wireGroups.set(key, group); } group.push(entry); } - const activeLocations = manifest.flatMap((entry) => + const activeLocations = wireEntries.flatMap((entry) => activeIds.has(entry.id) && entry.loc ? [entry.loc] : [], ); @@ -88,7 +98,15 @@ export function collectInactiveTraversalLocations( if (activeIds.has(entry.id) || !entry.loc) { continue; } - if (isSupersededByActiveLocation(entry.loc, activeLocations)) { + // Only suppress error-path entries (synthetic sub-spans of an active primary, + // e.g. `primary/error` for a pipe that could throw). Genuine branch + // alternatives (fallback, else, catch) must always appear as dead code + // when they were not taken — even if their loc falls within the active + // primary's full-wire span. + if ( + entry.error && + isSupersededByActiveLocation(entry.loc, activeLocations) + ) { continue; } @@ -101,5 +119,19 @@ export function collectInactiveTraversalLocations( } } + // Scope blocks: dead when no active wire location falls within the scope's span. + for (const entry of scopeEntries) { + if (!entry.loc) continue; + const hasActiveDescendant = activeLocations.some((al) => + containsLocation(entry.loc!, al), + ); + if (hasActiveDescendant) continue; + const key = locationKey(entry.loc); + if (!seen.has(key)) { + seen.add(key); + result.push(entry.loc); + } + } + return result; } diff --git a/packages/playground/test/trace-highlighting.test.ts b/packages/playground/test/trace-highlighting.test.ts index 0bdc96c2..876272e3 100644 --- a/packages/playground/test/trace-highlighting.test.ts +++ b/packages/playground/test/trace-highlighting.test.ts @@ -40,6 +40,185 @@ bridge Query.evaluate { ); }); + test("does not mark pipe expression as dead code when its primary entry is active", () => { + const bridge = getBridge(`version 1.5 + +bridge Query.searchTrains { + with input as i + with output as o + with std.str.toUpperCase as uc + + o.name <- uc:i.name +}`); + + const manifest = buildTraversalManifest(bridge); + const primaryEntry = manifest.find((entry) => entry.id === "name/primary"); + assert.ok(primaryEntry, "expected primary manifest entry"); + // Activate only the primary entry (pipe succeeded, no error) + const activeIds = new Set( + decodeExecutionTrace(manifest, 1n << BigInt(primaryEntry.bitIndex)).map( + (entry) => entry.id, + ), + ); + // The pipe primary/error entry is a narrower span within the active primary span. + // It should be suppressed (superseded), not shown as dead code. + assert.deepEqual( + collectInactiveTraversalLocations(manifest, activeIds), + [], + ); + }); + + test("unexecuted nullish fallback branch is shown as dead code", () => { + // o.value <- i.primary ?? i.fallback + // When i.primary is not nullish, the ?? fallback is never taken. + // It must appear as dead code — NOT suppressed just because its loc + // falls within the active primary's full-wire-span loc. + const bridge = getBridge(`version 1.5 + +bridge Query.test { + with input as i + with output as o + + o.value <- i.primary ?? i.fallback +}`); + + const manifest = buildTraversalManifest(bridge); + const primaryEntry = manifest.find((e) => e.id === "value/primary"); + const fallbackEntry = manifest.find((e) => e.id === "value/fallback:0"); + assert.ok(primaryEntry, "expected value/primary"); + assert.ok(fallbackEntry?.loc, "expected value/fallback:0 with loc"); + // Activate only the primary path (i.primary was not nullish). + const activeIds = new Set( + decodeExecutionTrace(manifest, 1n << BigInt(primaryEntry.bitIndex)).map( + (e) => e.id, + ), + ); + const inactiveLocs = collectInactiveTraversalLocations(manifest, activeIds); + assert.ok( + inactiveLocs.some( + (l) => + l.startLine === fallbackEntry.loc!.startLine && + l.startColumn === fallbackEntry.loc!.startColumn, + ), + "fallback loc should be in dead code locations", + ); + }); + + test("ref wire and pipe wire both inactive gray their full wire lines consistently", () => { + // When two sibling wires are both inactive, both should highlight the full + // wire statement — not just the RHS expression for the ref wire. + const bridge = getBridge(`version 1.5 + +bridge Query.test { + with input as i + with output as o + with std.str.toUpperCase as uc + + o.id <- i.user.id + o.name <- uc:i.user.name +}`); + + const manifest = buildTraversalManifest(bridge); + const idPrimary = manifest.find((entry) => entry.id === "id/primary"); + const namePrimary = manifest.find((entry) => entry.id === "name/primary"); + assert.ok(idPrimary?.loc, "expected id/primary loc"); + assert.ok(namePrimary?.loc, "expected name/primary loc"); + // Both wire statements begin at the same column ("o.id" / "o.name"). + // After the fix, id/primary should use the full wire loc (matching name/primary), + // not the narrower RHS expression loc it previously had. + assert.equal( + idPrimary.loc.startColumn, + namePrimary.loc.startColumn, + "id/primary and name/primary should start at the same column (full wire, not RHS-only)", + ); + }); + + test("scope blocks with no active fields are dimmed entirely", () => { + const bridge = getBridge(`version 1.5 + +bridge Query.test { + with input as i + with output as o + + o.legs <- i.items[] as s { + .origin { + .id <- s.from.id + .name <- s.from.name + } + .destination { + .id <- s.to.id + .name <- s.to.name + } + } +}`); + + const manifest = buildTraversalManifest(bridge); + // Activate only the origin.id and origin.name wires — destination scope is entirely dead. + const originIdEntry = manifest.find( + (e) => e.id === "legs.origin.id/primary", + ); + const originNameEntry = manifest.find( + (e) => e.id === "legs.origin.name/primary", + ); + assert.ok(originIdEntry, "expected origin.id entry"); + assert.ok(originNameEntry, "expected origin.name entry"); + const traceBit = + (1n << BigInt(originIdEntry.bitIndex)) | + (1n << BigInt(originNameEntry.bitIndex)); + const activeIds = new Set( + decodeExecutionTrace(manifest, traceBit).map((e) => e.id), + ); + + const inactiveLocs = collectInactiveTraversalLocations(manifest, activeIds); + + // The .destination { ... } scope block should appear as dead code. + const destinationScopeEntry = manifest.find( + (e) => e.kind === "scope" && e.loc && inactiveLocs.includes(e.loc), + ); + assert.ok( + destinationScopeEntry, + "expected .destination scope block to be in dead code locations", + ); + }); + + test("scope blocks with at least one active field are not dimmed", () => { + const bridge = getBridge(`version 1.5 + +bridge Query.test { + with input as i + with output as o + + o.legs <- i.items[] as s { + .origin { + .id <- s.from.id + .name <- s.from.name + } + } +}`); + + const manifest = buildTraversalManifest(bridge); + // Activate only origin.id — scope still has an active descendant. + const originIdEntry = manifest.find( + (e) => e.id === "legs.origin.id/primary", + ); + assert.ok(originIdEntry, "expected origin.id entry"); + const activeIds = new Set( + decodeExecutionTrace(manifest, 1n << BigInt(originIdEntry.bitIndex)).map( + (e) => e.id, + ), + ); + + const inactiveLocs = collectInactiveTraversalLocations(manifest, activeIds); + + // The .origin { ... } scope block itself should NOT be dead code (origin.id is active). + const originScope = manifest.find((e) => e.kind === "scope"); + assert.ok(originScope?.loc, "expected origin scope entry with loc"); + assert.ok( + !inactiveLocs.includes(originScope.loc), + "scope block with an active descendant should not be dimmed", + ); + }); + test("keeps granular inactive branch spans when they do not cover active code", () => { const bridge = getBridge(`version 1.5 @@ -51,8 +230,12 @@ bridge Query.test { }`); const manifest = buildTraversalManifest(bridge); + const thenEntry = manifest.find((entry) => entry.id === "name/then"); + assert.ok(thenEntry, "expected then branch manifest entry"); const activeIds = new Set( - decodeExecutionTrace(manifest, 1n << 0n).map((entry) => entry.id), + decodeExecutionTrace(manifest, 1n << BigInt(thenEntry.bitIndex)).map( + (entry) => entry.id, + ), ); const elseEntry = manifest.find((entry) => entry.id === "name/else"); @@ -61,4 +244,60 @@ bridge Query.test { elseEntry.loc, ]); }); + + test("inactive wire is fully dimmed when a sibling wire is active (wireIndex -1 grouping bug)", () => { + // When only `price` is requested, the `source` wire must be dimmed entirely. + // Previously all entries had wireIndex: -1 so they were grouped together, + // causing allDead to be false and `o.source` line to not be dimmed at all. + const bridge = getBridge(`version 1.5 + +bridge Query.assetPrice { + with input as i + with output as o + + o.price <- i.primary ?? i.fallback + o.source <- i.primary? "yes" : "no" +}`); + + const manifest = buildTraversalManifest(bridge); + // Activate only the price wire's primary path + const priceEntry = manifest.find((e) => e.id === "price/primary"); + assert.ok(priceEntry, "expected price/primary entry"); + const activeIds = new Set( + decodeExecutionTrace(manifest, 1n << BigInt(priceEntry.bitIndex)).map( + (e) => e.id, + ), + ); + + const inactiveLocs = collectInactiveTraversalLocations(manifest, activeIds); + + // The entire source wire line should be inactive (wireLoc of source wire). + const sourceThen = manifest.find((e) => e.id === "source/then"); + assert.ok(sourceThen?.wireLoc, "expected source/then entry with wireLoc"); + + // The wireLoc of the source wire must appear in inactive locs with the + // EXACT same start position as the statement (not just the branch literals). + // This verifies the whole statement is dimmed — including the ternary condition + // which has no individual traversal entry pointing at it. + assert.ok( + inactiveLocs.some( + (l) => + l.startLine === sourceThen.wireLoc!.startLine && + l.startColumn === sourceThen.wireLoc!.startColumn, + ), + "source wire full statement (starting at statement column) should be in dead code locations", + ); + + // And the price fallback (i.fallback) should also be inactive. + const priceFallback = manifest.find((e) => e.id === "price/fallback:0"); + assert.ok(priceFallback?.loc, "expected price/fallback:0 entry with loc"); + assert.ok( + inactiveLocs.some( + (l) => + l.startLine === priceFallback.loc!.startLine && + l.startColumn === priceFallback.loc!.startColumn, + ), + "price fallback loc should be in dead code locations", + ); + }); }); diff --git a/packages/playground/vite.config.ts b/packages/playground/vite.config.ts index bf2b6aeb..4d1bed06 100644 --- a/packages/playground/vite.config.ts +++ b/packages/playground/vite.config.ts @@ -4,16 +4,32 @@ import tailwindcss from "@tailwindcss/vite"; import { cloudflare } from "@cloudflare/vite-plugin"; import { fileURLToPath, URL } from "node:url"; +const compilerPreviewEnabled = + process.env.BRIDGE_ENABLE_COMPILER_PREVIEW !== "false"; + export default defineConfig({ plugins: [react(), tailwindcss(), cloudflare()], + define: { + __BRIDGE_COMPILER_PREVIEW__: JSON.stringify(compilerPreviewEnabled), + }, resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)), - "@stackables/bridge-core": fileURLToPath(new URL("../bridge-core/src/index.ts", import.meta.url)), - "@stackables/bridge-stdlib": fileURLToPath(new URL("../bridge-stdlib/src/index.ts", import.meta.url)), - "@stackables/bridge-parser": fileURLToPath(new URL("../bridge-parser/src/index.ts", import.meta.url)), - "@stackables/bridge-graphql": fileURLToPath(new URL("../bridge-graphql/src/index.ts", import.meta.url)), - "@stackables/bridge": fileURLToPath(new URL("../bridge/src/index.ts", import.meta.url)), + "@stackables/bridge-core": fileURLToPath( + new URL("../bridge-core/src/index.ts", import.meta.url), + ), + "@stackables/bridge-stdlib": fileURLToPath( + new URL("../bridge-stdlib/src/index.ts", import.meta.url), + ), + "@stackables/bridge-parser": fileURLToPath( + new URL("../bridge-parser/src/index.ts", import.meta.url), + ), + "@stackables/bridge-graphql": fileURLToPath( + new URL("../bridge-graphql/src/index.ts", import.meta.url), + ), + "@stackables/bridge": fileURLToPath( + new URL("../bridge/src/index.ts", import.meta.url), + ), }, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd7dcf50..d27ba49c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -369,6 +369,9 @@ importers: '@codemirror/commands': specifier: ^6.10.3 version: 6.10.3 + '@codemirror/lang-javascript': + specifier: ^6.2.2 + version: 6.2.5 '@codemirror/lang-json': specifier: ^6.0.2 version: 6.0.2 @@ -402,6 +405,9 @@ importers: '@stackables/bridge': specifier: workspace:* version: link:../bridge + '@stackables/bridge-compiler': + specifier: workspace:* + version: link:../bridge-compiler class-variance-authority: specifier: 0.7.1 version: 0.7.1 @@ -890,6 +896,9 @@ packages: '@codemirror/commands@6.10.3': resolution: {integrity: sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==} + '@codemirror/lang-javascript@6.2.5': + resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==} + '@codemirror/lang-json@6.0.2': resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} @@ -2051,6 +2060,9 @@ packages: '@lezer/highlight@1.2.3': resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + '@lezer/javascript@1.5.4': + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + '@lezer/json@1.0.3': resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} @@ -6384,6 +6396,16 @@ snapshots: '@codemirror/view': 6.40.0 '@lezer/common': 1.5.1 + '@codemirror/lang-javascript@6.2.5': + dependencies: + '@codemirror/autocomplete': 6.20.1 + '@codemirror/language': 6.12.2 + '@codemirror/lint': 6.9.5 + '@codemirror/state': 6.6.0 + '@codemirror/view': 6.40.0 + '@lezer/common': 1.5.1 + '@lezer/javascript': 1.5.4 + '@codemirror/lang-json@6.0.2': dependencies: '@codemirror/language': 6.12.2 @@ -7218,6 +7240,12 @@ snapshots: dependencies: '@lezer/common': 1.5.1 + '@lezer/javascript@1.5.4': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + '@lezer/json@1.0.3': dependencies: '@lezer/common': 1.5.1