From 28281d843e62028ee7e4f5cbb500dbfd9853b88c Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Mon, 16 Mar 2026 07:41:56 +0100 Subject: [PATCH 01/23] Relax checks --- docs/language.ebnf | 174 +++++++++++++++++++++++ packages/bridge-compiler/package.json | 10 +- packages/bridge/test/alias.test.ts | 30 ++-- packages/bridge/test/utils/regression.ts | 59 ++++++-- 4 files changed, 248 insertions(+), 25 deletions(-) create mode 100644 docs/language.ebnf diff --git a/docs/language.ebnf b/docs/language.ebnf new file mode 100644 index 0000000..5a8d36a --- /dev/null +++ b/docs/language.ebnf @@ -0,0 +1,174 @@ +(* ================================================================= *) +(* 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 --- *) +(* ONE unified statement list for everything *) +statement + = with + | wire + | wire_alias + | scope; + +with + = "with", identifier, [ "as", identifier ], [ "memoize" ]; + +(* ONE scope rule *) +scope + = target, "{", { statement }, "}"; + +(* ONE wire rule *) +wire + = target, ( routing + | "=", json ); + +(* ONE alias rule *) +wire_alias + = "alias", identifier, routing; + +(* --- 3. SHARED PATHS & ROUTING --- *) +(* The parser accepts leading dots everywhere. + The compiler will reject them if they are at the root. *) +target + = [ "." ], identifier, { ".", identifier }; + +routing + = "<-", expression, { ( "||" + | "??" ), expression }, [ "catch", expression ]; + +(* --- 4. EXPRESSIONS & REFERENCES --- *) +ref + = identifier, [ [ "?" ], ".", identifier ], { [ "?" ], ".", identifier }; + +expression + = base_expression, [ "?", expression, ":", expression ]; + +base_expression + = json + | ref, "[]", "as", identifier, "{", { statement }, "}" + | ref + | ( "throw" + | "panic" ), [ string ] + | ( "continue" + | "break" ), [ integer ]; + +(* --- 4. EMBEDDED JSON (RFC 8259) --- *) +json + = object + | array + | string + | number + | "true" + | "false" + | "null"; + +object + = "{", [ string, ":", json, { ",", string, ":", json } ], "}"; + +array + = "[", [ json, { ",", json } ], "]"; + +(* --- 5. LEXICAL RULES (TOKENS) --- *) +(* Identifiers map to your names, sources, fields, and iterators *) +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/packages/bridge-compiler/package.json b/packages/bridge-compiler/package.json index 0812fef..a4cd6c1 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/test/alias.test.ts b/packages/bridge/test/alias.test.ts index 32888f1..13c1477 100644 --- a/packages/bridge/test/alias.test.ts +++ b/packages/bridge/test/alias.test.ts @@ -14,19 +14,33 @@ regressionTest("alias keyword", { bridge: bridge` version 1.5 - bridge Alias.syntax { - with test.multitool as object - with input as i - with output as o + bridge Array.is_wire { + with context as c - # Simple alias with fallback and catch - alias user_info <- object?.user.info || i.info catch "Unknown" + o.arrayWithFallback <- c.missingArray[] as i { + .value <- i.value || "Fallback 1" + } || c.realArray[] as i { + .value <- i.value || "Fallback 2" + } catch "No arrays" - o.info <- user_info } + `, + disable: true, tools: tools, scenarios: { - "Alias.syntax": {}, + "Array.is_wire": { + "falsy gate with 2 arrays": { + context: { + missingArray: undefined, + realArray: [{ value: "Real value" }, { value: undefined }], + }, + input: {}, + assertData: { + arrayWithFallback: [{ value: "Real value" }, { value: "Fallback" }], + }, + assertTraces: 0, + }, + }, }, }); diff --git a/packages/bridge/test/utils/regression.ts b/packages/bridge/test/utils/regression.ts index 9351bd0..a1a9006 100644 --- a/packages/bridge/test/utils/regression.ts +++ b/packages/bridge/test/utils/regression.ts @@ -360,7 +360,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); @@ -860,7 +860,7 @@ export type Scenario = { * Temporarily disable specific test aspects for this scenario. * The test is still defined (not removed) but will be skipped. */ - disable?: ("runtime" | "compiled" | "graphql")[]; + disable?: true | ("runtime" | "compiled" | "graphql" | "parser")[]; }; export type RegressionTest = { @@ -869,6 +869,7 @@ export type RegressionTest = { context?: Record; /** Tool-level timeout in ms (default: 5 000). */ toolTimeoutMs?: number; + disable?: true | ("runtime" | "compiled" | "graphql" | "parser")[]; scenarios: Record>; }; @@ -1097,14 +1098,36 @@ export function assertGraphqlExpectation( // ── Harness ───────────────────────────────────────────────────────────────── +function isDisabled( + disable: true | ("runtime" | "compiled" | "graphql" | "parser")[] | undefined, + check: "runtime" | "compiled" | "graphql" | "parser", +): boolean { + if (["compiled", "parser"].includes(check)) { + return true; + } + + return ( + disable === true || (Array.isArray(disable) && disable.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 +1146,9 @@ export function regressionTest(name: string, data: RegressionTest) { output: unknown; }> = []; let pendingRuntimeTests = scenarioNames.filter( - (name) => !scenarios[name]!.disable?.includes("runtime"), + (name) => + !isDisabled(data.disable, "runtime") && + !isDisabled(scenarios[name]!.disable, "runtime"), ).length; let resolveRuntimeCollection!: () => void; @@ -1152,7 +1177,10 @@ export function regressionTest(name: string, data: RegressionTest) { for (const { name: engineName, execute } of engines) { test(engineName, async (t) => { - if (scenario.disable?.includes(engineName)) { + if ( + isDisabled(data.disable, engineName) || + isDisabled(scenario.disable, engineName) + ) { t.skip("disabled"); return; } @@ -1297,9 +1325,11 @@ export function regressionTest(name: string, data: RegressionTest) { (name) => !scenarios[name]!.assertError, ); - const allGraphqlDisabled = scenarioNames.every((name) => - scenarios[name]!.disable?.includes("graphql"), - ); + const allGraphqlDisabled = + isDisabled(data.disable, "graphql") || + scenarioNames.every((name) => + isDisabled(scenarios[name]!.disable, "graphql"), + ); if (scenarioNames.length > 0) { describe("graphql replay", () => { @@ -1383,7 +1413,10 @@ 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(data.disable, "graphql") || + isDisabled(scenario.disable, "graphql") + ) { t.skip("disabled"); return; } @@ -1544,9 +1577,11 @@ 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"), - ); + const allRuntimeDisabled = + isDisabled(data.disable, "runtime") || + scenarioNames.every((name) => + isDisabled(scenarios[name]!.disable, "runtime"), + ); if (allRuntimeDisabled) { t.skip("all scenarios have runtime disabled"); return; From 4454e77bd354c3c6938888768ff131766cadb92b Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Mon, 16 Mar 2026 08:11:43 +0100 Subject: [PATCH 02/23] =?UTF-8?q?Phase=201:=20Preparation=20=E2=80=94=20Di?= =?UTF-8?q?sable=20Coupled=20Tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/rearchitecture-plan.md | 217 +++++ .../test/enumerate-traversals.test.ts | 916 +++++++++--------- .../bridge-core/test/execution-tree.test.ts | 162 ++-- .../bridge-core/test/resolve-wires.test.ts | 355 +++---- .../bridge-parser/test/bridge-format.test.ts | 295 +++--- .../test/bridge-printer-examples.test.ts | 98 +- .../bridge-parser/test/bridge-printer.test.ts | 428 ++++---- 7 files changed, 1392 insertions(+), 1079 deletions(-) create mode 100644 docs/rearchitecture-plan.md diff --git a/docs/rearchitecture-plan.md b/docs/rearchitecture-plan.md new file mode 100644 index 0000000..0fa5add --- /dev/null +++ b/docs/rearchitecture-plan.md @@ -0,0 +1,217 @@ +# 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 + +_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. **Keep enabled:** All behavioral `regressionTest` tests in `packages/bridge/test/` + (runtime path) — these are the correctness anchor +7. Verify `pnpm build && pnpm test` passes with skipped tests noted + +--- + +## Phase 2: Define New IR Data Structures + +_Depends on Phase 1. Changes only `bridge-core/src/types.ts`._ + +### New types to add: + +```typescript +// Scope-aware statement — the building block of nested bridge bodies +type Statement = + | WireStatement // target <- expression chain + | WireAliasStatement // alias name <- expression chain + | WithStatement // with [as ] [memoize] + | ScopeStatement // target { Statement[] } + | ForceStatement; // force handle [catch null] + +// Array mapping as a first-class expression +// Added to the Expression union: +// { type: "array"; source: Expression; iteratorName: string; body: Statement[] } +``` + +### Modifications to existing types (transition period): + +- **`Bridge`**: Add `body?: Statement[]` alongside existing `wires`. When `body` + is present, consumers should prefer it. `wires`, `arrayIterators`, `forces` + become legacy and are removed after migration. +- **`ToolDef`**: Add `body?: Statement[]` alongside existing `wires`. +- **`DefineDef`**: Add `body?: Statement[]` alongside existing `wires`. +- **`Expression`**: Add `| { type: "array"; ... }` variant to the union. + +### 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 +- `Wire` type itself stays — wrapped in `WireStatement` in the tree + +--- + +## Phase 3: Update Parser Visitor to Produce Nested IR + +_Depends on Phase 2. Changes `bridge-parser/src/parser/parser.ts` visitor only._ + +1. **`processScopeLines()`**: Stop flattening paths. Emit `ScopeStatement`. +2. **`processElementLines()`**: Stop creating flat element-marked wires. + Produce `ArrayExpression` in the expression tree with `body: Statement[]`. +3. **`bridgeBodyLine` visitor**: Emit `WithStatement` nodes in body. +4. **Array mapping on wires**: Produce wire with source + `{ type: "array", ... }` instead of splitting into wire + metadata. +5. **`force` handling**: Convert from `bridge.forces[]` to `ForceStatement`. +6. **Expression desugaring** (arithmetic, concat, pipes): Keep as expression-level IR. + +**No Chevrotain grammar changes needed** — only the CST→AST visitor. + +--- + +## Phase 4: Update Execution Engine + +_Depends on Phase 3. Most critical phase._ + +Files: `ExecutionTree.ts`, `scheduleTools.ts`, `resolveWires.ts`, +`resolveWiresSources.ts`, `materializeShadows.ts`. + +1. **Scope chain**: `ScopeFrame { handles, wires, parent? }` — tool lookup + walks frames upward (shadowing semantics) +2. **Wire pre-indexing**: Walk statement tree once at construction, build + `Map` for O(1) lookup +3. **Array execution**: `ArrayExpression` evaluated → shadow tree per element + with nested `body: Statement[]` and iterator binding +4. **Define inlining**: Inline as nested `Statement[]` blocks +5. **`schedule()`/`pullSingle()`**: Scope-aware resolution + +**Gate:** All behavioral `regressionTest` suites must pass. + +--- + +## Phase 5: Reimplement Serializer + Re-enable Parser Tests + +_Depends on Phase 4. Can run parallel with early Phase 6._ + +1. Rewrite `bridge-format.ts` to walk `Statement[]` tree +2. Update `bridge-printer.ts` for new AST shape +3. Update `bridge-lint.ts` to walk `Statement[]` +4. Re-enable parser roundtrip tests (with updated fixtures) +5. Re-enable `execution-tree.test.ts`, `resolve-wires.test.ts`, + `enumerate-traversals.test.ts` with updated assertions + +--- + +## 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-core/test/enumerate-traversals.test.ts b/packages/bridge-core/test/enumerate-traversals.test.ts index 8cfc76c..6255098 100644 --- a/packages/bridge-core/test/enumerate-traversals.test.ts +++ b/packages/bridge-core/test/enumerate-traversals.test.ts @@ -27,9 +27,12 @@ function ids(entries: TraversalEntry[]): string[] { // ── Simple wires ──────────────────────────────────────────────────────────── -describe("enumerateTraversalIds", () => { - test("simple pull wire — 1 traversal (primary)", () => { - const instr = getBridge(bridge` +describe( + "enumerateTraversalIds", + { skip: "Phase 1: IR rearchitecture" }, + () => { + test("simple pull wire — 1 traversal (primary)", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with api @@ -39,17 +42,17 @@ describe("enumerateTraversalIds", () => { 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", - ); - }); + 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` + test("constant wire — 1 traversal (const)", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with api @@ -58,16 +61,16 @@ describe("enumerateTraversalIds", () => { 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")); - }); + 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 ─────────────────────────────────────────────────────── + // ── Fallback chains ─────────────────────────────────────────────────────── - test("|| fallback — 2 non-error traversals (primary + fallback)", () => { - const instr = getBridge(bridge` + test("|| fallback — 2 non-error traversals (primary + fallback)", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with a @@ -79,19 +82,19 @@ describe("enumerateTraversalIds", () => { 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); - }); + 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` + test("?? fallback — 2 non-error traversals (primary + nullish fallback)", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with api @@ -101,18 +104,18 @@ describe("enumerateTraversalIds", () => { 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"); - }); + 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` + test("|| || — 3 non-error traversals (primary + 2 fallbacks)", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with a @@ -124,22 +127,22 @@ describe("enumerateTraversalIds", () => { 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); - }); + 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 ───────────────────────────────────────────────────────────────── + // ── Catch ───────────────────────────────────────────────────────────────── - test("catch — 2 traversals (primary + catch)", () => { - const instr = getBridge(bridge` + test("catch — 2 traversals (primary + catch)", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with api @@ -149,19 +152,19 @@ describe("enumerateTraversalIds", () => { 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"); - }); + 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 ───────────────────────────────── + // ── Problem statement example: || + catch ───────────────────────────────── - test("o <- i.a || i.b catch i.c — 3 traversals", () => { - const instr = getBridge(bridge` + test("o <- i.a || i.b catch i.c — 3 traversals", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with a @@ -173,20 +176,20 @@ describe("enumerateTraversalIds", () => { 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"); - }); + 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 ─────────────────────────────────────────────── + // ── Error traversal entries ─────────────────────────────────────────────── - test("a.label || b.label — 4 traversals (primary, fallback, primary/error, fallback/error)", () => { - const instr = getBridge(bridge` + 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 @@ -198,25 +201,25 @@ describe("enumerateTraversalIds", () => { 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); - }); + 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` + test("a.label || b?.label — 3 traversals (primary, fallback, primary/error)", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with a @@ -228,23 +231,23 @@ describe("enumerateTraversalIds", () => { 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); - }); + 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` + test("a.label || b.label catch 'whatever' — 3 traversals (primary, fallback, catch)", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with a @@ -256,22 +259,22 @@ describe("enumerateTraversalIds", () => { 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); - }); + 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` + test("catch with tool ref — catch/error entry added", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with a @@ -283,22 +286,22 @@ describe("enumerateTraversalIds", () => { 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); - }); + 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` + test("simple pull wire — primary + primary/error", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with api @@ -308,19 +311,19 @@ describe("enumerateTraversalIds", () => { 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); - }); + 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` + test("input ref wire — no error entry (inputs cannot throw)", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with api @@ -330,18 +333,18 @@ describe("enumerateTraversalIds", () => { 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); - }); + 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` + test("safe (?.) wire — no primary/error entry", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with api @@ -351,18 +354,18 @@ describe("enumerateTraversalIds", () => { 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); - }); + 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` + test("error entries have unique IDs", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with a @@ -374,20 +377,20 @@ describe("enumerateTraversalIds", () => { 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)}`, - ); - }); + 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 ─────────────────────────────────────────────────────── + // ── Array iterators ─────────────────────────────────────────────────────── - test("array block — adds empty-array traversal", () => { - const instr = getBridge(bridge` + test("array block — adds empty-array traversal", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with api @@ -398,16 +401,16 @@ describe("enumerateTraversalIds", () => { } } `); - const entries = enumerateTraversalIds(instr); - const emptyArr = entries.filter((e) => e.kind === "empty-array"); - assert.equal(emptyArr.length, 1); - assert.equal(emptyArr[0].wireIndex, -1); - }); + 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 + ?? ───────────────────────────────── + // ── Problem statement example: array + ?? ───────────────────────────────── - test("o.out <- i.array[] as a { .data <- a.a ?? a.b } — 3 traversals", () => { - const instr = getBridge(bridge` + 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 @@ -417,24 +420,24 @@ describe("enumerateTraversalIds", () => { } } `); - 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"); - }); + 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 ───────────────────────────────────────────────────────── + // ── Nested arrays ───────────────────────────────────────────────────────── - test("nested array blocks — 2 empty-array entries", () => { - const instr = getBridge(bridge` + test("nested array blocks — 2 empty-array entries", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with api @@ -447,15 +450,15 @@ describe("enumerateTraversalIds", () => { } } `); - const entries = enumerateTraversalIds(instr); - const emptyArr = entries.filter((e) => e.kind === "empty-array"); - assert.equal(emptyArr.length, 2, "two array scopes"); - }); + const entries = enumerateTraversalIds(instr); + const emptyArr = entries.filter((e) => e.kind === "empty-array"); + assert.equal(emptyArr.length, 2, "two array scopes"); + }); - // ── IDs are unique ──────────────────────────────────────────────────────── + // ── IDs are unique ──────────────────────────────────────────────────────── - test("all IDs within a bridge are unique", () => { - const instr = getBridge(bridge` + test("all IDs within a bridge are unique", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with a @@ -468,20 +471,20 @@ describe("enumerateTraversalIds", () => { 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)}`, - ); - }); + 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 ────────────────────────────────────────────────── + // ── TraversalEntry shape ────────────────────────────────────────────────── - test("entries have correct structure", () => { - const instr = getBridge(bridge` + test("entries have correct structure", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with api @@ -491,23 +494,23 @@ describe("enumerateTraversalIds", () => { 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"); - }); + 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 ────────────────────────────────────────────────────── + // ── Conditional wire ────────────────────────────────────────────────────── - test("conditional (ternary) wire — 2 traversals (then + else)", () => { - const instr = getBridge(bridge` + test("conditional (ternary) wire — 2 traversals (then + else)", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with api @@ -517,21 +520,21 @@ describe("enumerateTraversalIds", () => { 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"); - }); + 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 ───────────────────────────────────── + // ── Total count is a complexity proxy ───────────────────────────────────── - test("total traversal count reflects complexity", () => { - const simple = getBridge(bridge` + test("total traversal count reflects complexity", () => { + const simple = getBridge(bridge` version 1.5 bridge Query.simple { with api @@ -539,7 +542,7 @@ describe("enumerateTraversalIds", () => { o.value <- api.value } `); - const complex = getBridge(bridge` + const complex = getBridge(bridge` version 1.5 bridge Query.complex { with a @@ -555,24 +558,28 @@ describe("enumerateTraversalIds", () => { } } `); - const simpleCount = enumerateTraversalIds(simple).length; - const complexCount = enumerateTraversalIds(complex).length; - assert.ok( - complexCount > simpleCount, - `complex (${complexCount}) should exceed simple (${simpleCount})`, - ); - }); -}); + 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); - }); +describe( + "buildTraversalManifest", + { skip: "Phase 1: IR rearchitecture" }, + () => { + test("is an alias for enumerateTraversalIds", () => { + assert.strictEqual(buildTraversalManifest, enumerateTraversalIds); + }); - test("entries have sequential bitIndex starting at 0", () => { - const instr = getBridge(bridge` + test("entries have sequential bitIndex starting at 0", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with a @@ -585,20 +592,21 @@ describe("buildTraversalManifest", () => { o.score <- a.score ?? 0 } `); - const manifest = buildTraversalManifest(instr); - for (let i = 0; i < manifest.length; i++) { - assert.equal( - manifest[i].bitIndex, - i, - `entry ${i} should have bitIndex ${i}`, - ); - } - }); -}); + const manifest = buildTraversalManifest(instr); + for (let i = 0; i < manifest.length; i++) { + assert.equal( + manifest[i].bitIndex, + i, + `entry ${i} should have bitIndex ${i}`, + ); + } + }); + }, +); // ── decodeExecutionTrace ──────────────────────────────────────────────────── -describe("decodeExecutionTrace", () => { +describe("decodeExecutionTrace", { skip: "Phase 1: IR rearchitecture" }, () => { test("empty trace returns empty array", () => { const instr = getBridge(bridge` version 1.5 @@ -701,9 +709,12 @@ function getDoc(source: string): BridgeDocument { return JSON.parse(JSON.stringify(raw)) as BridgeDocument; } -describe("executionTraceId: end-to-end", () => { - test("simple pull wire — primary bits are set", async () => { - const doc = getDoc(`version 1.5 +describe( + "executionTraceId: end-to-end", + { skip: "Phase 1: IR rearchitecture" }, + () => { + test("simple pull wire — primary bits are set", async () => { + const doc = getDoc(`version 1.5 bridge Query.demo { with api with input as i @@ -711,27 +722,27 @@ bridge Query.demo { api.q <- i.q o.result <- api.label }`); - const { executionTraceId } = await executeBridge({ - document: doc, - operation: "Query.demo", - input: { q: "test" }, - tools: { api: async () => ({ label: "Hello" }) }, - }); + const { executionTraceId } = await executeBridge({ + document: doc, + operation: "Query.demo", + input: { q: "test" }, + tools: { api: async () => ({ label: "Hello" }) }, + }); - assert.ok(executionTraceId > 0n, "trace should have bits set"); + assert.ok(executionTraceId > 0n, "trace should have bits set"); - // Decode and verify - const instr = doc.instructions.find( - (i): i is Bridge => i.kind === "bridge", - )!; - const manifest = buildTraversalManifest(instr); - const decoded = decodeExecutionTrace(manifest, executionTraceId); - const kinds = decoded.map((e) => e.kind); - assert.ok(kinds.includes("primary"), "should include primary paths"); - }); + // Decode and verify + const instr = doc.instructions.find( + (i): i is Bridge => i.kind === "bridge", + )!; + const manifest = buildTraversalManifest(instr); + const decoded = decodeExecutionTrace(manifest, executionTraceId); + const kinds = decoded.map((e) => e.kind); + assert.ok(kinds.includes("primary"), "should include primary paths"); + }); - test("fallback fires — fallback bit is set", async () => { - const doc = getDoc(`version 1.5 + test("fallback fires — fallback bit is set", async () => { + const doc = getDoc(`version 1.5 bridge Query.demo { with api with input as i @@ -739,26 +750,26 @@ bridge Query.demo { api.q <- i.q o.label <- api.label || "default" }`); - const { executionTraceId, data } = await executeBridge({ - document: doc, - operation: "Query.demo", - input: { q: "test" }, - tools: { api: async () => ({ label: null }) }, - }); + const { executionTraceId, data } = await executeBridge({ + document: doc, + operation: "Query.demo", + input: { q: "test" }, + tools: { api: async () => ({ label: null }) }, + }); - assert.equal((data as any).label, "default"); + assert.equal((data as any).label, "default"); - const instr = doc.instructions.find( - (i): i is Bridge => i.kind === "bridge", - )!; - const manifest = buildTraversalManifest(instr); - const decoded = decodeExecutionTrace(manifest, executionTraceId); - const kinds = decoded.map((e) => e.kind); - assert.ok(kinds.includes("fallback"), "should include fallback path"); - }); + const instr = doc.instructions.find( + (i): i is Bridge => i.kind === "bridge", + )!; + const manifest = buildTraversalManifest(instr); + const decoded = decodeExecutionTrace(manifest, executionTraceId); + const kinds = decoded.map((e) => e.kind); + assert.ok(kinds.includes("fallback"), "should include fallback path"); + }); - test("catch fires — catch bit is set", async () => { - const doc = getDoc(`version 1.5 + test("catch fires — catch bit is set", async () => { + const doc = getDoc(`version 1.5 bridge Query.demo { with api with input as i @@ -766,30 +777,30 @@ bridge Query.demo { api.q <- i.q o.lat <- api.lat catch 0 }`); - const { executionTraceId, data } = await executeBridge({ - document: doc, - operation: "Query.demo", - input: { q: "test" }, - tools: { - api: async () => { - throw new Error("boom"); + const { executionTraceId, data } = await executeBridge({ + document: doc, + operation: "Query.demo", + input: { q: "test" }, + tools: { + api: async () => { + throw new Error("boom"); + }, }, - }, - }); + }); - assert.equal((data as any).lat, 0); + assert.equal((data as any).lat, 0); - const instr = doc.instructions.find( - (i): i is Bridge => i.kind === "bridge", - )!; - const manifest = buildTraversalManifest(instr); - const decoded = decodeExecutionTrace(manifest, executionTraceId); - const kinds = decoded.map((e) => e.kind); - assert.ok(kinds.includes("catch"), "should include catch path"); - }); + const instr = doc.instructions.find( + (i): i is Bridge => i.kind === "bridge", + )!; + const manifest = buildTraversalManifest(instr); + const decoded = decodeExecutionTrace(manifest, executionTraceId); + const kinds = decoded.map((e) => e.kind); + assert.ok(kinds.includes("catch"), "should include catch path"); + }); - test("ternary — then branch bit is set", async () => { - const doc = getDoc(`version 1.5 + test("ternary — then branch bit is set", async () => { + const doc = getDoc(`version 1.5 bridge Query.demo { with api with input as i @@ -797,25 +808,25 @@ bridge Query.demo { api.q <- i.q o.label <- i.flag ? api.a : api.b }`); - const { executionTraceId } = await executeBridge({ - document: doc, - operation: "Query.demo", - input: { q: "test", flag: true }, - tools: { api: async () => ({ a: "yes", b: "no" }) }, - }); + const { executionTraceId } = await executeBridge({ + document: doc, + operation: "Query.demo", + input: { q: "test", flag: true }, + tools: { api: async () => ({ a: "yes", b: "no" }) }, + }); - const instr = doc.instructions.find( - (i): i is Bridge => i.kind === "bridge", - )!; - const manifest = buildTraversalManifest(instr); - const decoded = decodeExecutionTrace(manifest, executionTraceId); - const kinds = decoded.map((e) => e.kind); - assert.ok(kinds.includes("then"), "should include then path"); - assert.ok(!kinds.includes("else"), "should NOT include else path"); - }); + const instr = doc.instructions.find( + (i): i is Bridge => i.kind === "bridge", + )!; + const manifest = buildTraversalManifest(instr); + const decoded = decodeExecutionTrace(manifest, executionTraceId); + const kinds = decoded.map((e) => e.kind); + assert.ok(kinds.includes("then"), "should include then path"); + assert.ok(!kinds.includes("else"), "should NOT include else path"); + }); - test("ternary — else branch bit is set", async () => { - const doc = getDoc(`version 1.5 + test("ternary — else branch bit is set", async () => { + const doc = getDoc(`version 1.5 bridge Query.demo { with api with input as i @@ -823,49 +834,49 @@ bridge Query.demo { api.q <- i.q o.label <- i.flag ? api.a : api.b }`); - const { executionTraceId } = await executeBridge({ - document: doc, - operation: "Query.demo", - input: { q: "test", flag: false }, - tools: { api: async () => ({ a: "yes", b: "no" }) }, - }); + const { executionTraceId } = await executeBridge({ + document: doc, + operation: "Query.demo", + input: { q: "test", flag: false }, + tools: { api: async () => ({ a: "yes", b: "no" }) }, + }); - const instr = doc.instructions.find( - (i): i is Bridge => i.kind === "bridge", - )!; - const manifest = buildTraversalManifest(instr); - const decoded = decodeExecutionTrace(manifest, executionTraceId); - const kinds = decoded.map((e) => e.kind); - assert.ok(kinds.includes("else"), "should include else path"); - assert.ok(!kinds.includes("then"), "should NOT include then path"); - }); + const instr = doc.instructions.find( + (i): i is Bridge => i.kind === "bridge", + )!; + const manifest = buildTraversalManifest(instr); + const decoded = decodeExecutionTrace(manifest, executionTraceId); + const kinds = decoded.map((e) => e.kind); + assert.ok(kinds.includes("else"), "should include else path"); + assert.ok(!kinds.includes("then"), "should NOT include then path"); + }); - test("constant wire — const bit is set", async () => { - const doc = getDoc(`version 1.5 + test("constant wire — const bit is set", async () => { + const doc = getDoc(`version 1.5 bridge Query.demo { with api with output as o api.mode = "fast" o.result <- api.label }`); - const { executionTraceId } = await executeBridge({ - document: doc, - operation: "Query.demo", - input: {}, - tools: { api: async () => ({ label: "done" }) }, - }); + const { executionTraceId } = await executeBridge({ + document: doc, + operation: "Query.demo", + input: {}, + tools: { api: async () => ({ label: "done" }) }, + }); - const instr = doc.instructions.find( - (i): i is Bridge => i.kind === "bridge", - )!; - const manifest = buildTraversalManifest(instr); - const decoded = decodeExecutionTrace(manifest, executionTraceId); - const kinds = decoded.map((e) => e.kind); - assert.ok(kinds.includes("const"), "should include const path"); - }); + const instr = doc.instructions.find( + (i): i is Bridge => i.kind === "bridge", + )!; + const manifest = buildTraversalManifest(instr); + const decoded = decodeExecutionTrace(manifest, executionTraceId); + const kinds = decoded.map((e) => e.kind); + assert.ok(kinds.includes("const"), "should include const path"); + }); - test("executionTraceId is a bigint suitable for hex encoding", async () => { - const doc = getDoc(`version 1.5 + test("executionTraceId is a bigint suitable for hex encoding", async () => { + const doc = getDoc(`version 1.5 bridge Query.demo { with api with input as i @@ -873,20 +884,20 @@ bridge Query.demo { api.q <- i.q o.result <- api.label }`); - const { executionTraceId } = await executeBridge({ - document: doc, - operation: "Query.demo", - input: { q: "Berlin" }, - tools: { api: async () => ({ label: "Berlin" }) }, - }); + const { executionTraceId } = await executeBridge({ + document: doc, + operation: "Query.demo", + input: { q: "Berlin" }, + tools: { api: async () => ({ label: "Berlin" }) }, + }); - assert.equal(typeof executionTraceId, "bigint"); - const hex = `0x${executionTraceId.toString(16)}`; - assert.ok(hex.startsWith("0x"), "should be hex-encodable"); - }); + assert.equal(typeof executionTraceId, "bigint"); + const hex = `0x${executionTraceId.toString(16)}`; + assert.ok(hex.startsWith("0x"), "should be hex-encodable"); + }); - test("primary error bit is set when tool throws", async () => { - const doc = getDoc(`version 1.5 + test("primary error bit is set when tool throws", async () => { + const doc = getDoc(`version 1.5 bridge Query.demo { with api with input as i @@ -894,57 +905,60 @@ bridge Query.demo { api.q <- i.q o.lat <- api.lat }`); - try { - await executeBridge({ + try { + await executeBridge({ + document: doc, + operation: "Query.demo", + input: { q: "test" }, + tools: { + api: async () => { + throw new Error("boom"); + }, + }, + }); + assert.fail("should have thrown"); + } catch (err: any) { + const executionTraceId: bigint = err.executionTraceId; + assert.ok( + typeof executionTraceId === "bigint", + "error should carry executionTraceId", + ); + + const instr = doc.instructions.find( + (i): i is Bridge => i.kind === "bridge", + )!; + const manifest = buildTraversalManifest(instr); + const decoded = decodeExecutionTrace(manifest, executionTraceId); + const primaryError = decoded.find( + (e) => e.kind === "primary" && e.error, + ); + assert.ok(primaryError, "primary error bit should be set"); + } + }); + + test("no error bit when tool succeeds", async () => { + const doc = getDoc(`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 { executionTraceId } = await executeBridge({ document: doc, operation: "Query.demo", input: { q: "test" }, - tools: { - api: async () => { - throw new Error("boom"); - }, - }, + tools: { api: async () => ({ value: "ok" }) }, }); - assert.fail("should have thrown"); - } catch (err: any) { - const executionTraceId: bigint = err.executionTraceId; - assert.ok( - typeof executionTraceId === "bigint", - "error should carry executionTraceId", - ); const instr = doc.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; const manifest = buildTraversalManifest(instr); const decoded = decodeExecutionTrace(manifest, executionTraceId); - const primaryError = decoded.find((e) => e.kind === "primary" && e.error); - assert.ok(primaryError, "primary error bit should be set"); - } - }); - - test("no error bit when tool succeeds", async () => { - const doc = getDoc(`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 { executionTraceId } = await executeBridge({ - document: doc, - operation: "Query.demo", - input: { q: "test" }, - tools: { api: async () => ({ value: "ok" }) }, + const errorEntries = decoded.filter((e) => e.error); + assert.equal(errorEntries.length, 0, "no error bits when tool succeeds"); }); - - const instr = doc.instructions.find( - (i): i is Bridge => i.kind === "bridge", - )!; - const manifest = buildTraversalManifest(instr); - const decoded = decodeExecutionTrace(manifest, executionTraceId); - const errorEntries = decoded.filter((e) => e.error); - assert.equal(errorEntries.length, 0, "no error bits when tool succeeds"); - }); -}); + }, +); diff --git a/packages/bridge-core/test/execution-tree.test.ts b/packages/bridge-core/test/execution-tree.test.ts index bbe3082..d4cdb6b 100644 --- a/packages/bridge-core/test/execution-tree.test.ts +++ b/packages/bridge-core/test/execution-tree.test.ts @@ -17,93 +17,101 @@ 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, - ); - }); +describe( + "ExecutionTree edge cases", + { skip: "Phase 1: IR rearchitecture" }, + () => { + 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("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; + 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, - ); - }); + 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 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) }; + 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/); - }); -}); + 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/); + }); + }, +); // ═══════════════════════════════════════════════════════════════════════════ // Error class identity // ═══════════════════════════════════════════════════════════════════════════ -describe("BridgePanicError / BridgeAbortError", () => { - test("BridgePanicError extends Error", () => { - const err = new BridgePanicError("test"); - assert.ok(err instanceof Error); - assert.ok(err instanceof BridgePanicError); - assert.equal(err.name, "BridgePanicError"); - assert.equal(err.message, "test"); - }); +describe( + "BridgePanicError / BridgeAbortError", + { skip: "Phase 1: IR rearchitecture" }, + () => { + test("BridgePanicError extends Error", () => { + const err = new BridgePanicError("test"); + assert.ok(err instanceof Error); + assert.ok(err instanceof BridgePanicError); + assert.equal(err.name, "BridgePanicError"); + assert.equal(err.message, "test"); + }); - test("BridgeAbortError extends Error with default message", () => { - const err = new BridgeAbortError(); - assert.ok(err instanceof Error); - assert.ok(err instanceof BridgeAbortError); - assert.equal(err.name, "BridgeAbortError"); - assert.equal(err.message, "Execution aborted by external signal"); - }); + test("BridgeAbortError extends Error with default message", () => { + const err = new BridgeAbortError(); + assert.ok(err instanceof Error); + assert.ok(err instanceof BridgeAbortError); + assert.equal(err.name, "BridgeAbortError"); + assert.equal(err.message, "Execution aborted by external signal"); + }); - test("BridgeAbortError accepts custom message", () => { - const err = new BridgeAbortError("custom"); - assert.equal(err.message, "custom"); - }); -}); + test("BridgeAbortError accepts custom message", () => { + const err = new BridgeAbortError("custom"); + assert.equal(err.message, "custom"); + }); + }, +); diff --git a/packages/bridge-core/test/resolve-wires.test.ts b/packages/bridge-core/test/resolve-wires.test.ts index e923fbe..3be6fa9 100644 --- a/packages/bridge-core/test/resolve-wires.test.ts +++ b/packages/bridge-core/test/resolve-wires.test.ts @@ -46,7 +46,7 @@ function makeWire(sources: Wire["sources"], opts: Partial = {}): Wire { // ── evaluateExpression ────────────────────────────────────────────────────── -describe("evaluateExpression", () => { +describe("evaluateExpression", { skip: "Phase 1: IR rearchitecture" }, () => { test("evaluates a ref expression", async () => { const ctx = makeCtx({ "m.x": "hello" }); const expr: Expression = { type: "ref", ref: ref("x") }; @@ -167,192 +167,207 @@ describe("evaluateExpression", () => { // ── 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"); - }); +describe( + "applyFallbackGates — falsy (||)", + { skip: "Phase 1: IR rearchitecture" }, + () => { + 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("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("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("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("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("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("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 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 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 — 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 — 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 — 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/); - }); -}); + 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); + }); -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("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("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 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 (??)", + { skip: "Phase 1: IR rearchitecture" }, + () => { + 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("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); - }); -}); + 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"); + }); -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("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 ??", + { skip: "Phase 1: IR rearchitecture" }, + () => { + 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("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("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("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"); - }); -}); + 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", () => { +describe("applyCatch", { skip: "Phase 1: IR rearchitecture" }, () => { test("returns undefined when no catch handler", async () => { const ctx = makeCtx(); const w = makeWire([{ expr: { type: "ref", ref: REF } }]); diff --git a/packages/bridge-parser/test/bridge-format.test.ts b/packages/bridge-parser/test/bridge-format.test.ts index 566dca6..7161ced 100644 --- a/packages/bridge-parser/test/bridge-format.test.ts +++ b/packages/bridge-parser/test/bridge-format.test.ts @@ -429,7 +429,7 @@ describe("parseBridge", () => { // ── serializeBridge ───────────────────────────────────────────────────────── -describe("serializeBridge", () => { +describe("serializeBridge", { skip: "Phase 1: IR rearchitecture" }, () => { test("simple bridge roundtrip", () => { const input = bridge` version 1.5 @@ -982,9 +982,12 @@ describe("parseBridge: tool blocks", () => { // ── Tool roundtrip ────────────────────────────────────────────────────────── -describe("serializeBridge: tool roundtrip", () => { - test("GET tool roundtrips", () => { - const input = bridge` +describe( + "serializeBridge: tool roundtrip", + { skip: "Phase 1: IR rearchitecture" }, + () => { + test("GET tool roundtrips", () => { + const input = bridge` version 1.5 tool hereapi from httpCall { with context @@ -1008,15 +1011,15 @@ describe("serializeBridge: tool roundtrip", () => { } `; - const instructions = parseBridge(input); - assertDeepStrictEqualIgnoringLoc( - parseBridge(serializeBridge(instructions)), - instructions, - ); - }); + const instructions = parseBridge(input); + assertDeepStrictEqualIgnoringLoc( + parseBridge(serializeBridge(instructions)), + instructions, + ); + }); - test("POST tool roundtrips", () => { - const input = bridge` + test("POST tool roundtrips", () => { + const input = bridge` version 1.5 tool sendgrid from httpCall { with context @@ -1040,15 +1043,15 @@ describe("serializeBridge: tool roundtrip", () => { } `; - const instructions = parseBridge(input); - assertDeepStrictEqualIgnoringLoc( - parseBridge(serializeBridge(instructions)), - instructions, - ); - }); + const instructions = parseBridge(input); + assertDeepStrictEqualIgnoringLoc( + parseBridge(serializeBridge(instructions)), + instructions, + ); + }); - test("serialized tool output is human-readable", () => { - const input = bridge` + test("serialized tool output is human-readable", () => { + const input = bridge` version 1.5 tool hereapi from httpCall { with context @@ -1069,15 +1072,16 @@ describe("serializeBridge: tool roundtrip", () => { } `; - const output = serializeBridge(parseBridge(input)); - assert.ok(output.includes("tool hereapi from httpCall")); - assert.ok(output.includes("tool hereapi.geocode from hereapi")); - assert.ok( - output.includes('baseUrl = "https://geocode.search.hereapi.com/v1"'), - ); - assert.ok(output.includes("headers.apiKey <- context.hereapi.apiKey")); - }); -}); + const output = serializeBridge(parseBridge(input)); + assert.ok(output.includes("tool hereapi from httpCall")); + assert.ok(output.includes("tool hereapi.geocode from hereapi")); + assert.ok( + output.includes('baseUrl = "https://geocode.search.hereapi.com/v1"'), + ); + assert.ok(output.includes("headers.apiKey <- context.hereapi.apiKey")); + }); + }, +); // ── Parser robustness ─────────────────────────────────────────────────────── @@ -1471,9 +1475,12 @@ describe("version tags: parser produces version on HandleBinding", () => { }); }); -describe("version tags: round-trip serialization", () => { - test("bridge handle @version survives parse → serialize → parse", () => { - const src = bridge` +describe( + "version tags: round-trip serialization", + { skip: "Phase 1: IR rearchitecture" }, + () => { + test("bridge handle @version survives parse → serialize → parse", () => { + const src = bridge` version 1.5 bridge Query.test { with myCorp.utils@2.1 as utils @@ -1482,39 +1489,39 @@ describe("version tags: round-trip serialization", () => { o.val <- utils.result } `; - const instructions = parseBridge(src); - const serialized = serializeBridge(instructions); - assert.ok( - serialized.includes("myCorp.utils@2.1 as utils"), - `got: ${serialized}`, - ); - // Re-parse and verify - const reparsed = parseBridge(serialized); - const instr = reparsed.instructions.find( - (i): i is Bridge => i.kind === "bridge", - )!; - const h = instr.handles.find( - (h) => h.kind === "tool" && h.handle === "utils", - ); - assert.ok(h); - if (h?.kind === "tool") assert.equal(h.version, "2.1"); - }); + const instructions = parseBridge(src); + const serialized = serializeBridge(instructions); + assert.ok( + serialized.includes("myCorp.utils@2.1 as utils"), + `got: ${serialized}`, + ); + // Re-parse and verify + const reparsed = parseBridge(serialized); + const instr = reparsed.instructions.find( + (i): i is Bridge => i.kind === "bridge", + )!; + const h = instr.handles.find( + (h) => h.kind === "tool" && h.handle === "utils", + ); + assert.ok(h); + if (h?.kind === "tool") assert.equal(h.version, "2.1"); + }); - test("tool dep @version survives round-trip", () => { - const src = bridge` + test("tool dep @version survives round-trip", () => { + const src = bridge` version 1.5 tool myApi from std.httpCall { with stripe@2.0 as pay .baseUrl = "https://api.example.com" } `; - const instructions = parseBridge(src); - const serialized = serializeBridge(instructions); - assert.ok(serialized.includes("stripe@2.0 as pay"), `got: ${serialized}`); - }); + const instructions = parseBridge(src); + const serialized = serializeBridge(instructions); + assert.ok(serialized.includes("stripe@2.0 as pay"), `got: ${serialized}`); + }); - test("unversioned handle stays unversioned in round-trip", () => { - const src = bridge` + test("unversioned handle stays unversioned in round-trip", () => { + const src = bridge` version 1.5 bridge Query.test { with myCorp.utils @@ -1522,53 +1529,64 @@ describe("version tags: round-trip serialization", () => { o.val <- utils.result } `; - const instructions = parseBridge(src); - const serialized = serializeBridge(instructions); - assert.ok(serialized.includes("with myCorp.utils\n"), `got: ${serialized}`); - assert.ok( - !serialized.includes("@"), - `should have no @ sign: ${serialized}`, - ); - }); -}); - -describe("version tags: VersionDecl in serializer", () => { - test("serializer preserves declared version from VersionDecl", () => { - const src = bridge` + const instructions = parseBridge(src); + const serialized = serializeBridge(instructions); + assert.ok( + serialized.includes("with myCorp.utils\n"), + `got: ${serialized}`, + ); + assert.ok( + !serialized.includes("@"), + `should have no @ sign: ${serialized}`, + ); + }); + }, +); + +describe( + "version tags: VersionDecl in serializer", + { skip: "Phase 1: IR rearchitecture" }, + () => { + test("serializer preserves declared version from VersionDecl", () => { + const src = bridge` version 1.7 bridge Query.test { with output as o o.x = "ok" } `; - const instructions = parseBridge(src); - const serialized = serializeBridge(instructions); - assert.ok( - serialized.startsWith("version 1.7\n"), - `expected 'version 1.7' header, got: ${serialized.slice(0, 30)}`, - ); - }); + const instructions = parseBridge(src); + const serialized = serializeBridge(instructions); + assert.ok( + serialized.startsWith("version 1.7\n"), + `expected 'version 1.7' header, got: ${serialized.slice(0, 30)}`, + ); + }); - test("version 1.5 round-trips correctly", () => { - const src = bridge` + test("version 1.5 round-trips correctly", () => { + const src = bridge` version 1.5 bridge Query.test { with output as o o.x = "ok" } `; - const instructions = parseBridge(src); - const serialized = serializeBridge(instructions); - assert.ok( - serialized.startsWith("version 1.5\n"), - `expected 'version 1.5' header, got: ${serialized.slice(0, 30)}`, - ); - }); -}); - -describe("serializeBridge string keyword quoting", () => { - test("keeps reserved-word strings quoted in constant wires", () => { - const src = bridge` + const instructions = parseBridge(src); + const serialized = serializeBridge(instructions); + assert.ok( + serialized.startsWith("version 1.5\n"), + `expected 'version 1.5' header, got: ${serialized.slice(0, 30)}`, + ); + }); + }, +); + +describe( + "serializeBridge string keyword quoting", + { skip: "Phase 1: IR rearchitecture" }, + () => { + test("keeps reserved-word strings quoted in constant wires", () => { + const src = bridge` version 1.5 bridge Query.test { with input as i @@ -1578,43 +1596,47 @@ describe("serializeBridge string keyword quoting", () => { } `; - const serialized = serializeBridge(parseBridge(src)); - assert.ok(serialized.includes('o.value = "const"'), serialized); - assert.doesNotThrow(() => parseBridge(serialized)); - }); -}); - -describe("parser diagnostics and serializer edge cases", () => { - test("parseBridgeDiagnostics reports lexer errors with a range", () => { - const result = parseBridgeDiagnostics( - 'version 1.5\nbridge Query.x {\n with output as o\n o.x = "ok"\n}\n§', - ); - assert.ok(result.diagnostics.length > 0); - assert.equal(result.diagnostics[0]?.severity, "error"); - assert.equal(result.diagnostics[0]?.range.start.line, 5); - assert.equal(result.diagnostics[0]?.range.start.character, 0); - }); + const serialized = serializeBridge(parseBridge(src)); + assert.ok(serialized.includes('o.value = "const"'), serialized); + assert.doesNotThrow(() => parseBridge(serialized)); + }); + }, +); + +describe( + "parser diagnostics and serializer edge cases", + { skip: "Phase 1: IR rearchitecture" }, + () => { + test("parseBridgeDiagnostics reports lexer errors with a range", () => { + const result = parseBridgeDiagnostics( + 'version 1.5\nbridge Query.x {\n with output as o\n o.x = "ok"\n}\n§', + ); + assert.ok(result.diagnostics.length > 0); + assert.equal(result.diagnostics[0]?.severity, "error"); + assert.equal(result.diagnostics[0]?.range.start.line, 5); + assert.equal(result.diagnostics[0]?.range.start.character, 0); + }); - test("reserved source identifier is rejected as const name", () => { - assert.throws( - () => parseBridge('version 1.5\nconst input = "x"'), - /reserved source identifier.*const name/i, - ); - }); + test("reserved source identifier is rejected as const name", () => { + assert.throws( + () => parseBridge('version 1.5\nconst input = "x"'), + /reserved source identifier.*const name/i, + ); + }); - test("serializeBridge keeps passthrough shorthand", () => { - const src = "version 1.5\nbridge Query.upper with std.str.toUpperCase"; - const serialized = serializeBridge(parseBridge(src)); - assert.ok( - serialized.includes("bridge Query.upper with std.str.toUpperCase"), - serialized, - ); - }); + test("serializeBridge keeps passthrough shorthand", () => { + const src = "version 1.5\nbridge Query.upper with std.str.toUpperCase"; + const serialized = serializeBridge(parseBridge(src)); + assert.ok( + serialized.includes("bridge Query.upper with std.str.toUpperCase"), + serialized, + ); + }); - test("define handles cannot be memoized at the invocation site", () => { - assert.throws( - () => - parseBridge(bridge` + test("define handles cannot be memoized at the invocation site", () => { + assert.throws( + () => + parseBridge(bridge` version 1.5 define formatProfile { @@ -1634,12 +1656,12 @@ describe("parser diagnostics and serializer edge cases", () => { } } `), - /memoize|tool/i, - ); - }); + /memoize|tool/i, + ); + }); - test("serializeBridge uses compact default handle bindings", () => { - const src = bridge` + test("serializeBridge uses compact default handle bindings", () => { + const src = bridge` version 1.5 bridge Query.defaults { with input @@ -1649,9 +1671,10 @@ describe("parser diagnostics and serializer edge cases", () => { output.value <- input.name } `; - const serialized = serializeBridge(parseBridge(src)); - assert.ok(serialized.includes(" with input\n"), serialized); - assert.ok(serialized.includes(" with output\n"), serialized); - assert.ok(serialized.includes(" with const\n"), serialized); - }); -}); + const serialized = serializeBridge(parseBridge(src)); + assert.ok(serialized.includes(" with input\n"), serialized); + assert.ok(serialized.includes(" with output\n"), serialized); + assert.ok(serialized.includes(" with const\n"), serialized); + }); + }, +); diff --git a/packages/bridge-parser/test/bridge-printer-examples.test.ts b/packages/bridge-parser/test/bridge-printer-examples.test.ts index 5a9f10c..5198e98 100644 --- a/packages/bridge-parser/test/bridge-printer-examples.test.ts +++ b/packages/bridge-parser/test/bridge-printer-examples.test.ts @@ -12,23 +12,26 @@ import { bridge } from "@stackables/bridge-core"; * ============================================================================ */ -describe("formatBridge - full examples", () => { - test("simple tool declaration", () => { - const input = bridge` +describe( + "formatBridge - full examples", + { skip: "Phase 1: IR rearchitecture" }, + () => { + 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 +39,7 @@ describe("formatBridge - full examples", () => { .method=GET } `; - const expected = bridge` + const expected = bridge` version 1.5 tool geo from std.httpCall { @@ -45,11 +48,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 +61,7 @@ describe("formatBridge - full examples", () => { o.value<-i.value } `; - const expected = bridge` + const expected = bridge` version 1.5 bridge Query.test { @@ -69,25 +72,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 +106,7 @@ o.x<-i.y o.lower <- lc: i.name } `; - const expected = bridge` + const expected = bridge` version 1.5 bridge Query.greet { @@ -119,11 +122,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 +144,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 +164,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 +184,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 4420d9e..45ceb59 100644 --- a/packages/bridge-parser/test/bridge-printer.test.ts +++ b/packages/bridge-parser/test/bridge-printer.test.ts @@ -14,245 +14,272 @@ 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", + { skip: "Phase 1: IR rearchitecture" }, + () => { + 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", + { skip: "Phase 1: IR rearchitecture" }, + () => { + 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", + { skip: "Phase 1: IR rearchitecture" }, + () => { + 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", + { skip: "Phase 1: IR rearchitecture" }, + () => { + 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", + { skip: "Phase 1: IR rearchitecture" }, + () => { + 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", + { skip: "Phase 1: IR rearchitecture" }, + () => { + 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", + { skip: "Phase 1: IR rearchitecture" }, + () => { + 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 +289,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 +313,27 @@ 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", + { skip: "Phase 1: IR rearchitecture" }, + () => { + 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 +342,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 +356,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 +364,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 +396,7 @@ tool b from std.httpCall { .path = "/b" } `; - assert.equal(formatSnippet(input), expected); - }); -}); + assert.equal(formatSnippet(input), expected); + }); + }, +); From 18402bfb0d7c3c321d5e1c200850a29a3a4ecb56 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Mon, 16 Mar 2026 08:45:58 +0100 Subject: [PATCH 03/23] Phase 2: Define New IR Data Structures --- docs/language.ebnf | 38 ++-- docs/rearchitecture-plan.md | 16 +- packages/bridge-compiler/src/codegen.ts | 4 + packages/bridge-core/src/ExecutionTree.ts | 4 + packages/bridge-core/src/index.ts | 8 + .../bridge-core/src/resolveWiresSources.ts | 14 ++ packages/bridge-core/src/scheduleTools.ts | 6 + packages/bridge-core/src/types.ts | 184 +++++++++++++++++- 8 files changed, 245 insertions(+), 29 deletions(-) diff --git a/docs/language.ebnf b/docs/language.ebnf index 5a8d36a..f21ad5d 100644 --- a/docs/language.ebnf +++ b/docs/language.ebnf @@ -21,35 +21,42 @@ tool = "tool", identifier, "from", identifier, "{", { statement }, [ "on error", "=", json ], "}"; (* --- 2. STATEMENTS & SCOPE --- *) -(* ONE unified statement list for everything *) statement = with | wire | wire_alias - | scope; + | scope + | spread + | force; with - = "with", identifier, [ "as", identifier ], [ "memoize" ]; + = "with", identifier, [ "as", identifier ]; -(* ONE scope rule *) scope = target, "{", { statement }, "}"; -(* ONE wire rule *) +(* Standard assignment *) wire = target, ( routing | "=", json ); -(* ONE alias rule *) +(* 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 --- *) -(* The parser accepts leading dots everywhere. - The compiler will reject them if they are at the root. *) target = [ "." ], identifier, { ".", identifier }; +(* The Right-Hand Side Evaluation Chain *) routing = "<-", expression, { ( "||" | "??" ), expression }, [ "catch", expression ]; @@ -58,8 +65,13 @@ routing ref = identifier, [ [ "?" ], ".", identifier ], { [ "?" ], ".", identifier }; +(* An expression is a piped value, optionally followed by a ternary gate *) expression - = base_expression, [ "?", expression, ":", expression ]; + = pipe_chain, [ "?", expression, ":", expression ]; + +(* A pipe chain allows infinite routing: handle:handle:source *) +pipe_chain + = { identifier, [ ".", identifier ], ":" }, base_expression; base_expression = json @@ -70,7 +82,7 @@ base_expression | ( "continue" | "break" ), [ integer ]; -(* --- 4. EMBEDDED JSON (RFC 8259) --- *) +(* --- 5. EMBEDDED JSON (RFC 8259) --- *) json = object | array @@ -86,8 +98,7 @@ object array = "[", [ json, { ",", json } ], "]"; -(* --- 5. LEXICAL RULES (TOKENS) --- *) -(* Identifiers map to your names, sources, fields, and iterators *) +(* --- 6. LEXICAL RULES (TOKENS) --- *) identifier = letter, { letter | digit @@ -155,8 +166,7 @@ letter | "W" | "X" | "Y" - | "Z" - | "_"; + | "Z"; digit = "0" diff --git a/docs/rearchitecture-plan.md b/docs/rearchitecture-plan.md index 0fa5add..9725898 100644 --- a/docs/rearchitecture-plan.md +++ b/docs/rearchitecture-plan.md @@ -35,25 +35,25 @@ chainable with `||`, `??`, `catch`, and `alias`. Currently it's baked into wire --- -## Phase 1: Preparation — Disable Coupled Tests +## 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 +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: +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: +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. **Keep enabled:** All behavioral `regressionTest` tests in `packages/bridge/test/` +6. ✅ **Kept enabled:** All behavioral `regressionTest` tests in `packages/bridge/test/` (runtime path) — these are the correctness anchor -7. Verify `pnpm build && pnpm test` passes with skipped tests noted +7. ✅ Verified `pnpm build && pnpm test` passes with skipped tests noted --- diff --git a/packages/bridge-compiler/src/codegen.ts b/packages/bridge-compiler/src/codegen.ts index 02eb075..164951a 100644 --- a/packages/bridge-compiler/src/codegen.ts +++ b/packages/bridge-compiler/src/codegen.ts @@ -2889,6 +2889,10 @@ class CodegenContext { this.computeExprCost(expr.left, visited), this.computeExprCost(expr.right, visited), ); + case "array": + return this.computeExprCost(expr.source, visited); + case "pipe": + return this.computeExprCost(expr.source, visited); } } diff --git a/packages/bridge-core/src/ExecutionTree.ts b/packages/bridge-core/src/ExecutionTree.ts index 3da63e4..77ce564 100644 --- a/packages/bridge-core/src/ExecutionTree.ts +++ b/packages/bridge-core/src/ExecutionTree.ts @@ -1136,6 +1136,10 @@ export class ExecutionTree implements TreeContext { this.computeExprCost(expr.left, visited), this.computeExprCost(expr.right, visited), ); + case "array": + return this.computeExprCost(expr.source, visited); + case "pipe": + return this.computeExprCost(expr.source, visited); } } diff --git a/packages/bridge-core/src/index.ts b/packages/bridge-core/src/index.ts index d6402f7..a2658fd 100644 --- a/packages/bridge-core/src/index.ts +++ b/packages/bridge-core/src/index.ts @@ -70,12 +70,17 @@ export type { ControlFlowInstruction, DefineDef, Expression, + ForceStatement, HandleBinding, Instruction, NodeRef, + ScopeStatement, SourceLocation, ScalarToolCallFn, ScalarToolFn, + SourceChain, + SpreadStatement, + Statement, ToolCallFn, ToolContext, ToolDef, @@ -83,8 +88,11 @@ export type { ToolMetadata, VersionDecl, Wire, + WireAliasStatement, WireCatch, WireSourceEntry, + WireStatement, + WithStatement, } from "./types.ts"; // ── Wire resolution ───────────────────────────────────────────────────────── diff --git a/packages/bridge-core/src/resolveWiresSources.ts b/packages/bridge-core/src/resolveWiresSources.ts index 52d4117..97c1b5f 100644 --- a/packages/bridge-core/src/resolveWiresSources.ts +++ b/packages/bridge-core/src/resolveWiresSources.ts @@ -65,6 +65,20 @@ export function evaluateExpression( case "or": return evaluateOr(ctx, expr, pullChain); + + 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", + ); + + 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", + ); } } diff --git a/packages/bridge-core/src/scheduleTools.ts b/packages/bridge-core/src/scheduleTools.ts index 6953e71..2d59797 100644 --- a/packages/bridge-core/src/scheduleTools.ts +++ b/packages/bridge-core/src/scheduleTools.ts @@ -98,6 +98,12 @@ function collectExprRefs(expr: Expression, refs: NodeRef[]): void { collectExprRefs(expr.left, refs); collectExprRefs(expr.right, refs); break; + case "array": + collectExprRefs(expr.source, refs); + break; + case "pipe": + collectExprRefs(expr.source, refs); + break; // literal, control — no refs } } diff --git a/packages/bridge-core/src/types.ts b/packages/bridge-core/src/types.ts index 72b570c..921e0e4 100644 --- a/packages/bridge-core/src/types.ts +++ b/packages/bridge-core/src/types.ts @@ -61,8 +61,11 @@ export type Bridge = { field: string; /** Declared data sources and their wire handles */ handles: HandleBinding[]; - /** Connection wires */ + /** Connection wires (legacy flat representation — use `body` for nested IR) */ wires: Wire[]; + /** Nested statement tree — the new scoped IR. + * When present, consumers should prefer this over `wires`/`arrayIterators`/`forces`. */ + body?: Statement[]; /** * When set, this bridge was declared with the passthrough shorthand: * `bridge Type.field with `. The value is the define/tool name. @@ -71,7 +74,8 @@ export type Bridge = { /** 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. */ + * swallow the error for fire-and-forget side-effects. + * @deprecated — use ForceStatement in `body` instead. */ forces?: Array<{ handle: string; module: string; @@ -81,7 +85,9 @@ export type Bridge = { /** When true, errors from this forced handle are silently caught (`?? null`). */ catchError?: true; }>; + /** @deprecated — use ArrayExpression in `body` wire sources instead. */ arrayIterators?: Record; + /** @deprecated — use PipeExpression in `body` wire sources instead. */ pipeHandles?: Array<{ key: string; handle: string; @@ -141,9 +147,12 @@ 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 */ + /** Connection wires (legacy flat representation — use `body` for nested IR) */ wires: Wire[]; - /** Synthetic fork handles for expressions, string interpolation, etc. */ + /** Nested statement tree — the new scoped IR. + * When present, consumers should prefer this over `wires`. */ + body?: Statement[]; + /** @deprecated — use PipeExpression in `body` wire sources instead. */ pipeHandles?: Bridge["pipeHandles"]; /** Error fallback for the tool call — replaces the result when the tool throws. */ onError?: { value: string } | { source: string }; @@ -251,6 +260,44 @@ 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; }; /** @@ -284,6 +331,126 @@ export type WireCatch = | { value: string; loc?: SourceLocation } | { control: ControlFlowInstruction; 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. Statement[] replaces + * the flat Wire[] in Bridge, ToolDef, and DefineDef. + */ +export type Statement = + | WireStatement + | WireAliasStatement + | SpreadStatement + | WithStatement + | ScopeStatement + | ForceStatement; + /** * Named constant definition — a reusable value defined in the bridge file. * @@ -359,11 +526,14 @@ export type DefineDef = { name: string; /** Declared handles (tools, input, output, etc.) */ handles: HandleBinding[]; - /** Connection wires (same format as Bridge wires) */ + /** Connection wires (legacy flat representation — use `body` for nested IR) */ wires: Wire[]; - /** Array iterators (same as Bridge) */ + /** Nested statement tree — the new scoped IR. + * When present, consumers should prefer this over `wires`/`arrayIterators`. */ + body?: Statement[]; + /** @deprecated — use ArrayExpression in `body` wire sources instead. */ arrayIterators?: Record; - /** Pipe fork registry (same as Bridge) */ + /** @deprecated — use PipeExpression in `body` wire sources instead. */ pipeHandles?: Bridge["pipeHandles"]; }; /* c8 ignore stop */ From 5b4c4ea5f1cffa0831c3a743ca53f40f554481b3 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Mon, 16 Mar 2026 09:14:08 +0100 Subject: [PATCH 04/23] Phase 2: Refine types --- docs/rearchitecture-plan.md | 54 +++++++++---- packages/bridge-compiler/src/codegen.ts | 32 +++++--- packages/bridge-core/src/ExecutionTree.ts | 14 ++++ packages/bridge-core/src/index.ts | 2 + .../bridge-core/src/resolveWiresSources.ts | 15 ++++ packages/bridge-core/src/scheduleTools.ts | 12 +++ packages/bridge-core/src/tree-utils.ts | 2 +- packages/bridge-core/src/types.ts | 75 ++++++++++++++++++- packages/bridge-parser/src/bridge-format.ts | 7 +- 9 files changed, 183 insertions(+), 30 deletions(-) diff --git a/docs/rearchitecture-plan.md b/docs/rearchitecture-plan.md index 9725898..82e8a03 100644 --- a/docs/rearchitecture-plan.md +++ b/docs/rearchitecture-plan.md @@ -57,34 +57,58 @@ _No dependencies. Single commit._ --- -## Phase 2: Define New IR Data Structures +## Phase 2: Define New IR Data Structures ✅ COMPLETE -_Depends on Phase 1. Changes only `bridge-core/src/types.ts`._ +_Depends on Phase 1. Changes `bridge-core/src/types.ts` + `index.ts`._ -### New types to add: +### 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 - | WireAliasStatement // alias name <- expression chain + | 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] -// Array mapping as a first-class expression -// Added to the Expression union: -// { type: "array"; source: Expression; iteratorName: string; body: Statement[] } +// 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`**: Add `body?: Statement[]` alongside existing `wires`. When `body` - is present, consumers should prefer it. `wires`, `arrayIterators`, `forces` - become legacy and are removed after migration. -- **`ToolDef`**: Add `body?: Statement[]` alongside existing `wires`. -- **`DefineDef`**: Add `body?: Statement[]` alongside existing `wires`. -- **`Expression`**: Add `| { type: "array"; ... }` variant to the union. +- ✅ **`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: @@ -92,7 +116,7 @@ type Statement = 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 -- `Wire` type itself stays — wrapped in `WireStatement` in the tree +- Legacy `Wire` type stays for backward compat with old engine path --- diff --git a/packages/bridge-compiler/src/codegen.ts b/packages/bridge-compiler/src/codegen.ts index 164951a..d5514c6 100644 --- a/packages/bridge-compiler/src/codegen.ts +++ b/packages/bridge-compiler/src/codegen.ts @@ -72,7 +72,7 @@ function wRef(w: Wire): NodeRef { } /** Primary source literal value (for constant wires). */ function wVal(w: Wire): string { - return (w.sources[0]!.expr as LitExpr).value; + return (w.sources[0]!.expr as LitExpr).value as string; } /** Safe flag on a pull wire's ref expression. */ function wSafe(w: Wire): true | undefined { @@ -96,7 +96,7 @@ function eRef(e: Expression): NodeRef { } /** Value from an expression (for literal-type expressions). */ function eVal(e: Expression): string { - return (e as LitExpr).value; + return (e as LitExpr).value as string; } /** Whether a wire has a catch handler. */ @@ -1676,7 +1676,7 @@ class CodegenContext { forkExprs, ) : tern.then.type === "literal" - ? emitCoerced((tern.then as LitExpr).value) + ? emitCoerced((tern.then as LitExpr).value as string) : "undefined"; const elseExpr = tern.else.type === "ref" @@ -1686,7 +1686,7 @@ class CodegenContext { forkExprs, ) : tern.else.type === "literal" - ? emitCoerced((tern.else as LitExpr).value) + ? emitCoerced((tern.else as LitExpr).value as string) : "undefined"; const expr = `(${condExpr} ? ${thenExpr} : ${elseExpr})`; if (path.length > 1) { @@ -2893,6 +2893,20 @@ class CodegenContext { return this.computeExprCost(expr.source, visited); case "pipe": return this.computeExprCost(expr.source, visited); + case "binary": + return Math.max( + this.computeExprCost(expr.left, visited), + this.computeExprCost(expr.right, visited), + ); + case "unary": + return this.computeExprCost(expr.operand, visited); + case "concat": { + let max = 0; + for (const part of expr.parts) { + max = Math.max(max, this.computeExprCost(part, visited)); + } + return max; + } } } @@ -3198,7 +3212,7 @@ class CodegenContext { wTern(w).thenLoc, ) : (wTern(w).then as LitExpr).value !== undefined - ? emitCoerced((wTern(w).then as LitExpr).value) + ? emitCoerced((wTern(w).then as LitExpr).value as string) : "undefined"; const elseExpr = (wTern(w).else as RefExpr).ref !== undefined @@ -3207,7 +3221,7 @@ class CodegenContext { wTern(w).elseLoc, ) : (wTern(w).else as LitExpr).value !== undefined - ? emitCoerced((wTern(w).else as LitExpr).value) + ? emitCoerced((wTern(w).else as LitExpr).value as string) : "undefined"; let expr = `(${condExpr} ? ${thenExpr} : ${elseExpr})`; expr = this.applyFallbacks(w, expr); @@ -3376,16 +3390,16 @@ class CodegenContext { } return this.wrapExprWithLoc(this.refToExpr(ref), loc); } - return val !== undefined ? emitCoerced(val) : "undefined"; + return val !== undefined ? emitCoerced(val as string) : "undefined"; }; const thenExpr = resolveBranch( (wTern(w).then as RefExpr).ref, - (wTern(w).then as LitExpr).value, + (wTern(w).then as LitExpr).value as string | undefined, wTern(w).thenLoc, ); const elseExpr = resolveBranch( (wTern(w).else as RefExpr).ref, - (wTern(w).else as LitExpr).value, + (wTern(w).else as LitExpr).value as string | undefined, wTern(w).elseLoc, ); let expr = `(${condExpr} ? ${thenExpr} : ${elseExpr})`; diff --git a/packages/bridge-core/src/ExecutionTree.ts b/packages/bridge-core/src/ExecutionTree.ts index 77ce564..d97facc 100644 --- a/packages/bridge-core/src/ExecutionTree.ts +++ b/packages/bridge-core/src/ExecutionTree.ts @@ -1140,6 +1140,20 @@ export class ExecutionTree implements TreeContext { return this.computeExprCost(expr.source, visited); case "pipe": return this.computeExprCost(expr.source, visited); + case "binary": + return Math.max( + this.computeExprCost(expr.left, visited), + this.computeExprCost(expr.right, visited), + ); + case "unary": + return this.computeExprCost(expr.operand, visited); + case "concat": { + let max = 0; + for (const part of expr.parts) { + max = Math.max(max, this.computeExprCost(part, visited)); + } + return max; + } } } diff --git a/packages/bridge-core/src/index.ts b/packages/bridge-core/src/index.ts index a2658fd..c82d690 100644 --- a/packages/bridge-core/src/index.ts +++ b/packages/bridge-core/src/index.ts @@ -61,6 +61,7 @@ export type { Logger } from "./tree-types.ts"; export { SELF_MODULE } from "./types.ts"; export type { + BinaryOp, Bridge, BridgeDocument, BatchToolCallFn, @@ -73,6 +74,7 @@ export type { ForceStatement, HandleBinding, Instruction, + JsonValue, NodeRef, ScopeStatement, SourceLocation, diff --git a/packages/bridge-core/src/resolveWiresSources.ts b/packages/bridge-core/src/resolveWiresSources.ts index 97c1b5f..7f2e0ea 100644 --- a/packages/bridge-core/src/resolveWiresSources.ts +++ b/packages/bridge-core/src/resolveWiresSources.ts @@ -79,6 +79,21 @@ export function evaluateExpression( throw new Error( "Pipe expressions are not yet supported in evaluateExpression", ); + + case "binary": + throw new Error( + "Binary expressions are not yet supported in evaluateExpression", + ); + + case "unary": + throw new Error( + "Unary expressions are not yet supported in evaluateExpression", + ); + + case "concat": + throw new Error( + "Concat expressions are not yet supported in evaluateExpression", + ); } } diff --git a/packages/bridge-core/src/scheduleTools.ts b/packages/bridge-core/src/scheduleTools.ts index 2d59797..f4f2454 100644 --- a/packages/bridge-core/src/scheduleTools.ts +++ b/packages/bridge-core/src/scheduleTools.ts @@ -104,6 +104,18 @@ function collectExprRefs(expr: Expression, refs: NodeRef[]): void { case "pipe": collectExprRefs(expr.source, refs); break; + case "binary": + collectExprRefs(expr.left, refs); + collectExprRefs(expr.right, refs); + break; + case "unary": + collectExprRefs(expr.operand, refs); + break; + case "concat": + for (const part of expr.parts) { + collectExprRefs(part, refs); + } + break; // literal, control — no refs } } diff --git a/packages/bridge-core/src/tree-utils.ts b/packages/bridge-core/src/tree-utils.ts index dba042a..643b4c9 100644 --- a/packages/bridge-core/src/tree-utils.ts +++ b/packages/bridge-core/src/tree-utils.ts @@ -54,7 +54,7 @@ export function pathEquals(a: string[], b: string[]): boolean { * 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; diff --git a/packages/bridge-core/src/types.ts b/packages/bridge-core/src/types.ts index 921e0e4..84e9d98 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. * @@ -221,9 +230,15 @@ 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. + * Legacy path (flat Wire[]): value is still a JSON-encoded string. + * New path (Statement[]): value is the parsed JsonValue. + */ type: "literal"; - value: string; + value: JsonValue; loc?: SourceLocation; } | { @@ -298,8 +313,64 @@ export type Expression = /** 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. * diff --git a/packages/bridge-parser/src/bridge-format.ts b/packages/bridge-parser/src/bridge-format.ts index a21e037..e79a44e 100644 --- a/packages/bridge-parser/src/bridge-format.ts +++ b/packages/bridge-parser/src/bridge-format.ts @@ -31,7 +31,8 @@ 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 wVal = (w: Wire): string => + (w.sources[0].expr as LitExpr).value as string; const wSafe = (w: Wire): true | undefined => { const e = w.sources[0].expr; return e.type === "ref" ? e.safe : undefined; @@ -39,7 +40,7 @@ const wSafe = (w: Wire): true | 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; +const eVal = (e: Expression): string => (e as LitExpr).value as string; /** * Parse .bridge text — delegates to the Chevrotain parser. @@ -113,7 +114,7 @@ function serFallbacks( 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)}`; + if (e.type === "literal") return ` ${op} ${valFn(e.value as string)}`; return ""; }) .join(""); From df1d71f5024d55fe1ec3b7adcbd666794e2c5b30 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Mon, 16 Mar 2026 09:41:34 +0100 Subject: [PATCH 05/23] Phase 3: Update Parser Visitor to Produce Nested IR --- docs/rearchitecture-plan.md | 37 +- packages/bridge-compiler/src/codegen.ts | 2 +- .../bridge-core/src/enumerate-traversals.ts | 3 +- packages/bridge-core/src/types.ts | 2 +- packages/bridge-parser/src/bridge-format.ts | 6 +- .../bridge-parser/src/parser/ast-builder.ts | 2044 +++++++++++++++++ 6 files changed, 2076 insertions(+), 18 deletions(-) create mode 100644 packages/bridge-parser/src/parser/ast-builder.ts diff --git a/docs/rearchitecture-plan.md b/docs/rearchitecture-plan.md index 82e8a03..2c9120f 100644 --- a/docs/rearchitecture-plan.md +++ b/docs/rearchitecture-plan.md @@ -120,18 +120,31 @@ type Statement = --- -## Phase 3: Update Parser Visitor to Produce Nested IR - -_Depends on Phase 2. Changes `bridge-parser/src/parser/parser.ts` visitor only._ - -1. **`processScopeLines()`**: Stop flattening paths. Emit `ScopeStatement`. -2. **`processElementLines()`**: Stop creating flat element-marked wires. - Produce `ArrayExpression` in the expression tree with `body: Statement[]`. -3. **`bridgeBodyLine` visitor**: Emit `WithStatement` nodes in body. -4. **Array mapping on wires**: Produce wire with source - `{ type: "array", ... }` instead of splitting into wire + metadata. -5. **`force` handling**: Convert from `bridge.forces[]` to `ForceStatement`. -6. **Expression desugaring** (arithmetic, concat, pipes): Keep as expression-level IR. +## 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. diff --git a/packages/bridge-compiler/src/codegen.ts b/packages/bridge-compiler/src/codegen.ts index d5514c6..5de436c 100644 --- a/packages/bridge-compiler/src/codegen.ts +++ b/packages/bridge-compiler/src/codegen.ts @@ -119,7 +119,7 @@ function catchRef(w: Wire): NodeRef | undefined { } /** Get the catch value if present. */ function catchValue(w: Wire): string | undefined { - return w.catch && "value" in w.catch ? w.catch.value : undefined; + return w.catch && "value" in w.catch ? (w.catch.value as string) : undefined; } /** Get the catch control if present. */ function catchControl(w: Wire): ControlFlowInstruction | undefined { diff --git a/packages/bridge-core/src/enumerate-traversals.ts b/packages/bridge-core/src/enumerate-traversals.ts index 274e301..fd5b0c7 100644 --- a/packages/bridge-core/src/enumerate-traversals.ts +++ b/packages/bridge-core/src/enumerate-traversals.ts @@ -197,7 +197,8 @@ function sourceEntryDescription( function catchDescription(w: Wire, hmap: Map): string { if (!w.catch) return "catch"; - if ("value" in w.catch) return `catch ${w.catch.value}`; + if ("value" in w.catch) + return `catch ${typeof w.catch.value === "string" ? w.catch.value : JSON.stringify(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"; diff --git a/packages/bridge-core/src/types.ts b/packages/bridge-core/src/types.ts index 84e9d98..25711ad 100644 --- a/packages/bridge-core/src/types.ts +++ b/packages/bridge-core/src/types.ts @@ -399,7 +399,7 @@ export interface WireSourceEntry { */ export type WireCatch = | { ref: NodeRef; loc?: SourceLocation } - | { value: string; loc?: SourceLocation } + | { value: JsonValue; loc?: SourceLocation } | { control: ControlFlowInstruction; loc?: SourceLocation }; /** diff --git a/packages/bridge-parser/src/bridge-format.ts b/packages/bridge-parser/src/bridge-format.ts index e79a44e..991eba0 100644 --- a/packages/bridge-parser/src/bridge-format.ts +++ b/packages/bridge-parser/src/bridge-format.ts @@ -104,7 +104,7 @@ function serializeControl(ctrl: ControlFlowInstruction): string { function serFallbacks( w: Wire, refFn: (ref: NodeRef) => string, - valFn: (v: string) => string = (v) => v, + valFn: (v: string) => string = (v: string) => v, ): string { if (w.sources.length <= 1) return ""; return w.sources @@ -124,13 +124,13 @@ function serFallbacks( function serCatch( w: Wire, refFn: (ref: NodeRef) => string, - valFn: (v: string) => string = (v) => v, + valFn: (v: string) => string = (v: string) => 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)}`; + return ` catch ${valFn(w.catch.value as string)}`; } // ── Serializer ─────────────────────────────────────────────────────────────── 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 0000000..902f426 --- /dev/null +++ b/packages/bridge-parser/src/parser/ast-builder.ts @@ -0,0 +1,2044 @@ +/** + * 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[]; + }, +): { + 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 { + 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 { + // Parse the ref path: could be "handle.path" or "pipe:handle.path" + const segments = seg.path.split("."); + const root = segments[0]; + const path = segments.slice(1); + const ref = resolveRef(root, path, lineNum, iterScope); + parts.push({ type: "ref", ref, loc }); + } + } + return { type: "concat", parts, loc }; + } + + /** + * 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.sourceAlt) { + return buildSourceExpression( + (c.sourceAlt as CstNode[])[0], + lineNum, + iterScope, + ); + } + 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")!; + const expr = buildCoalesceAltExpression(altNode, lineNum, iterScope); + 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 } : {}), + }; + } + // Source ref + if (c.sourceAlt) { + const srcNode = (c.sourceAlt as CstNode[])[0]; + const headNode = sub(srcNode, "head")!; + const { root, segments, rootSafe, segmentSafe } = + extractAddressPath(headNode); + const ref = resolveRef(root, segments, lineNum, iterScope); + return { + ref: { + ...ref, + ...(rootSafe ? { rootSafe: true } : {}), + ...(segmentSafe ? { pathSafe: segmentSafe } : {}), + }, + ...(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; + 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); + } + + // 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); + } + 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[], + ): 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, + path: scopeSegs, + }; + const scopeBody: Statement[] = []; + + for (const innerAlias of nestedAliases) { + buildAliasStatement(innerAlias, scopeBody, iterScope); + } + for (const innerLine of nestedScopeLines) { + buildPathScopeLine(innerLine, scopeBody, iterScope); + } + 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, + 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", + 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}: Undeclared handle "${handle}" in force statement`, + ); + } + 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; + } + + // 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); + } + } + + 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. + } +} From 97920c9b5c21082b9c206fcfe5253e94df8e7962 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Mon, 16 Mar 2026 09:49:53 +0100 Subject: [PATCH 06/23] Phase 4: Update Execution Engine - prep --- docs/rearchitecture-plan.md | 14 ++++- .../bridge-core/src/resolveWiresSources.ts | 63 ++++++++++++++++--- packages/bridge-parser/src/parser/parser.ts | 27 ++++++++ .../test/utils/parse-test-utils.ts | 3 +- 4 files changed, 96 insertions(+), 11 deletions(-) diff --git a/docs/rearchitecture-plan.md b/docs/rearchitecture-plan.md index 2c9120f..2cf4e97 100644 --- a/docs/rearchitecture-plan.md +++ b/docs/rearchitecture-plan.md @@ -155,7 +155,19 @@ directly from Chevrotain CST nodes, separate from the legacy `buildBridgeBody()` _Depends on Phase 3. Most critical phase._ Files: `ExecutionTree.ts`, `scheduleTools.ts`, `resolveWires.ts`, -`resolveWiresSources.ts`, `materializeShadows.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` + +### Remaining 1. **Scope chain**: `ScopeFrame { handles, wires, parent? }` — tool lookup walks frames upward (shadowing semantics) diff --git a/packages/bridge-core/src/resolveWiresSources.ts b/packages/bridge-core/src/resolveWiresSources.ts index 7f2e0ea..4875065 100644 --- a/packages/bridge-core/src/resolveWiresSources.ts +++ b/packages/bridge-core/src/resolveWiresSources.ts @@ -81,19 +81,13 @@ export function evaluateExpression( ); case "binary": - throw new Error( - "Binary expressions are not yet supported in evaluateExpression", - ); + return evaluateBinary(ctx, expr, pullChain); case "unary": - throw new Error( - "Unary expressions are not yet supported in evaluateExpression", - ); + return evaluateUnary(ctx, expr, pullChain); case "concat": - throw new Error( - "Concat expressions are not yet supported in evaluateExpression", - ); + return evaluateConcat(ctx, expr, pullChain); } } @@ -361,6 +355,57 @@ 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": + 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); + 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`. diff --git a/packages/bridge-parser/src/parser/parser.ts b/packages/bridge-parser/src/parser/parser.ts index 932082f..98ca349 100644 --- a/packages/bridge-parser/src/parser/parser.ts +++ b/packages/bridge-parser/src/parser/parser.ts @@ -85,6 +85,7 @@ import type { 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) ────────────────────── @@ -3144,6 +3145,18 @@ function buildToolDef( } } + // Build nested Statement[] body alongside legacy wires + const bodyResult = buildBody( + bodyLines, + "Tools", + toolName, + previousInstructions, + { + forbiddenHandleKinds: new Set(["input", "output"]), + selfWireNodes, + }, + ); + return { kind: "tool", name: toolName, @@ -3153,6 +3166,7 @@ function buildToolDef( wires, ...(pipeHandles.length > 0 ? { pipeHandles } : {}), ...(onError ? { onError } : {}), + body: bodyResult.body, }; } @@ -3167,6 +3181,9 @@ function buildDefineDef(node: CstNode): DefineDef { const { handles, wires, arrayIterators, pipeHandles, forces } = buildBridgeBody(bodyLines, "Define", name, [], lineNum); + // Build nested Statement[] body alongside legacy wires + const bodyResult = buildBody(bodyLines, "Define", name, []); + return { kind: "define", name, @@ -3175,6 +3192,7 @@ function buildDefineDef(node: CstNode): DefineDef { ...(Object.keys(arrayIterators).length > 0 ? { arrayIterators } : {}), ...(pipeHandles.length > 0 ? { pipeHandles } : {}), ...(forces.length > 0 ? { forces } : {}), + body: bodyResult.body, }; } @@ -3273,6 +3291,14 @@ function buildBridge( ); } + // Build nested Statement[] body alongside legacy wires + const bodyResult = buildBody( + bodyLines, + typeName, + fieldName, + previousInstructions, + ); + const instructions: Instruction[] = []; instructions.push({ kind: "bridge", @@ -3284,6 +3310,7 @@ function buildBridge( Object.keys(arrayIterators).length > 0 ? arrayIterators : undefined, pipeHandles: pipeHandles.length > 0 ? pipeHandles : undefined, forces: forces.length > 0 ? forces : undefined, + body: bodyResult.body, }); return instructions; } diff --git a/packages/bridge-parser/test/utils/parse-test-utils.ts b/packages/bridge-parser/test/utils/parse-test-utils.ts index 118c68a..2632352 100644 --- a/packages/bridge-parser/test/utils/parse-test-utils.ts +++ b/packages/bridge-parser/test/utils/parse-test-utils.ts @@ -12,7 +12,8 @@ function omitLoc(value: unknown): unknown { key === "loc" || key.endsWith("Loc") || key === "source" || - key === "filename" + key === "filename" || + key === "body" ) { continue; } From a79c892bfa1bafb295c479805b1965d7581f5265 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Mon, 16 Mar 2026 10:05:09 +0100 Subject: [PATCH 07/23] Phase 4: Update Execution Engine - part 1 --- docs/rearchitecture-plan.md | 15 +- packages/bridge-core/src/ExecutionTree.ts | 180 +++++++----------- .../bridge-core/src/materializeShadows.ts | 21 +- packages/bridge-core/src/scheduleTools.ts | 24 ++- packages/bridge-core/src/tree-utils.ts | 69 +++++++ 5 files changed, 172 insertions(+), 137 deletions(-) diff --git a/docs/rearchitecture-plan.md b/docs/rearchitecture-plan.md index 2cf4e97..f586406 100644 --- a/docs/rearchitecture-plan.md +++ b/docs/rearchitecture-plan.md @@ -166,17 +166,22 @@ Files: `ExecutionTree.ts`, `scheduleTools.ts`, `resolveWires.ts`, - ✅ **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 1. **Scope chain**: `ScopeFrame { handles, wires, parent? }` — tool lookup walks frames upward (shadowing semantics) -2. **Wire pre-indexing**: Walk statement tree once at construction, build - `Map` for O(1) lookup -3. **Array execution**: `ArrayExpression` evaluated → shadow tree per element +2. **Array execution**: `ArrayExpression` evaluated → shadow tree per element with nested `body: Statement[]` and iterator binding -4. **Define inlining**: Inline as nested `Statement[]` blocks -5. **`schedule()`/`pullSingle()`**: Scope-aware resolution +3. **Define inlining**: Inline as nested `Statement[]` blocks +4. **`schedule()`/`pullSingle()`**: Scope-aware resolution **Gate:** All behavioral `regressionTest` suites must pass. diff --git a/packages/bridge-core/src/ExecutionTree.ts b/packages/bridge-core/src/ExecutionTree.ts index d97facc..a84e8a3 100644 --- a/packages/bridge-core/src/ExecutionTree.ts +++ b/packages/bridge-core/src/ExecutionTree.ts @@ -42,14 +42,13 @@ import { MAX_EXECUTION_DEPTH, } from "./tree-types.ts"; import { - pathEquals, getPrimaryRef, isPullWire, roundMs, - sameTrunk, TRUNK_KEY_CACHE, trunkKey, UNSAFE_KEYS, + WireIndex, } from "./tree-utils.ts"; import type { Bridge, @@ -118,6 +117,7 @@ type BatchToolQueue = { export class ExecutionTree implements TreeContext { state: Record = {}; bridge: Bridge | undefined; + wireIndex: WireIndex | undefined; source?: string; filename?: string; /** @@ -231,6 +231,9 @@ export class ExecutionTree implements TreeContext { (i): i is Bridge => i.kind === "bridge" && i.type === trunk.type && i.field === trunk.field, ); + if (this.bridge) { + this.wireIndex = new WireIndex(this.bridge.wires); + } if (this.bridge?.pipeHandles) { this.pipeHandleMap = new Map( this.bridge.pipeHandles.map((ph) => [ph.key, ph]), @@ -748,6 +751,7 @@ export class ExecutionTree implements TreeContext { child.toolDefCache = new Map(); // Share read-only pre-computed data from parent child.bridge = this.bridge; + child.wireIndex = this.wireIndex; child.pipeHandleMap = this.pipeHandleMap; child.handleVersionMap = this.handleVersionMap; child.memoizedToolKeys = this.memoizedToolKeys; @@ -990,10 +994,7 @@ export class ExecutionTree implements TreeContext { // 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), - ) ?? []; + const fieldWires = this.wireIndex?.forTrunkAndPath(ref, ref.path) ?? []; if (fieldWires.length > 0) { // resolveWires already delivers the value at ref.path — no applyPath. return this.resolveWires(fieldWires, nextChain); @@ -1178,8 +1179,7 @@ export class ExecutionTree implements TreeContext { // Define — recursive, best (cheapest) incoming wire wins if (ref.module.startsWith("__define_")) { - const incoming = - this.bridge?.wires.filter((wire) => sameTrunk(wire.to, ref)) ?? []; + const incoming = this.wireIndex?.forTrunk(ref) ?? []; let best = Infinity; for (const wire of incoming) { best = Math.min(best, this.computeWireCost(wire, visited)); @@ -1189,8 +1189,7 @@ export class ExecutionTree implements TreeContext { // Local alias — recursive, cheapest incoming wire wins if (ref.module === "__local") { - const incoming = - this.bridge?.wires.filter((wire) => sameTrunk(wire.to, ref)) ?? []; + const incoming = this.wireIndex?.forTrunk(ref) ?? []; let best = Infinity; for (const wire of incoming) { best = Math.min(best, this.computeWireCost(wire, visited)); @@ -1246,10 +1245,7 @@ export class ExecutionTree implements TreeContext { * 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), - ) ?? []; + const matches = this.wireIndex?.forTrunkAndPath(this.trunk, path) ?? []; if (matches.length === 0) return undefined; const result = this.resolveWires(matches); if (!array) return result; @@ -1264,12 +1260,17 @@ export class ExecutionTree implements TreeContext { } private isElementScopedTrunk(ref: NodeRef): boolean { - return trunkDependsOnElement(this.bridge, { - module: ref.module, - type: ref.type, - field: ref.field, - instance: ref.instance, - }); + return trunkDependsOnElement( + this.bridge, + { + module: ref.module, + type: ref.type, + field: ref.field, + instance: ref.instance, + }, + undefined, + this.wireIndex, + ); } /** @@ -1289,16 +1290,10 @@ export class ExecutionTree implements TreeContext { * Shared by `collectOutput()` and `run()`. */ private async resolveNestedField(prefix: string[]): Promise { - const bridge = this.bridge!; const { type, field } = this.trunk; + const trunkRef = { module: SELF_MODULE, type, field }; - const exactWires = bridge.wires.filter( - (w) => - w.to.module === SELF_MODULE && - w.to.type === type && - w.to.field === field && - pathEquals(w.to.path, prefix), - ); + const exactWires = this.wireIndex?.forTrunkAndPath(trunkRef, prefix) ?? []; // Separate spread wires from regular wires const spreadWires = exactWires.filter((w) => isPullWire(w) && w.spread); @@ -1309,16 +1304,14 @@ export class ExecutionTree implements TreeContext { // 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 allTrunkWires = this.wireIndex?.forTrunk(trunkRef) ?? []; + const hasElementWires = allTrunkWires.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) ); @@ -1338,15 +1331,10 @@ export class ExecutionTree implements TreeContext { // Collect sub-fields from deeper wires const subFields = new Set(); - for (const wire of bridge.wires) { + const allTrunkWires2 = this.wireIndex?.forTrunk(trunkRef) ?? []; + for (const wire of allTrunkWires2) { 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) - ) { + if (p.length > prefix.length && prefix.every((seg, i) => p[i] === seg)) { subFields.add(p[prefix.length]!); } } @@ -1430,14 +1418,11 @@ export class ExecutionTree implements TreeContext { return elementData; } + const trunkRef = { module: SELF_MODULE, type, field }; 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 - ) { + const allTrunkWires = this.wireIndex?.forTrunk(trunkRef) ?? []; + for (const wire of allTrunkWires) { + if (wire.to.path.length > 0) { outputFields.add(wire.to.path[0]!); } } @@ -1455,27 +1440,19 @@ export class ExecutionTree implements TreeContext { } // 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, - ); + const trunkRef2 = { module: SELF_MODULE, type, field }; + const hasRootWire = ( + this.wireIndex?.forTrunkAndPath(trunkRef2, []) ?? [] + ).some((w) => isPullWire(w)); 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 - ) { + const allTrunkWires2 = this.wireIndex?.forTrunk(trunkRef2) ?? []; + for (const wire of allTrunkWires2) { + if (wire.to.path.length > 0) { outputFields.add(wire.to.path[0]!); } } @@ -1521,16 +1498,12 @@ export class ExecutionTree implements TreeContext { const forcePromises = this.executeForced(); const { type, field } = this.trunk; + const selfTrunkRef = { module: SELF_MODULE, type, field }; // 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, - ); + const rootWires = ( + this.wireIndex?.forTrunkAndPath(selfTrunkRef, []) ?? [] + ).filter((w) => isPullWire(w)); // Passthrough wire: root wire without spread flag const hasPassthroughWire = rootWires.some( @@ -1547,16 +1520,14 @@ export class ExecutionTree implements TreeContext { // (`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 allSelfTrunkWires = this.wireIndex?.forTrunk(selfTrunkRef) ?? []; + const hasElementWires = allSelfTrunkWires.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.element === true) ); }); @@ -1579,13 +1550,8 @@ export class ExecutionTree implements TreeContext { // 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 - ) { + for (const wire of allSelfTrunkWires) { + if (wire.to.path.length > 0) { outputFields.add(wire.to.path[0]!); } } @@ -1648,12 +1614,7 @@ export class ExecutionTree implements TreeContext { 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, - ) ?? []; + this.wireIndex?.forTrunkAndPath(this.trunk, [this.trunk.field]) ?? []; if (directOutput.length > 0) { return this.resolveWires(directOutput); } @@ -1663,13 +1624,11 @@ export class ExecutionTree implements TreeContext { 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), - ) ?? []; + const allMatches = + this.wireIndex?.forTrunkAndPath(this.trunk, cleanPath) ?? []; + const matches = allMatches.filter((w) => + w.to.element ? !!this.parent : true, + ); if (matches.length > 0) { // ── Lazy define resolution ────────────────────────────────────── @@ -1729,17 +1688,15 @@ export class ExecutionTree implements TreeContext { // 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 allResponseTrunkWires = + this.wireIndex?.forTrunk(this.trunk) ?? []; + const hasElementWires = allResponseTrunkWires.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) ); @@ -1857,16 +1814,14 @@ export class ExecutionTree implements TreeContext { * 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, - ) ?? []; + const rootWires = this.wireIndex?.forTrunkAndPath(this.trunk, []) ?? []; + const forwards = rootWires.filter( + (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, + ); if (forwards.length === 0) return []; @@ -1876,10 +1831,7 @@ export class ExecutionTree implements TreeContext { fw.sources[0]!.expr as Extract ).ref; const fieldWires = - this.bridge?.wires.filter( - (w) => - sameTrunk(w.to, defOutTrunk) && pathEquals(w.to.path, cleanPath), - ) ?? []; + this.wireIndex?.forTrunkAndPath(defOutTrunk, cleanPath) ?? []; result.push(...fieldWires); } return result; diff --git a/packages/bridge-core/src/materializeShadows.ts b/packages/bridge-core/src/materializeShadows.ts index fc0f6a8..850eae3 100644 --- a/packages/bridge-core/src/materializeShadows.ts +++ b/packages/bridge-core/src/materializeShadows.ts @@ -10,7 +10,7 @@ import type { Wire } from "./types.ts"; import { SELF_MODULE } from "./types.ts"; -import { setNested } from "./tree-utils.ts"; +import { setNested, type WireIndex } from "./tree-utils.ts"; import { BREAK_SYM, CONTINUE_SYM, @@ -31,6 +31,7 @@ import { matchesRequestedFields } from "./requested-fields.ts"; */ export interface MaterializerHost { readonly bridge: { readonly wires: readonly Wire[] } | undefined; + readonly wireIndex: WireIndex | undefined; readonly trunk: Trunk; /** Sparse fieldset filter — passed through from ExecutionTree. */ readonly requestedFields?: string[] | undefined; @@ -62,8 +63,16 @@ export interface MaterializableShadow { * `materializeShadows` to drive the execution phase. */ export function planShadowOutput(host: MaterializerHost, pathPrefix: string[]) { - const wires = host.bridge!.wires; const { type, field } = host.trunk; + const trunkRef = { module: SELF_MODULE, type, field }; + const trunkWires = + host.wireIndex?.forTrunk(trunkRef) ?? + host.bridge!.wires.filter( + (w) => + w.to.module === SELF_MODULE && + w.to.type === type && + w.to.field === field, + ); const directFields = new Set(); const deepPaths = new Map(); @@ -71,14 +80,8 @@ export function planShadowOutput(host: MaterializerHost, pathPrefix: string[]) { // Key: wire.to.path joined by \0 (null char is safe — field names are identifiers). const wireGroupsByPath = new Map(); - for (const wire of wires) { + for (const wire of trunkWires) { 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; diff --git a/packages/bridge-core/src/scheduleTools.ts b/packages/bridge-core/src/scheduleTools.ts index f4f2454..02dcef7 100644 --- a/packages/bridge-core/src/scheduleTools.ts +++ b/packages/bridge-core/src/scheduleTools.ts @@ -12,7 +12,12 @@ 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 { + trunkKey, + sameTrunk, + setNested, + type WireIndex, +} from "./tree-utils.ts"; import { lookupToolFn, resolveToolDefByName, @@ -35,6 +40,7 @@ import { export interface SchedulerContext extends ToolLookupContext { // ── Scheduler-specific fields ────────────────────────────────────────── readonly bridge: Bridge | undefined; + readonly wireIndex: WireIndex | 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. */ @@ -124,6 +130,7 @@ export function trunkDependsOnElement( bridge: Bridge | undefined, target: Trunk, visited = new Set(), + wireIdx?: WireIndex, ): boolean { if (!bridge) return false; @@ -143,7 +150,9 @@ export function trunkDependsOnElement( if (visited.has(key)) return false; visited.add(key); - const incoming = bridge.wires.filter((wire) => sameTrunk(wire.to, target)); + const incoming = wireIdx + ? wireIdx.forTrunk(target) + : bridge.wires.filter((wire) => sameTrunk(wire.to, target)); for (const wire of incoming) { if (wire.to.element) return true; @@ -155,7 +164,7 @@ export function trunkDependsOnElement( field: ref.field, instance: ref.instance, }; - if (trunkDependsOnElement(bridge, sourceTrunk, visited)) { + if (trunkDependsOnElement(bridge, sourceTrunk, visited, wireIdx)) { return true; } } @@ -184,7 +193,7 @@ export function schedule( // 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)) { + if (!trunkDependsOnElement(ctx.bridge, target, undefined, ctx.wireIndex)) { return ctx.parent.schedule(target, pullChain); } } @@ -198,13 +207,10 @@ export function schedule( const baseTrunk = pipeFork?.baseTrunk; const baseWires = baseTrunk - ? (ctx.bridge?.wires.filter( - (w) => !("pipe" in w) && sameTrunk(w.to, baseTrunk), - ) ?? []) + ? (ctx.wireIndex?.forTrunk(baseTrunk) ?? []).filter((w) => !("pipe" in w)) : []; // Fork-specific wires (pipe wires targeting the fork's own instance) - const forkWires = - ctx.bridge?.wires.filter((w) => sameTrunk(w.to, target)) ?? []; + const forkWires = ctx.wireIndex?.forTrunk(target) ?? []; // Merge: base provides defaults, fork overrides const bridgeWires = [...baseWires, ...forkWires]; diff --git a/packages/bridge-core/src/tree-utils.ts b/packages/bridge-core/src/tree-utils.ts index 643b4c9..9ab46a3 100644 --- a/packages/bridge-core/src/tree-utils.ts +++ b/packages/bridge-core/src/tree-utils.ts @@ -210,3 +210,72 @@ export function getSimplePullRef(w: Wire): NodeRef | null { export function roundMs(ms: number): number { return Math.round(ms * 100) / 100; } + +// ── Wire pre-index ────────────────────────────────────────────────────────── + +/** + * Pre-indexes wires by their target trunk key for O(1) lookup. + * Built once at bridge construction time in a single O(n) pass. + * + * Two levels: + * - `byTrunk` maps `trunkKey(wire.to)` → Wire[] (all wires targeting that trunk) + * - `byTrunkAndPath` maps `trunkKey + "\0" + path.join("\0")` → Wire[] + * (exact trunk+path match) + */ +export class WireIndex { + private readonly byTrunk = new Map(); + private readonly byTrunkAndPath = new Map(); + + constructor(wires: Wire[]) { + for (const w of wires) { + const tk = trunkKey(w.to); + let trunkList = this.byTrunk.get(tk); + if (!trunkList) { + trunkList = []; + this.byTrunk.set(tk, trunkList); + } + trunkList.push(w); + + const pathKey = tk + "\0" + w.to.path.join("\0"); + let pathList = this.byTrunkAndPath.get(pathKey); + if (!pathList) { + pathList = []; + this.byTrunkAndPath.set(pathKey, pathList); + } + pathList.push(w); + } + } + + /** All wires targeting a trunk (ignoring path). Also includes element-scoped wires. */ + forTrunk(ref: Trunk & { element?: boolean }): Wire[] { + const key = trunkKey(ref); + const wires = this.byTrunk.get(key); + // Also look up element-scoped wires (key:*) when the query isn't element-scoped + if (!ref.element) { + const elemKey = `${ref.module}:${ref.type}:${ref.field}:*`; + const elemWires = this.byTrunk.get(elemKey); + if (elemWires) { + return wires ? [...wires, ...elemWires] : elemWires; + } + } + return wires ?? EMPTY_WIRES; + } + + /** All wires targeting a trunk at an exact path. Also includes element-scoped wires. */ + forTrunkAndPath(ref: Trunk & { element?: boolean }, path: string[]): Wire[] { + const key = trunkKey(ref) + "\0" + path.join("\0"); + const wires = this.byTrunkAndPath.get(key); + // Also look up element-scoped wires when the query isn't element-scoped + if (!ref.element) { + const elemKey = + `${ref.module}:${ref.type}:${ref.field}:*` + "\0" + path.join("\0"); + const elemWires = this.byTrunkAndPath.get(elemKey); + if (elemWires) { + return wires ? [...wires, ...elemWires] : elemWires; + } + } + return wires ?? EMPTY_WIRES; + } +} + +const EMPTY_WIRES: Wire[] = []; From b733086ffe21430d33aba1864b62b9d1a2fe04a9 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Mon, 16 Mar 2026 12:49:32 +0100 Subject: [PATCH 08/23] A new start --- packages/bridge-core/src/index.ts | 4 + packages/bridge-core/src/v3/execute-bridge.ts | 784 ++++++++++++++++++ packages/bridge/test/path-scoping.test.ts | 6 + .../bridge/test/strict-scope-rules.test.ts | 1 + packages/bridge/test/utils/regression.ts | 79 +- 5 files changed, 839 insertions(+), 35 deletions(-) create mode 100644 packages/bridge-core/src/v3/execute-bridge.ts diff --git a/packages/bridge-core/src/index.ts b/packages/bridge-core/src/index.ts index c82d690..3f7e81d 100644 --- a/packages/bridge-core/src/index.ts +++ b/packages/bridge-core/src/index.ts @@ -124,3 +124,7 @@ export { matchesRequestedFields, filterOutputFields, } from "./requested-fields.ts"; + +// ── V3 scope-based engine (POC) ───────────────────────────────────────────── + +export { executeBridge as executeBridgeV3 } from "./v3/execute-bridge.ts"; diff --git a/packages/bridge-core/src/v3/execute-bridge.ts b/packages/bridge-core/src/v3/execute-bridge.ts new file mode 100644 index 0000000..7066ea9 --- /dev/null +++ b/packages/bridge-core/src/v3/execute-bridge.ts @@ -0,0 +1,784 @@ +import type { ToolTrace, TraceLevel } from "../tracing.ts"; +import type { Logger } from "../tree-types.ts"; +import type { + Bridge, + BridgeDocument, + Expression, + NodeRef, + SourceChain, + Statement, + ToolDef, + ToolMap, + WireAliasStatement, + WireStatement, +} from "../types.ts"; +import { SELF_MODULE } from "../types.ts"; +import { TraceCollector } from "../tracing.ts"; +import { + std as bundledStd, + STD_VERSION as BUNDLED_STD_VERSION, +} from "@stackables/bridge-stdlib"; +import { resolveStd } from "../version-check.ts"; + +export type ExecuteBridgeOptions = { + /** Parsed bridge document (from `parseBridge` or `parseBridgeDiagnostics`). */ + document: BridgeDocument; + /** + * Which bridge to execute, as `"Type.field"`. + * Mirrors the `bridge Type.field { ... }` declaration. + * Example: `"Query.searchTrains"` or `"Mutation.sendEmail"`. + */ + operation: string; + /** Input arguments — equivalent to GraphQL field arguments. */ + input?: Record; + /** + * Tool functions available to the engine. + * + * Supports namespaced nesting: `{ myNamespace: { myTool } }`. + * The built-in `std` namespace is always included; user tools are + * merged on top (shallow). + * + * To provide a specific version of std (e.g. when the bridge file + * targets an older major), use a versioned namespace key: + * ```ts + * tools: { "std@1.5": oldStdNamespace } + * ``` + */ + tools?: ToolMap; + /** Context available via `with context as ctx` inside the bridge. */ + context?: Record; + /** + * Enable tool-call tracing. + * - `"off"` (default) — no collection, zero overhead + * - `"basic"` — tool, fn, timing, errors; no input/output + * - `"full"` — everything including input and output + */ + trace?: TraceLevel; + /** Structured logger for engine events. */ + logger?: Logger; + /** External abort signal — cancels execution when triggered. */ + signal?: AbortSignal; + /** + * Hard timeout for tool calls in milliseconds. + * Tools that exceed this duration throw a `BridgeTimeoutError`. + * Default: 15_000 (15 seconds). Set to `0` to disable. + */ + toolTimeoutMs?: number; + /** + * Maximum shadow-tree nesting depth. + * Default: 30. Increase for deeply nested array mappings. + */ + maxDepth?: number; + /** + * Sparse fieldset filter. + * + * When provided, only the listed output fields (and their transitive + * dependencies) are resolved. Tools that feed exclusively into + * unrequested fields are never called. + * + * Supports dot-separated paths and a trailing wildcard: + * `["id", "price", "legs.*"]` + * + * Omit or pass an empty array to resolve all fields (the default). + */ + requestedFields?: string[]; +}; + +export type ExecuteBridgeResult = { + data: T; + traces: ToolTrace[]; + /** Compact bitmask encoding which traversal paths were taken during execution. */ + executionTraceId: bigint; +}; + +// ── Scope-based pull engine (v3) ──────────────────────────────────────────── + +/** Unique key for a tool instance trunk. */ +function toolKey(module: string, field: string, instance?: number): string { + return instance ? `${module}:${field}:${instance}` : `${module}:${field}`; +} + +/** Ownership key for a tool (module:field, no instance). */ +function toolOwnerKey(module: string, field: string): string { + return module === SELF_MODULE ? field : `${module}:${field}`; +} + +/** + * 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. + */ +function getPath(obj: unknown, path: string[]): unknown { + let current: unknown = obj; + for (const segment of path) { + if (current == null || typeof current !== "object") return undefined; + current = (current as Record)[segment]; + } + return current; +} + +/** + * Set a nested property on an object following a path array, + * creating intermediate objects as needed. + */ +function setPath( + obj: Record, + path: string[], + value: unknown, +): void { + let current: Record = obj; + for (let i = 0; i < path.length - 1; i++) { + const segment = path[i]!; + 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) { + 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 (current == null || typeof current !== "object") return undefined; + current = (current as Record)[part]; + } + if (typeof current === "function") + return current as (...args: unknown[]) => unknown; + } + + return undefined; +} + +/** + * Execution scope — the core of the v3 pull-based engine. + * + * 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 + */ +class ExecutionScope { + readonly parent: ExecutionScope | null; + readonly output: Record; + readonly selfInput: Record; + readonly engine: EngineContext; + + /** Tools declared via `with` at this scope level — keyed by "module:field". */ + private readonly ownedTools = new Set(); + + /** Tool input wires indexed by full tool key — evaluated lazily on demand. */ + private readonly toolInputWires = new Map(); + + /** Memoized tool call results — cached Promise per tool key. */ + private readonly toolResults = new Map>(); + + /** Element data stack for array iteration nesting. */ + private readonly elementData: unknown[] = []; + + /** Output wires (self-module and element) indexed by dot-joined target path. */ + private readonly outputWires = new Map(); + + /** Alias statements indexed by name — evaluated lazily on first read. */ + private readonly aliases = new Map(); + + /** Cached alias evaluation results. */ + private readonly aliasResults = new Map>(); + + constructor( + parent: ExecutionScope | null, + selfInput: Record, + output: Record, + engine: EngineContext, + ) { + this.parent = parent; + this.selfInput = selfInput; + this.output = output; + this.engine = engine; + } + + /** Register that this scope owns a tool declared via `with`. */ + declareToolBinding(name: string): void { + this.ownedTools.add(bindingOwnerKey(name)); + } + + /** Index a tool input wire for lazy evaluation during tool call. */ + addToolInputWire(wire: WireStatement): void { + const key = toolKey( + wire.target.module, + wire.target.field, + wire.target.instance, + ); + let wires = this.toolInputWires.get(key); + if (!wires) { + wires = []; + this.toolInputWires.set(key, wires); + } + wires.push(wire); + } + + /** Index an output wire (self-module or element) by its target path. */ + addOutputWire(wire: WireStatement): void { + const key = wire.target.path.join("."); + this.outputWires.set(key, wire); + } + + /** Get an output wire by field path key. */ + getOutputWire(field: string): WireStatement | undefined { + return this.outputWires.get(field); + } + + /** Get all indexed output field names. */ + allOutputFields(): string[] { + return Array.from(this.outputWires.keys()); + } + + /** + * Collect all output wires matching the requested fields via prefix matching. + * - Requesting "profile" matches wires "profile", "profile.name", "profile.age" + * - Requesting "profile.name" matches wire "profile" (parent provides the object) + */ + collectMatchingOutputWires(requestedFields: string[]): WireStatement[] { + const matched = new Set(); + const result: WireStatement[] = []; + + for (const field of requestedFields) { + for (const [key, wire] of this.outputWires) { + if (matched.has(key)) continue; + // Exact match, or prefix match in either direction + if ( + key === field || + key.startsWith(field + ".") || + field.startsWith(key + ".") + ) { + matched.add(key); + result.push(wire); + } + } + } + + return result; + } + + /** Index an alias statement for lazy evaluation. */ + addAlias(stmt: WireAliasStatement): void { + this.aliases.set(stmt.name, stmt); + } + + /** + * Resolve an alias by name — walks the scope chain. + * Evaluates lazily and caches the result. + */ + resolveAlias( + name: string, + evaluator: (chain: SourceChain, scope: ExecutionScope) => Promise, + ): Promise { + // Check local cache + if (this.aliasResults.has(name)) return this.aliasResults.get(name)!; + + // Do I have this alias? + const alias = this.aliases.get(name); + if (alias) { + const promise = evaluator(alias, this); + this.aliasResults.set(name, promise); + return promise; + } + + // Delegate to parent + if (this.parent) { + return this.parent.resolveAlias(name, evaluator); + } + + throw new Error(`Alias "${name}" not found in any scope`); + } + + /** Push element data for array iteration. */ + pushElement(data: unknown): void { + this.elementData.push(data); + } + + /** 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; + } + + /** Get the root scope (for non-element output writes). */ + root(): ExecutionScope { + let scope: ExecutionScope = this; + while (scope.parent) 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. + */ + async resolveToolResult( + module: string, + field: string, + instance: number | undefined, + ): Promise { + const key = toolKey(module, field, instance); + + // Check local memoization cache + if (this.toolResults.has(key)) return this.toolResults.get(key)!; + + // Does this scope own the tool? + if (this.ownedTools.has(toolOwnerKey(module, field))) { + return this.callTool(key, module, field); + } + + // Delegate to parent scope (lexical chain traversal) + if (this.parent) { + return this.parent.resolveToolResult(module, field, instance); + } + + 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. + */ + private callTool( + key: string, + module: string, + field: string, + ): Promise { + const promise = (async () => { + // Pull: evaluate tool input wires lazily + const input: Record = {}; + const wires = this.toolInputWires.get(key) ?? []; + for (const wire of wires) { + const value = await evaluateSourceChain(wire, this); + setPath(input, wire.target.path, value); + } + + const toolName = module === SELF_MODULE ? field : `${module}.${field}`; + const fn = lookupToolFn(this.engine.tools, toolName); + if (!fn) throw new Error(`Tool function "${toolName}" not registered`); + + const startMs = performance.now(); + const result = await fn(input, { logger: this.engine.logger }); + const durationMs = performance.now() - startMs; + + if (this.engine.tracer) { + this.engine.tracer.record( + this.engine.tracer.entry({ + tool: toolName, + fn: toolName, + input, + output: result, + durationMs, + startedAt: this.engine.tracer.now() - durationMs, + }), + ); + } + + return result; + })(); + + this.toolResults.set(key, promise); + return promise; + } +} + +/** Shared engine-wide context. */ +interface EngineContext { + readonly tools: ToolMap; + readonly instructions: readonly (Bridge | ToolDef | { kind: string })[]; + readonly type: string; + readonly field: string; + readonly context: Record; + readonly logger?: Logger; + readonly tracer?: TraceCollector; +} + +// ── 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); + } + break; + case "wire": { + const target = stmt.target; + 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; + } + } + } +} + +/** + * 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. + */ +async function resolveRequestedFields( + scope: ExecutionScope, + requestedFields: string[], +): Promise { + // If no specific fields, resolve all indexed output wires. + // Otherwise, use prefix matching to find relevant wires. + const wires = + requestedFields.length > 0 + ? scope.collectMatchingOutputWires(requestedFields) + : scope.allOutputFields().map((f) => scope.getOutputWire(f)!); + + for (const wire of wires) { + const value = await evaluateSourceChain(wire, scope); + writeTarget(wire.target, value, scope); + } +} + +/** + * Evaluate a source chain (fallback gates: ||, ??). + */ +async function evaluateSourceChain( + chain: SourceChain, + scope: ExecutionScope, +): Promise { + let value: unknown; + + for (const entry of chain.sources) { + if (entry.gate === "falsy" && value) break; + if (entry.gate === "nullish" && value != null) break; + value = await evaluateExpression(entry.expr, scope); + } + + return value; +} + +/** + * Evaluate an expression tree. + */ +async function evaluateExpression( + expr: Expression, + scope: ExecutionScope, +): Promise { + switch (expr.type) { + case "ref": + return resolveRef(expr.ref, scope); + + case "literal": + return expr.value; + + case "array": + return evaluateArrayExpr(expr, scope); + + case "ternary": { + const cond = await evaluateExpression(expr.cond, scope); + return cond + ? evaluateExpression(expr.then, scope) + : evaluateExpression(expr.else, scope); + } + + case "and": { + const left = await evaluateExpression(expr.left, scope); + return left ? evaluateExpression(expr.right, scope) : left; + } + + case "or": { + const left = await evaluateExpression(expr.left, scope); + return left ? left : evaluateExpression(expr.right, scope); + } + + case "control": + throw new Error( + `Control flow "${expr.control.kind}" not implemented in v3 POC`, + ); + + case "pipe": + case "binary": + case "unary": + case "concat": + throw new Error( + `Expression type "${expr.type}" not implemented in v3 POC`, + ); + + default: + throw new Error(`Unknown expression type: ${(expr as Expression).type}`); + } +} + +/** + * Evaluate an array mapping expression. + * + * Creates a child scope for each element, indexes its body statements, + * then pulls output wires. Tool reads inside the body trigger lazy + * evaluation up the scope chain. + */ +async function evaluateArrayExpr( + expr: Extract, + scope: ExecutionScope, +): Promise { + const sourceValue = await evaluateExpression(expr.source, scope); + if (!Array.isArray(sourceValue)) return []; + + const results: unknown[] = []; + + for (const element of sourceValue) { + const elementOutput: Record = {}; + const childScope = new ExecutionScope( + scope, + scope.selfInput, + elementOutput, + scope.engine, + ); + childScope.pushElement(element); + + // Index then pull — child scope may declare its own tools + indexStatements(expr.body, childScope); + await resolveRequestedFields(childScope, []); + + results.push(elementOutput); + } + + return results; +} + +/** + * Resolve a NodeRef to its value. + */ +async function resolveRef( + ref: NodeRef, + scope: ExecutionScope, +): 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); + } + + // Alias reference — lazy evaluation with caching + if (ref.module === SELF_MODULE && ref.type === "__local") { + const aliasResult = await scope.resolveAlias( + ref.field, + evaluateSourceChain, + ); + return getPath(aliasResult, ref.path); + } + + // Self-module input reference — reading from input args + if (ref.module === SELF_MODULE && ref.instance == null) { + return getPath(scope.selfInput, ref.path); + } + + // Tool reference — reading from a tool's output (triggers lazy call) + const toolResult = await scope.resolveToolResult( + ref.module, + ref.field, + ref.instance, + ); + return getPath(toolResult, ref.path); +} + +/** + * 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; + + // Create engine context + const engine: EngineContext = { + tools: allTools, + instructions: doc.instructions, + type, + field, + context, + logger: options.logger, + tracer, + }; + + // 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); + // Pull: resolve requested output fields — tool calls happen lazily on demand + await resolveRequestedFields(rootScope, options.requestedFields ?? []); + + return { + data: output as T, + traces: tracer?.traces ?? [], + executionTraceId: 0n, + }; +} diff --git a/packages/bridge/test/path-scoping.test.ts b/packages/bridge/test/path-scoping.test.ts index 5443f6f..c3e6b20 100644 --- a/packages/bridge/test/path-scoping.test.ts +++ b/packages/bridge/test/path-scoping.test.ts @@ -16,6 +16,7 @@ import { bridge } from "@stackables/bridge"; // ── 1. Scope block execution — constants ──────────────────────────────────── regressionTest("path scoping: scope block constants", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -45,6 +46,7 @@ regressionTest("path scoping: scope block constants", { // ── 2. Scope block execution — pull wires ─────────────────────────────────── regressionTest("path scoping: scope block pull wires", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -79,6 +81,7 @@ regressionTest("path scoping: scope block pull wires", { // ── 3. Scope block execution — nested scopes ──────────────────────────────── regressionTest("path scoping: nested scope blocks", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -119,6 +122,7 @@ regressionTest("path scoping: nested scope blocks", { // ── 4. Scope block on tool input ──────────────────────────────────────────── regressionTest("path scoping: scope block on tool input", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -154,6 +158,7 @@ regressionTest("path scoping: scope block on tool input", { // ── 5. Alias inside nested scope blocks ───────────────────────────────────── regressionTest("path scoping: alias inside nested scope", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -194,6 +199,7 @@ regressionTest("path scoping: alias inside nested scope", { // ── 6. Array mapper scope blocks ──────────────────────────────────────────── regressionTest("path scoping: array mapper scope blocks", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 diff --git a/packages/bridge/test/strict-scope-rules.test.ts b/packages/bridge/test/strict-scope-rules.test.ts index 8210cb0..ee28267 100644 --- a/packages/bridge/test/strict-scope-rules.test.ts +++ b/packages/bridge/test/strict-scope-rules.test.ts @@ -9,6 +9,7 @@ import { bridge } from "@stackables/bridge"; // ═══════════════════════════════════════════════════════════════════════════ regressionTest("strict scope rules - valid behavior", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 diff --git a/packages/bridge/test/utils/regression.ts b/packages/bridge/test/utils/regression.ts index a1a9006..3ad12f5 100644 --- a/packages/bridge/test/utils/regression.ts +++ b/packages/bridge/test/utils/regression.ts @@ -22,7 +22,10 @@ import { type BridgeDocument, } from "../../src/index.ts"; import { bridgeTransform, getBridgeTraces } from "@stackables/bridge-graphql"; -import { executeBridge as executeRuntime } from "@stackables/bridge-core"; +import { + executeBridge as executeRuntime, + executeBridgeV3, +} from "@stackables/bridge-core"; import { executeBridge as executeCompiled, type ExecuteBridgeOptions, @@ -827,8 +830,8 @@ function synthesizeSelectedGraphQLData( * Lets assertions branch on engine or inspect wall-clock timing. */ export type AssertContext = { - /** Which engine is running: "runtime" | "compiled" | "graphql". */ - engine: "runtime" | "compiled" | "graphql"; + /** Which engine is running: "runtime" | "compiled" | "graphql" | "v3". */ + engine: "runtime" | "compiled" | "graphql" | "v3"; /** High-resolution timestamp (ms) captured just before execution started. */ startMs: number; }; @@ -857,10 +860,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, v3 are off) */ - disable?: true | ("runtime" | "compiled" | "graphql" | "parser")[]; + disable?: true | ("runtime" | "compiled" | "graphql" | "parser" | "v3")[]; }; export type RegressionTest = { @@ -869,7 +875,14 @@ export type RegressionTest = { context?: Record; /** Tool-level timeout in ms (default: 5 000). */ toolTimeoutMs?: number; - disable?: true | ("runtime" | "compiled" | "graphql" | "parser")[]; + /** + * 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, v3 are off) + */ + disable?: true | ("runtime" | "compiled" | "graphql" | "parser" | "v3")[]; scenarios: Record>; }; @@ -878,6 +891,7 @@ export type RegressionTest = { const engines = [ { name: "runtime", execute: executeRuntime }, { name: "compiled", execute: executeCompiled }, + { name: "v3", execute: executeBridgeV3 as typeof executeRuntime }, ] as const; function assertDataExpectation( @@ -1099,16 +1113,19 @@ export function assertGraphqlExpectation( // ── Harness ───────────────────────────────────────────────────────────────── function isDisabled( - disable: true | ("runtime" | "compiled" | "graphql" | "parser")[] | undefined, - check: "runtime" | "compiled" | "graphql" | "parser", + disable: + | true + | ("runtime" | "compiled" | "graphql" | "parser" | "v3")[] + | undefined, + check: "runtime" | "compiled" | "graphql" | "parser" | "v3", ): boolean { - if (["compiled", "parser"].includes(check)) { - return true; - } + if (disable === true) return true; - return ( - disable === true || (Array.isArray(disable) && disable.includes(check)) - ); + // Explicit array: trust exactly what the user listed + if (Array.isArray(disable)) return disable.includes(check); + + // Not set: defaults — compiled, parser, v3 are off + return ["compiled", "parser", "v3"].includes(check); } export function regressionTest(name: string, data: RegressionTest) { @@ -1147,8 +1164,7 @@ export function regressionTest(name: string, data: RegressionTest) { }> = []; let pendingRuntimeTests = scenarioNames.filter( (name) => - !isDisabled(data.disable, "runtime") && - !isDisabled(scenarios[name]!.disable, "runtime"), + !isDisabled(scenarios[name]!.disable ?? data.disable, "runtime"), ).length; let resolveRuntimeCollection!: () => void; @@ -1177,10 +1193,10 @@ export function regressionTest(name: string, data: RegressionTest) { for (const { name: engineName, execute } of engines) { test(engineName, async (t) => { - if ( - isDisabled(data.disable, engineName) || - isDisabled(scenario.disable, 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; } @@ -1325,11 +1341,9 @@ export function regressionTest(name: string, data: RegressionTest) { (name) => !scenarios[name]!.assertError, ); - const allGraphqlDisabled = - isDisabled(data.disable, "graphql") || - scenarioNames.every((name) => - isDisabled(scenarios[name]!.disable, "graphql"), - ); + const allGraphqlDisabled = scenarioNames.every((name) => + isDisabled(scenarios[name]!.disable ?? data.disable, "graphql"), + ); if (scenarioNames.length > 0) { describe("graphql replay", () => { @@ -1413,10 +1427,7 @@ export function regressionTest(name: string, data: RegressionTest) { for (const scenarioName of scenarioNames) { test(scenarioName, async (t) => { const scenario = scenarios[scenarioName]!; - if ( - isDisabled(data.disable, "graphql") || - isDisabled(scenario.disable, "graphql") - ) { + if (isDisabled(scenario.disable ?? data.disable, "graphql")) { t.skip("disabled"); return; } @@ -1577,11 +1588,9 @@ export function regressionTest(name: string, data: RegressionTest) { // After all scenarios for this operation, verify traversal coverage test("traversal coverage", async (t) => { - const allRuntimeDisabled = - isDisabled(data.disable, "runtime") || - scenarioNames.every((name) => - isDisabled(scenarios[name]!.disable, "runtime"), - ); + const allRuntimeDisabled = scenarioNames.every((name) => + isDisabled(scenarios[name]!.disable ?? data.disable, "runtime"), + ); if (allRuntimeDisabled) { t.skip("all scenarios have runtime disabled"); return; From 1fb7708ee3b2ad7a51ffd3422ca945353d901741 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Mon, 16 Mar 2026 13:36:19 +0100 Subject: [PATCH 09/23] Fallback chains --- packages/bridge-core/src/v3/execute-bridge.ts | 181 ++++++++++++++---- .../bridge/test/bugfixes/fallback-bug.test.ts | 1 + packages/bridge/test/chained.test.ts | 1 + packages/bridge/test/coalesce-cost.test.ts | 11 ++ packages/bridge/test/resilience.test.ts | 5 + packages/bridge/test/shared-parity.test.ts | 1 + 6 files changed, 165 insertions(+), 35 deletions(-) diff --git a/packages/bridge-core/src/v3/execute-bridge.ts b/packages/bridge-core/src/v3/execute-bridge.ts index 7066ea9..b688b48 100644 --- a/packages/bridge-core/src/v3/execute-bridge.ts +++ b/packages/bridge-core/src/v3/execute-bridge.ts @@ -10,10 +10,12 @@ import type { ToolDef, ToolMap, WireAliasStatement, + WireCatch, WireStatement, } from "../types.ts"; import { SELF_MODULE } from "../types.ts"; import { TraceCollector } from "../tracing.ts"; +import { isFatalError } from "../tree-types.ts"; import { std as bundledStd, STD_VERSION as BUNDLED_STD_VERSION, @@ -117,12 +119,24 @@ function bindingOwnerKey(name: string): string { /** * 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[]): unknown { +function getPath( + obj: unknown, + path: string[], + rootSafe?: boolean, + pathSafe?: boolean[], +): unknown { let current: unknown = obj; - for (const segment of path) { - if (current == null || typeof current !== "object") return undefined; - current = (current as Record)[segment]; + for (let i = 0; i < path.length; i++) { + if (current == null || typeof current !== "object") { + const safe = pathSafe?.[i] ?? (i === 0 ? (rootSafe ?? false) : false); + if (safe) return undefined; + return undefined; // path traversal naturally returns undefined + } + current = (current as Record)[path[i]!]; } return current; } @@ -136,6 +150,13 @@ function setPath( path: string[], value: unknown, ): void { + // Empty path — merge value into root object + if (path.length === 0) { + if (value != null && typeof value === "object" && !Array.isArray(value)) { + Object.assign(obj, value as Record); + } + return; + } let current: Record = obj; for (let i = 0; i < path.length - 1; i++) { const segment = path[i]!; @@ -402,23 +423,42 @@ class ExecutionScope { if (!fn) throw new Error(`Tool function "${toolName}" not registered`); const startMs = performance.now(); - const result = await fn(input, { logger: this.engine.logger }); - const durationMs = performance.now() - startMs; - - if (this.engine.tracer) { - this.engine.tracer.record( - this.engine.tracer.entry({ - tool: toolName, - fn: toolName, - input, - output: result, - durationMs, - startedAt: this.engine.tracer.now() - durationMs, - }), - ); - } + try { + const result = await fn(input, { logger: this.engine.logger }); + const durationMs = performance.now() - startMs; + + if (this.engine.tracer) { + this.engine.tracer.record( + this.engine.tracer.entry({ + tool: toolName, + fn: toolName, + input, + output: result, + durationMs, + startedAt: this.engine.tracer.now() - durationMs, + }), + ); + } + + return result; + } catch (err) { + const durationMs = performance.now() - startMs; + + if (this.engine.tracer) { + this.engine.tracer.record( + this.engine.tracer.entry({ + tool: toolName, + fn: toolName, + input, + error: (err as Error).message, + durationMs, + startedAt: this.engine.tracer.now() - durationMs, + }), + ); + } - return result; + throw err; + } })(); this.toolResults.set(key, promise); @@ -542,20 +582,70 @@ async function resolveRequestedFields( /** * Evaluate a source chain (fallback gates: ||, ??). + * Wraps with catch handler if present. */ async function evaluateSourceChain( chain: SourceChain, scope: ExecutionScope, ): Promise { - let value: unknown; + try { + let value: unknown; + + for (const entry of chain.sources) { + if (entry.gate === "falsy" && value) continue; + if (entry.gate === "nullish" && value != null) continue; + value = await evaluateExpression(entry.expr, scope); + } + + return value; + } catch (err) { + if (isFatalError(err)) throw err; + if (chain.catch) { + return applyCatchHandler(chain.catch, scope); + } + throw err; + } +} - for (const entry of chain.sources) { - if (entry.gate === "falsy" && value) break; - if (entry.gate === "nullish" && value != null) break; - value = await evaluateExpression(entry.expr, scope); +/** + * 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, +): Promise { + if ("control" in c) { + if (c.control.kind === "throw") throw new Error(c.control.message); + // panic, continue, break — import would be needed for full support + throw new Error( + `Control flow "${c.control.kind}" in catch not yet implemented in v3`, + ); } + if ("ref" in c) { + return resolveRef(c.ref, scope); + } + // Literal value + return c.value; +} - return value; +/** + * Evaluate an expression safely — swallows non-fatal errors and returns undefined. + * Fatal errors (panic, abort) always propagate. + */ +async function evaluateExprSafe( + fn: () => unknown | Promise, +): Promise { + try { + const result = fn(); + if (result != null && typeof (result as Promise).then === "function") { + return await (result as Promise); + } + return result; + } catch (err) { + if (isFatalError(err)) throw err; + return undefined; + } } /** @@ -567,6 +657,9 @@ async function evaluateExpression( ): Promise { switch (expr.type) { case "ref": + if (expr.safe) { + return evaluateExprSafe(() => resolveRef(expr.ref, scope)); + } return resolveRef(expr.ref, scope); case "literal": @@ -583,13 +676,23 @@ async function evaluateExpression( } case "and": { - const left = await evaluateExpression(expr.left, scope); - return left ? evaluateExpression(expr.right, scope) : left; + const left = expr.leftSafe + ? await evaluateExprSafe(() => evaluateExpression(expr.left, scope)) + : await evaluateExpression(expr.left, scope); + if (!left) return left; + return expr.rightSafe + ? evaluateExprSafe(() => evaluateExpression(expr.right, scope)) + : evaluateExpression(expr.right, scope); } case "or": { - const left = await evaluateExpression(expr.left, scope); - return left ? left : evaluateExpression(expr.right, scope); + const left = expr.leftSafe + ? await evaluateExprSafe(() => evaluateExpression(expr.left, scope)) + : await evaluateExpression(expr.left, scope); + if (left) return left; + return expr.rightSafe + ? evaluateExprSafe(() => evaluateExpression(expr.right, scope)) + : evaluateExpression(expr.right, scope); } case "control": @@ -657,7 +760,7 @@ async function resolveRef( if (ref.element) { const depth = ref.elementDepth ?? 0; const elementData = scope.getElement(depth); - return getPath(elementData, ref.path); + return getPath(elementData, ref.path, ref.rootSafe, ref.pathSafe); } // Alias reference — lazy evaluation with caching @@ -666,12 +769,12 @@ async function resolveRef( ref.field, evaluateSourceChain, ); - return getPath(aliasResult, ref.path); + return getPath(aliasResult, ref.path, ref.rootSafe, ref.pathSafe); } // Self-module input reference — reading from input args if (ref.module === SELF_MODULE && ref.instance == null) { - return getPath(scope.selfInput, ref.path); + return getPath(scope.selfInput, ref.path, ref.rootSafe, ref.pathSafe); } // Tool reference — reading from a tool's output (triggers lazy call) @@ -680,7 +783,7 @@ async function resolveRef( ref.field, ref.instance, ); - return getPath(toolResult, ref.path); + return getPath(toolResult, ref.path, ref.rootSafe, ref.pathSafe); } /** @@ -774,7 +877,15 @@ export async function executeBridge( // Index: register tool bindings, tool input wires, and output wires indexStatements(bridge.body, rootScope); // Pull: resolve requested output fields — tool calls happen lazily on demand - await resolveRequestedFields(rootScope, options.requestedFields ?? []); + try { + await resolveRequestedFields(rootScope, options.requestedFields ?? []); + } catch (err) { + // Attach collected traces to error for harness/caller access + if (tracer) { + (err as { traces?: ToolTrace[] }).traces = tracer.traces; + } + throw err; + } return { data: output as T, diff --git a/packages/bridge/test/bugfixes/fallback-bug.test.ts b/packages/bridge/test/bugfixes/fallback-bug.test.ts index dd75c3c..a94c13e 100644 --- a/packages/bridge/test/bugfixes/fallback-bug.test.ts +++ b/packages/bridge/test/bugfixes/fallback-bug.test.ts @@ -15,6 +15,7 @@ import { tools } from "../utils/bridge-tools.ts"; // ═══════════════════════════════════════════════════════════════════════════ regressionTest("string interpolation || fallback priority", { + disable: ["compiled", "parser"], bridge: ` version 1.5 diff --git a/packages/bridge/test/chained.test.ts b/packages/bridge/test/chained.test.ts index b4bcf6a..b8bb042 100644 --- a/packages/bridge/test/chained.test.ts +++ b/packages/bridge/test/chained.test.ts @@ -11,6 +11,7 @@ import { bridge } from "@stackables/bridge"; // ═══════════════════════════════════════════════════════════════════════════ regressionTest("chained providers", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 diff --git a/packages/bridge/test/coalesce-cost.test.ts b/packages/bridge/test/coalesce-cost.test.ts index cfeb0c0..1b08775 100644 --- a/packages/bridge/test/coalesce-cost.test.ts +++ b/packages/bridge/test/coalesce-cost.test.ts @@ -17,6 +17,7 @@ import { bridge } from "@stackables/bridge"; // ── || short-circuit evaluation ──────────────────────────────────────────── regressionTest("|| fallback chains", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -82,6 +83,7 @@ regressionTest("|| fallback chains", { "a throws → uncaught wires fail": { input: { a: { _error: "boom" } }, allowDowngrade: true, + disable: ["compiled", "parser", "v3"], assertError: /BridgeRuntimeError/, assertTraces: 1, assertGraphql: { @@ -94,6 +96,7 @@ regressionTest("|| fallback chains", { "b throws → fallback error propagates": { input: { b: { _error: "boom" } }, allowDowngrade: true, + disable: ["compiled", "parser", "v3"], assertError: /BridgeRuntimeError/, assertTraces: 2, assertGraphql: { @@ -106,6 +109,7 @@ regressionTest("|| fallback chains", { "c throws → third-position fallback error": { input: { c: { _error: "boom" } }, allowDowngrade: true, + disable: ["compiled", "parser", "v3"], assertError: /BridgeRuntimeError/, assertTraces: 3, assertGraphql: { @@ -344,6 +348,7 @@ regressionTest("overdefinition: explicit cost override", { // ── ?. safe execution modifier ──────────────────────────────────────────── regressionTest("?. safe execution modifier", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -396,6 +401,7 @@ regressionTest("?. safe execution modifier", { "?. on non-existent const paths": { input: {}, allowDowngrade: true, + disable: ["compiled", "parser", "v3"], fields: ["constChained", "constMixed"], assertData: { constChained: "A", @@ -406,6 +412,7 @@ regressionTest("?. safe execution modifier", { "b throws in fallback position → error propagates": { input: { a: { _error: "any" }, b: { _error: "boom" } }, allowDowngrade: true, + disable: ["compiled", "parser", "v3"], fields: ["withToolFallback"], assertError: /BridgeRuntimeError/, assertTraces: 2, @@ -420,6 +427,7 @@ regressionTest("?. safe execution modifier", { // ── Mixed || and ?? chains ────────────────────────────────────────────────── regressionTest("mixed || and ?? chains", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -499,6 +507,7 @@ regressionTest("mixed || and ?? chains", { "a throws → error on all wires": { input: { a: { _error: "boom" } }, allowDowngrade: true, + disable: ["compiled", "parser", "v3"], assertError: /BridgeRuntimeError/, assertTraces: 1, assertGraphql: { @@ -510,6 +519,7 @@ regressionTest("mixed || and ?? chains", { "b throws → fallback error": { input: { b: { _error: "boom" } }, allowDowngrade: true, + disable: ["compiled", "parser", "v3"], assertError: /BridgeRuntimeError/, assertTraces: 2, assertGraphql: { @@ -521,6 +531,7 @@ regressionTest("mixed || and ?? chains", { "c throws → fallback:1 error on fourItem": { input: { c: { _error: "boom" } }, allowDowngrade: true, + disable: ["compiled", "parser", "v3"], fields: ["fourItem"], assertError: /BridgeRuntimeError/, assertTraces: 3, diff --git a/packages/bridge/test/resilience.test.ts b/packages/bridge/test/resilience.test.ts index 02fd050..33a5383 100644 --- a/packages/bridge/test/resilience.test.ts +++ b/packages/bridge/test/resilience.test.ts @@ -157,6 +157,7 @@ regressionTest("resilience: tool on error", { // ── 3. Wire catch ─────────────────────────────────────────────────────────── regressionTest("resilience: wire catch", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -302,6 +303,7 @@ regressionTest("resilience: combined on error + catch + const", { // ── 5. Wire || falsy-fallback ─────────────────────────────────────────────── regressionTest("resilience: wire falsy-fallback (||)", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -481,6 +483,7 @@ regressionTest("resilience: multi-wire null-coalescing", { // ── 7. || source + catch source ───────────────────────────────────────────── regressionTest("resilience: || source + catch source (COALESCE)", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -599,6 +602,7 @@ regressionTest("resilience: || source + catch source (COALESCE)", { "Query.catchPipeSource": { "api succeeds — catch not used": { input: {}, + disable: ["compiled", "parser", "v3"], tools: { api: () => ({ result: "direct-value" }), fallbackApi: () => ({ backup: "unused" }), @@ -610,6 +614,7 @@ regressionTest("resilience: || source + catch source (COALESCE)", { }, "catch pipes fallback through tool": { input: {}, + disable: ["compiled", "parser", "v3"], tools: { api: () => { throw new Error("api down"); diff --git a/packages/bridge/test/shared-parity.test.ts b/packages/bridge/test/shared-parity.test.ts index 0313a44..fd21aaf 100644 --- a/packages/bridge/test/shared-parity.test.ts +++ b/packages/bridge/test/shared-parity.test.ts @@ -490,6 +490,7 @@ regressionTest("parity: ternary / conditional wires", { // ── 5. Catch fallbacks ────────────────────────────────────────────────────── regressionTest("parity: catch fallbacks", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 From a757b1b561a0726a0527ccf3f486368d00c1cbdc Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Mon, 16 Mar 2026 15:15:59 +0100 Subject: [PATCH 10/23] V3-Phase 2: Binary + Unary + Concat Expressions --- docs/rearchitecture-plan.md | 113 ++++++++++++++++++ packages/bridge-core/src/v3/execute-bridge.ts | 84 ++++++++++--- packages/bridge/test/expressions.test.ts | 10 ++ .../test/interpolation-universal.test.ts | 16 ++- packages/bridge/test/shared-parity.test.ts | 2 + .../bridge/test/string-interpolation.test.ts | 1 + 6 files changed, 210 insertions(+), 16 deletions(-) diff --git a/docs/rearchitecture-plan.md b/docs/rearchitecture-plan.md index f586406..3c413ef 100644 --- a/docs/rearchitecture-plan.md +++ b/docs/rearchitecture-plan.md @@ -187,6 +187,119 @@ Files: `ExecutionTree.ts`, `scheduleTools.ts`, `resolveWires.ts`, --- +## Phase 4b: V3 Scope-Based Pull Engine + +_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 + +**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 + +#### V3-Phase 4: Control Flow + +**Unlocks:** control-flow.test.ts, shared-parity.test.ts (break/continue) + +- `throw` — raises BridgeRuntimeError +- `panic` — raises BridgePanicError (fatal) +- `break` / `continue` — array iteration control + +#### V3-Phase 5: ToolDef / Define / Extends / on error + +**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 +- Extends chain resolution +- `on error` handler on tool invocation + +#### V3-Phase 6: Force Statements + +**Unlocks:** force-wire.test.ts, builtin-tools.test.ts (audit) + +- `force` — tool runs even if output not queried + +#### V3-Phase 7: Const Blocks + +**Unlocks:** resilience.test.ts (const in bridge), shared-parity.test.ts +(const blocks) + +- `with const as c` — reading from document-level `const` declarations + +#### V3-Phase 8: Overdefinition / Multi-wire + +**Unlocks:** coalesce-cost.test.ts (overdefinition), shared-parity.test.ts +(overdefinition) + +- Multiple wires to same target with cost-based prioritization +- Nullish coalescing across wires + +#### V3-Phase 9: Advanced Features + +- Spread syntax (`... <- a`) +- Native batching +- Memoized loop tools +- Error location tracking (BridgeRuntimeError wrapping) +- Prototype pollution guards +- Infinite loop protection +- Context binding (`with context`) + +--- + ## Phase 5: Reimplement Serializer + Re-enable Parser Tests _Depends on Phase 4. Can run parallel with early Phase 6._ diff --git a/packages/bridge-core/src/v3/execute-bridge.ts b/packages/bridge-core/src/v3/execute-bridge.ts index b688b48..b464ffe 100644 --- a/packages/bridge-core/src/v3/execute-bridge.ts +++ b/packages/bridge-core/src/v3/execute-bridge.ts @@ -144,16 +144,22 @@ function getPath( /** * Set a nested property on an object following a path array, * creating intermediate objects as needed. + * + * 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 + // 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; } @@ -638,7 +644,10 @@ async function evaluateExprSafe( ): Promise { try { const result = fn(); - if (result != null && typeof (result as Promise).then === "function") { + if ( + result != null && + typeof (result as Promise).then === "function" + ) { return await (result as Promise); } return result; @@ -679,20 +688,28 @@ async function evaluateExpression( const left = expr.leftSafe ? await evaluateExprSafe(() => evaluateExpression(expr.left, scope)) : await evaluateExpression(expr.left, scope); - if (!left) return left; - return expr.rightSafe - ? evaluateExprSafe(() => evaluateExpression(expr.right, scope)) - : evaluateExpression(expr.right, scope); + if (!left) return false; + if (expr.right.type === "literal" && expr.right.value === "true") { + return Boolean(left); + } + const right = expr.rightSafe + ? await evaluateExprSafe(() => evaluateExpression(expr.right, scope)) + : await evaluateExpression(expr.right, scope); + return Boolean(right); } case "or": { const left = expr.leftSafe ? await evaluateExprSafe(() => evaluateExpression(expr.left, scope)) : await evaluateExpression(expr.left, scope); - if (left) return left; - return expr.rightSafe - ? evaluateExprSafe(() => evaluateExpression(expr.right, scope)) - : evaluateExpression(expr.right, scope); + if (left) return true; + if (expr.right.type === "literal" && expr.right.value === "true") { + return Boolean(left); + } + const right = expr.rightSafe + ? await evaluateExprSafe(() => evaluateExpression(expr.right, scope)) + : await evaluateExpression(expr.right, scope); + return Boolean(right); } case "control": @@ -700,10 +717,45 @@ async function evaluateExpression( `Control flow "${expr.control.kind}" not implemented in v3 POC`, ); - case "pipe": - case "binary": + case "binary": { + const left = await evaluateExpression(expr.left, scope); + const right = await evaluateExpression(expr.right, scope); + 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); + 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": - case "concat": + return !(await evaluateExpression(expr.operand, scope)); + + case "concat": { + const parts = await Promise.all( + expr.parts.map((p) => evaluateExpression(p, scope)), + ); + return parts.map((v) => (v == null ? "" : String(v))).join(""); + } + + case "pipe": throw new Error( `Expression type "${expr.type}" not implemented in v3 POC`, ); @@ -887,8 +939,12 @@ export async function executeBridge( throw err; } + // 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: output as T, + data, traces: tracer?.traces ?? [], executionTraceId: 0n, }; diff --git a/packages/bridge/test/expressions.test.ts b/packages/bridge/test/expressions.test.ts index 359ec96..bffd82c 100644 --- a/packages/bridge/test/expressions.test.ts +++ b/packages/bridge/test/expressions.test.ts @@ -5,6 +5,7 @@ import { bridge } from "@stackables/bridge"; // ── Execution tests (regressionTest) ──────────────────────────────────────── regressionTest("expressions: execution", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -164,6 +165,7 @@ regressionTest("expressions: execution", { // ── Operator precedence tests (regressionTest) ────────────────────────────── regressionTest("expressions: operator precedence", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -221,6 +223,7 @@ regressionTest("expressions: operator precedence", { // ── Safe flag propagation in expressions (regressionTest) ─────────────────── regressionTest("safe flag propagation in expressions", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -329,6 +332,7 @@ regressionTest("safe flag propagation in expressions", { // ── String comparison and array mapping ───────────────────────────────────── regressionTest("expressions: string comparison and array mapping", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -394,6 +398,7 @@ regressionTest("expressions: string comparison and array mapping", { // ── Catch error fallback ──────────────────────────────────────────────────── regressionTest("expressions: catch error fallback", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -426,6 +431,7 @@ regressionTest("expressions: catch error fallback", { // ── Boolean logic: and/or ─────────────────────────────────────────────────── regressionTest("boolean logic: and/or end-to-end", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -469,6 +475,7 @@ regressionTest("boolean logic: and/or end-to-end", { // ── Parenthesized boolean expressions ─────────────────────────────────────── regressionTest("parenthesized boolean expressions: end-to-end", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -526,6 +533,7 @@ regressionTest("parenthesized boolean expressions: end-to-end", { // ── condAnd / condOr with synchronous tools ───────────────────────────────── regressionTest("condAnd / condOr with synchronous tools", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -587,6 +595,7 @@ regressionTest("condAnd / condOr with synchronous tools", { // ── Safe flag on right operand expressions ────────────────────────────────── regressionTest("safe flag on right operand expressions", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -635,6 +644,7 @@ regressionTest("safe flag on right operand expressions", { // ── Short-circuit data correctness ────────────────────────────────────────── regressionTest("and/or short-circuit data correctness", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 diff --git a/packages/bridge/test/interpolation-universal.test.ts b/packages/bridge/test/interpolation-universal.test.ts index f699cb3..0c1995f 100644 --- a/packages/bridge/test/interpolation-universal.test.ts +++ b/packages/bridge/test/interpolation-universal.test.ts @@ -14,6 +14,7 @@ import { bridge } from "@stackables/bridge"; // ═══════════════════════════════════════════════════════════════════════════ regressionTest("universal interpolation: fallback", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -70,8 +71,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" }], @@ -87,6 +98,7 @@ regressionTest("universal interpolation: fallback", { }); regressionTest("universal interpolation: ternary", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 diff --git a/packages/bridge/test/shared-parity.test.ts b/packages/bridge/test/shared-parity.test.ts index fd21aaf..db85acd 100644 --- a/packages/bridge/test/shared-parity.test.ts +++ b/packages/bridge/test/shared-parity.test.ts @@ -906,6 +906,7 @@ regressionTest("parity: const blocks", { // ── 10. String interpolation ──────────────────────────────────────────────── regressionTest("parity: string interpolation", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -947,6 +948,7 @@ regressionTest("parity: string interpolation", { // ── 11. Expressions (math, comparison) ────────────────────────────────────── regressionTest("parity: expressions", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 diff --git a/packages/bridge/test/string-interpolation.test.ts b/packages/bridge/test/string-interpolation.test.ts index 6b0b727..8d74495 100644 --- a/packages/bridge/test/string-interpolation.test.ts +++ b/packages/bridge/test/string-interpolation.test.ts @@ -5,6 +5,7 @@ import { bridge } from "@stackables/bridge"; // ── String interpolation execution tests ──────────────────────────────────── regressionTest("string interpolation", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 From 4376eb1c85361e0a12caab13c37aebc6ad467e42 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Mon, 16 Mar 2026 16:30:20 +0100 Subject: [PATCH 11/23] Features ++ --- packages/bridge-core/src/v3/execute-bridge.ts | 500 +++++++++++++++++- .../bridge-parser/src/parser/ast-builder.ts | 18 + packages/bridge/test/alias.test.ts | 4 +- .../test/bugfixes/trace-tooldef-names.test.ts | 71 ++- packages/bridge/test/builtin-tools.test.ts | 14 + packages/bridge/test/execute-bridge.test.ts | 8 + .../test/infinite-loop-protection.test.ts | 2 + .../bridge/test/language-spec/wires.test.ts | 1 + .../bridge/test/prototype-pollution.test.ts | 3 + packages/bridge/test/resilience.test.ts | 3 + packages/bridge/test/scheduling.test.ts | 10 +- packages/bridge/test/scope-and-edges.test.ts | 3 + packages/bridge/test/shared-parity.test.ts | 12 + packages/bridge/test/sync-tools.test.ts | 2 + packages/bridge/test/tool-features.test.ts | 8 + .../test/tool-self-wires-runtime.test.ts | 1 + packages/bridge/test/traces-on-errors.test.ts | 3 + 17 files changed, 624 insertions(+), 39 deletions(-) diff --git a/packages/bridge-core/src/v3/execute-bridge.ts b/packages/bridge-core/src/v3/execute-bridge.ts index b464ffe..a63c9d6 100644 --- a/packages/bridge-core/src/v3/execute-bridge.ts +++ b/packages/bridge-core/src/v3/execute-bridge.ts @@ -3,8 +3,12 @@ import type { Logger } from "../tree-types.ts"; import type { Bridge, BridgeDocument, + ConstDef, + DefineDef, Expression, + HandleBinding, NodeRef, + ScopeStatement, SourceChain, Statement, ToolDef, @@ -14,8 +18,9 @@ import type { WireStatement, } from "../types.ts"; import { SELF_MODULE } from "../types.ts"; -import { TraceCollector } from "../tracing.ts"; +import { TraceCollector, resolveToolMeta } from "../tracing.ts"; import { isFatalError } from "../tree-types.ts"; +import { UNSAFE_KEYS } from "../tree-utils.ts"; import { std as bundledStd, STD_VERSION as BUNDLED_STD_VERSION, @@ -131,12 +136,19 @@ function getPath( ): 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 || typeof current !== "object") { const safe = pathSafe?.[i] ?? (i === 0 ? (rootSafe ?? false) : false); - if (safe) return undefined; - return undefined; // path traversal naturally returns undefined + if (safe) { + current = undefined; + continue; + } + // Strict path: simulate JS property access to get TypeError on null + return (current as Record)[segment]; } - current = (current as Record)[path[i]!]; + current = (current as Record)[segment]; } return current; } @@ -166,6 +178,8 @@ function setPath( 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" || @@ -177,6 +191,8 @@ function setPath( } const leaf = path[path.length - 1]; if (leaf !== undefined) { + if (UNSAFE_KEYS.has(leaf)) + throw new Error(`Unsafe assignment key: ${leaf}`); current[leaf] = value; } } @@ -199,6 +215,7 @@ function lookupToolFn( 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]; } @@ -247,6 +264,18 @@ class ExecutionScope { /** Cached alias evaluation results. */ private readonly aliasResults = new Map>(); + /** Handle bindings — maps handle alias to binding info. */ + private readonly handleBindings = new Map(); + + /** Owned define modules — keyed by __define_ prefix. */ + private readonly ownedDefines = new Set(); + + /** Define input wires indexed by "module:field" key. */ + private readonly defineInputWires = new Map(); + + /** When true, this scope acts as a root for output writes (define scopes). */ + private isRootScope = false; + constructor( parent: ExecutionScope | null, selfInput: Record, @@ -264,6 +293,52 @@ class ExecutionScope { this.ownedTools.add(bindingOwnerKey(name)); } + /** Register that this scope owns a define block declared via `with`. */ + declareDefineBinding(handle: string): void { + this.ownedDefines.add(`__define_${handle}`); + } + + /** Index a define input wire (wire targeting a __define_* module). */ + addDefineInputWire(wire: WireStatement): void { + const key = `${wire.target.module}:${wire.target.field}`; + let wires = this.defineInputWires.get(key); + if (!wires) { + wires = []; + this.defineInputWires.set(key, wires); + } + wires.push(wire); + } + + /** Register a handle binding for later lookup (pipe expressions, etc.). */ + registerHandle(binding: HandleBinding): void { + this.handleBindings.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}:${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 key = toolKey( @@ -368,10 +443,10 @@ class ExecutionScope { return undefined; } - /** Get the root scope (for non-element output writes). */ + /** Get the root scope (stops at define boundaries). */ root(): ExecutionScope { let scope: ExecutionScope = this; - while (scope.parent) scope = scope.parent; + while (scope.parent && !scope.isRootScope) scope = scope.parent; return scope; } @@ -409,6 +484,8 @@ class ExecutionScope { /** * Lazily call a tool — evaluates input wires on demand, invokes the * tool function, and caches the result. + * + * Supports ToolDef resolution (extends chain, base wires, onError). */ private callTool( key: string, @@ -416,28 +493,42 @@ class ExecutionScope { field: string, ): Promise { const promise = (async () => { - // Pull: evaluate tool input wires lazily + 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); + if (!fn) throw new Error(`No tool found for "${fnName}"`); + const { doTrace } = resolveToolMeta(fn); + + // Build input: ToolDef base wires first, then bridge wires override const input: Record = {}; + + if (toolDef?.body) { + await evaluateToolDefBody(toolDef.body, input, this); + } + const wires = this.toolInputWires.get(key) ?? []; for (const wire of wires) { const value = await evaluateSourceChain(wire, this); setPath(input, wire.target.path, value); } - const toolName = module === SELF_MODULE ? field : `${module}.${field}`; - const fn = lookupToolFn(this.engine.tools, toolName); - if (!fn) throw new Error(`Tool function "${toolName}" not registered`); - const startMs = performance.now(); try { const result = await fn(input, { logger: this.engine.logger }); const durationMs = performance.now() - startMs; - if (this.engine.tracer) { + if (this.engine.tracer && doTrace) { this.engine.tracer.record( this.engine.tracer.entry({ tool: toolName, - fn: toolName, + fn: fnName, input, output: result, durationMs, @@ -450,11 +541,11 @@ class ExecutionScope { } catch (err) { const durationMs = performance.now() - startMs; - if (this.engine.tracer) { + if (this.engine.tracer && doTrace) { this.engine.tracer.record( this.engine.tracer.entry({ tool: toolName, - fn: toolName, + fn: fnName, input, error: (err as Error).message, durationMs, @@ -463,6 +554,23 @@ class ExecutionScope { ); } + if (isFatalError(err)) throw err; + + 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); + } + } + } + throw err; } })(); @@ -470,17 +578,223 @@ class ExecutionScope { this.toolResults.set(key, promise); return promise; } + + /** + * Resolve a define block result via scope chain. + * Creates a child scope, indexes define body, and pulls output. + */ + async resolveDefine( + module: string, + field: string, + instance: number | undefined, + ): Promise { + const key = `${module}:${field}`; + + // Check memoization + if (this.toolResults.has(key)) return this.toolResults.get(key)!; + + // Check ownership + if (this.ownedDefines.has(module)) { + return this.executeDefine(key, module); + } + + // Delegate to parent + if (this.parent) { + return this.parent.resolveDefine(module, field, instance); + } + + throw new Error(`Define "${module}" not found in any scope`); + } + + /** + * Execute a define block — build input from bridge wires, create + * child scope with define body, pull output. + */ + private executeDefine(key: string, module: 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) + const inputWires = this.defineInputWires.get(key) ?? []; + const defineInput: Record = {}; + for (const wire of inputWires) { + const value = await evaluateSourceChain(wire, this); + setPath(defineInput, wire.target.path, value); + } + + // Create child scope with define input as selfInput + const defineOutput: Record = {}; + const defineScope = new ExecutionScope( + this, + defineInput, + defineOutput, + this.engine, + ); + defineScope.isRootScope = true; + + // Index define body and pull output + indexStatements(defineDef.body, defineScope); + await resolveRequestedFields(defineScope, []); + + 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 | { kind: string })[]; + readonly instructions: readonly (Bridge | ToolDef | ConstDef | DefineDef)[]; readonly type: string; readonly field: string; readonly context: Record; readonly logger?: Logger; readonly tracer?: TraceCollector; + readonly toolDefCache: Map; +} + +// ── 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: [], + wires: [], + 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, +): 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) + for (const stmt of body) { + if (stmt.kind === "wire" && stmt.target.instance == null) { + const value = await evaluateSourceChain(stmt, toolDefScope); + setPath(input, stmt.target.path, value); + } else if (stmt.kind === "scope") { + await evaluateToolDefScope(stmt, input, toolDefScope); + } + } +} + +/** Recursively evaluate scope blocks inside ToolDef bodies. */ +async function evaluateToolDefScope( + scope: ScopeStatement, + input: Record, + toolDefScope: ExecutionScope, +): Promise { + const prefix = scope.target.path; + for (const inner of scope.body) { + if (inner.kind === "wire" && inner.target.instance == null) { + const value = await evaluateSourceChain(inner, toolDefScope); + setPath(input, [...prefix, ...inner.target.path], value); + } else if (inner.kind === "scope") { + // Nest the inner scope under the current prefix + const nested: ScopeStatement = { + ...inner, + target: { + ...inner.target, + path: [...prefix, ...inner.target.path], + }, + }; + await evaluateToolDefScope(nested, input, toolDefScope); + } + } } // ── Statement indexing & pulling ──────────────────────────────────────────── @@ -499,10 +813,18 @@ function indexStatements( case "with": if (stmt.binding.kind === "tool") { scope.declareToolBinding(stmt.binding.name); + } else if (stmt.binding.kind === "define") { + scope.declareDefineBinding(stmt.binding.handle); } + scope.registerHandle(stmt.binding); 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) @@ -756,9 +1078,7 @@ async function evaluateExpression( } case "pipe": - throw new Error( - `Expression type "${expr.type}" not implemented in v3 POC`, - ); + return evaluatePipeExpression(expr, scope); default: throw new Error(`Unknown expression type: ${(expr as Expression).type}`); @@ -775,8 +1095,9 @@ async function evaluateExpression( async function evaluateArrayExpr( expr: Extract, scope: ExecutionScope, -): Promise { +): Promise { const sourceValue = await evaluateExpression(expr.source, scope); + if (sourceValue == null) return null; if (!Array.isArray(sourceValue)) return []; const results: unknown[] = []; @@ -801,6 +1122,107 @@ async function evaluateArrayExpr( 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, +): Promise { + // 1. Evaluate source + const sourceValue = await evaluateExpression(expr.source, scope); + + // 2. 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}"`, + ); + + // 3. 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); + + // 4. Build input + const input: Record = {}; + + // 4a. ToolDef body wires (base configuration) + if (toolDef?.body) { + await evaluateToolDefBody(toolDef.body, input, scope); + } + + // 4b. Bridge wires for this tool (non-pipe input wires) + const bridgeWires = scope.collectToolInputWiresFor(toolName); + for (const wire of bridgeWires) { + const value = await evaluateSourceChain(wire, scope); + setPath(input, wire.target.path, value); + } + + // 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) + const startMs = performance.now(); + try { + const result = await fn(input, { logger: scope.engine.logger }); + 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) { + 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. */ @@ -824,6 +1246,26 @@ async function resolveRef( 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_")) { + const result = await scope.resolveDefine( + ref.module, + ref.field, + ref.instance, + ); + return getPath(result, ref.path, ref.rootSafe, ref.pathSafe); + } + // Self-module input reference — reading from input args if (ref.module === SELF_MODULE && ref.instance == null) { return getPath(scope.selfInput, ref.path, ref.rootSafe, ref.pathSafe); @@ -838,6 +1280,23 @@ async function resolveRef( 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); + return getPath(parsed, remaining, ref.rootSafe, ref.pathSafe); +} + /** * Write a value to the target output location. * @@ -920,6 +1379,7 @@ export async function executeBridge( context, logger: options.logger, tracer, + toolDefCache: new Map(), }; // Create root scope and execute diff --git a/packages/bridge-parser/src/parser/ast-builder.ts b/packages/bridge-parser/src/parser/ast-builder.ts index 902f426..0d79a15 100644 --- a/packages/bridge-parser/src/parser/ast-builder.ts +++ b/packages/bridge-parser/src/parser/ast-builder.ts @@ -1997,6 +1997,24 @@ export function buildBody( 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", diff --git a/packages/bridge/test/alias.test.ts b/packages/bridge/test/alias.test.ts index 13c1477..b2f2985 100644 --- a/packages/bridge/test/alias.test.ts +++ b/packages/bridge/test/alias.test.ts @@ -21,12 +21,12 @@ regressionTest("alias keyword", { .value <- i.value || "Fallback 1" } || c.realArray[] as i { .value <- i.value || "Fallback 2" - } catch "No arrays" + } catch [] } `, - disable: true, + disable: ["compiled", "parser"], tools: tools, scenarios: { "Array.is_wire": { diff --git a/packages/bridge/test/bugfixes/trace-tooldef-names.test.ts b/packages/bridge/test/bugfixes/trace-tooldef-names.test.ts index b42b6b6..8582d51 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"); @@ -31,6 +43,7 @@ function assertTraceShape(traces: ToolTrace[]) { // ── 1. ToolDef-backed tool: tool vs fn fields ─────────────────────────────── regressionTest("trace: ToolDef name preserved in trace", { + disable: ["compiled", "parser"], bridge: ` version 1.5 @@ -57,8 +70,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"`, + ); }, }, }, @@ -68,6 +89,7 @@ regressionTest("trace: ToolDef name preserved in trace", { // ── 2. Multiple ToolDefs from same function are distinguishable ───────────── regressionTest("trace: multiple ToolDefs from same fn are distinguishable", { + disable: ["compiled", "parser"], bridge: ` version 1.5 @@ -105,10 +127,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`, + ); }, }, }, @@ -118,6 +154,7 @@ regressionTest("trace: multiple ToolDefs from same fn are distinguishable", { // ── 3. Plain tool (no ToolDef) — tool and fn are identical ────────────────── regressionTest("trace: plain tool has matching tool and fn fields", { + disable: ["compiled", "parser"], bridge: ` version 1.5 @@ -151,6 +188,7 @@ regressionTest("trace: plain tool has matching tool and fn fields", { // ── 4. ToolDef used in define block ───────────────────────────────────────── regressionTest("trace: ToolDef in define block preserves name", { + disable: ["compiled", "parser"], bridge: ` version 1.5 @@ -186,8 +224,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"`, + ); }, }, }, @@ -197,6 +243,7 @@ regressionTest("trace: ToolDef in define block preserves name", { // ── 5. Same tool referenced from two define blocks ────────────────────────── regressionTest("trace: same tool in two defines produces correct names", { + disable: ["compiled", "parser"], bridge: ` version 1.5 diff --git a/packages/bridge/test/builtin-tools.test.ts b/packages/bridge/test/builtin-tools.test.ts index a0978be..2f76314 100644 --- a/packages/bridge/test/builtin-tools.test.ts +++ b/packages/bridge/test/builtin-tools.test.ts @@ -9,6 +9,7 @@ import { bridge } from "@stackables/bridge"; describe("builtin tools", () => { regressionTest("string builtins", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 bridge Query.format { @@ -54,6 +55,7 @@ describe("builtin tools", () => { assertTraces: 4, }, "missing std tool when namespace overridden": { + disable: ["v3"], input: { text: "Hello" }, tools: { std: { somethingElse: () => ({}) }, @@ -84,6 +86,7 @@ describe("builtin tools", () => { // ── Custom tools alongside std ────────────────────────────────────────── regressionTest("custom tools alongside std", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 bridge Query.process { @@ -113,6 +116,7 @@ describe("builtin tools", () => { // ── Array filter ──────────────────────────────────────────────────────── regressionTest("array filter", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 bridge Query.admins { @@ -158,6 +162,7 @@ describe("builtin tools", () => { assertTraces: 1, }, "users source error propagates": { + disable: ["v3"], input: {}, tools: { getUsers: async () => { @@ -174,6 +179,7 @@ describe("builtin tools", () => { // ── Array find ────────────────────────────────────────────────────────── regressionTest("array find", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 bridge Query.findUser { @@ -206,6 +212,7 @@ describe("builtin tools", () => { assertTraces: 1, }, "users source error propagates": { + disable: ["v3"], input: { role: "editor" }, tools: { getUsers: async () => { @@ -216,6 +223,7 @@ describe("builtin tools", () => { assertTraces: 1, }, "find tool failure propagates to projected fields": { + disable: ["v3"], input: { role: "editor" }, tools: { std: { @@ -238,6 +246,7 @@ describe("builtin tools", () => { // ── Array first ───────────────────────────────────────────────────────── regressionTest("array first", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 bridge Query.first { @@ -256,6 +265,7 @@ describe("builtin tools", () => { assertTraces: 0, }, "first tool failure propagates": { + disable: ["v3"], input: { items: ["a", "b"] }, tools: { std: { @@ -278,6 +288,7 @@ describe("builtin tools", () => { // ── Array first strict mode ───────────────────────────────────────────── regressionTest("array first strict mode", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 tool pf from std.arr.first { @@ -300,6 +311,7 @@ describe("builtin tools", () => { assertTraces: 0, }, "strict errors with multiple elements": { + disable: ["v3"], input: { items: ["a", "b"] }, assertError: /RuntimeError/, assertTraces: 0, @@ -311,6 +323,7 @@ describe("builtin tools", () => { // ── toArray ───────────────────────────────────────────────────────────── regressionTest("toArray", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 bridge Query.normalize { @@ -336,6 +349,7 @@ describe("builtin tools", () => { assertTraces: 1, }, "toArray tool failure propagates": { + disable: ["v3"], input: { value: "hello" }, tools: { std: { diff --git a/packages/bridge/test/execute-bridge.test.ts b/packages/bridge/test/execute-bridge.test.ts index 997e0c3..54dce28 100644 --- a/packages/bridge/test/execute-bridge.test.ts +++ b/packages/bridge/test/execute-bridge.test.ts @@ -14,6 +14,7 @@ import { bridge } from "@stackables/bridge"; // ── Object output: chained tools, root passthrough, constants ───────────── regressionTest("object output: chained tools and passthrough", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -78,6 +79,7 @@ regressionTest("object output: chained tools and passthrough", { // ── Array output ────────────────────────────────────────────────────────── regressionTest("array output: root and sub-field mapping", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -166,6 +168,7 @@ regressionTest("array output: root and sub-field mapping", { // ── Pipe, alias and ternary inside array blocks ─────────────────────────── regressionTest("array blocks: pipe, alias, and ternary", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -291,6 +294,7 @@ regressionTest("array blocks: pipe, alias, and ternary", { // ── Nested structures: scope blocks and nested arrays ───────────────────── regressionTest("nested structures: scope blocks and nested arrays", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -446,6 +450,7 @@ regressionTest("nested structures: scope blocks and nested arrays", { // ── Alias declarations ─────────────────────────────────────────────────── regressionTest("alias: iterator-scoped aliases", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -561,6 +566,7 @@ regressionTest("alias: iterator-scoped aliases", { }); regressionTest("alias: top-level aliases", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -660,6 +666,7 @@ regressionTest("alias: top-level aliases", { }); regressionTest("alias: expressions and modifiers", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -904,6 +911,7 @@ const noTraceTool = (p: any) => ({ y: p.x * 3 }); (noTraceTool as any).bridge = { sync: true, trace: false }; regressionTest("tracing", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 diff --git a/packages/bridge/test/infinite-loop-protection.test.ts b/packages/bridge/test/infinite-loop-protection.test.ts index 00c9d79..8affc2f 100644 --- a/packages/bridge/test/infinite-loop-protection.test.ts +++ b/packages/bridge/test/infinite-loop-protection.test.ts @@ -40,6 +40,7 @@ regressionTest("circular dependency detection", { // ══════════════════════════════════════════════════════════════════════════════ regressionTest("infinite loop protection: array mapping", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -69,6 +70,7 @@ regressionTest("infinite loop protection: array mapping", { }); regressionTest("infinite loop protection: non-circular chain", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 diff --git a/packages/bridge/test/language-spec/wires.test.ts b/packages/bridge/test/language-spec/wires.test.ts index d3a280c..9452bde 100644 --- a/packages/bridge/test/language-spec/wires.test.ts +++ b/packages/bridge/test/language-spec/wires.test.ts @@ -10,6 +10,7 @@ import { tools } from "../utils/bridge-tools.ts"; import { bridge } from "@stackables/bridge"; regressionTest("wires", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 diff --git a/packages/bridge/test/prototype-pollution.test.ts b/packages/bridge/test/prototype-pollution.test.ts index 673749a..176d326 100644 --- a/packages/bridge/test/prototype-pollution.test.ts +++ b/packages/bridge/test/prototype-pollution.test.ts @@ -11,6 +11,7 @@ import { bridge } from "@stackables/bridge"; // ══════════════════════════════════════════════════════════════════════════════ regressionTest("prototype pollution – setNested guard", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -65,6 +66,7 @@ regressionTest("prototype pollution – setNested guard", { }); regressionTest("prototype pollution – pullSingle guard", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -103,6 +105,7 @@ regressionTest("prototype pollution – pullSingle guard", { }); regressionTest("prototype pollution – tool lookup guard", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 diff --git a/packages/bridge/test/resilience.test.ts b/packages/bridge/test/resilience.test.ts index 33a5383..19192ef 100644 --- a/packages/bridge/test/resilience.test.ts +++ b/packages/bridge/test/resilience.test.ts @@ -12,6 +12,7 @@ import { bridge } from "@stackables/bridge"; // ── 1. Const in bridge ────────────────────────────────────────────────────── regressionTest("resilience: const in bridge", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -48,6 +49,7 @@ regressionTest("resilience: const in bridge", { // ── 2. Tool on error ──────────────────────────────────────────────────────── regressionTest("resilience: tool on error", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -238,6 +240,7 @@ regressionTest("resilience: wire catch", { // ── 4. Combined: on error + catch + const ─────────────────────────────────── regressionTest("resilience: combined on error + catch + const", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 diff --git a/packages/bridge/test/scheduling.test.ts b/packages/bridge/test/scheduling.test.ts index 420a383..10a3ef2 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}`); @@ -69,6 +65,7 @@ function assertSequential( // after geocode, formatGreeting runs independently in parallel. regressionTest("scheduling: diamond dependency dedup", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -128,6 +125,7 @@ regressionTest("scheduling: diamond dependency dedup", { // timing (two 60ms calls completing in ~60ms, not 120ms). regressionTest("scheduling: pipe forks run independently", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -161,6 +159,7 @@ regressionTest("scheduling: pipe forks run independently", { // Execution: i.text → toUpper → normalize (right-to-left) regressionTest("scheduling: chained pipes execute right-to-left", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -194,6 +193,7 @@ regressionTest("scheduling: chained pipes execute right-to-left", { // The tool should be called the minimum number of times necessary. regressionTest("scheduling: shared tool dedup across pipe and direct", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 diff --git a/packages/bridge/test/scope-and-edges.test.ts b/packages/bridge/test/scope-and-edges.test.ts index 212d4ba..0f6394e 100644 --- a/packages/bridge/test/scope-and-edges.test.ts +++ b/packages/bridge/test/scope-and-edges.test.ts @@ -12,6 +12,7 @@ import { bridge } from "@stackables/bridge"; // ── 1. Nested shadow scope chain ──────────────────────────────────────────── regressionTest("nested shadow scope chain", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -141,6 +142,7 @@ regressionTest("nested shadow scope chain", { // ── 2. Tool extends: duplicate target override ────────────────────────────── regressionTest("tool extends with duplicate target override", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -213,6 +215,7 @@ const mockHttpCall = async () => ({ }); regressionTest("nested array-in-array mapping", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 diff --git a/packages/bridge/test/shared-parity.test.ts b/packages/bridge/test/shared-parity.test.ts index db85acd..8d8b7c0 100644 --- a/packages/bridge/test/shared-parity.test.ts +++ b/packages/bridge/test/shared-parity.test.ts @@ -12,6 +12,7 @@ import { bridge } from "@stackables/bridge"; // ── 1. Pull wires + constants ─────────────────────────────────────────────── regressionTest("parity: pull wires + constants", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -172,6 +173,7 @@ regressionTest("parity: pull wires + constants", { // ── 2. Fallback operators (??, ||) ────────────────────────────────────────── regressionTest("parity: fallback operators", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -273,6 +275,7 @@ regressionTest("parity: fallback operators", { // ── 3. Array mapping ──────────────────────────────────────────────────────── regressionTest("parity: array mapping", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -412,6 +415,7 @@ regressionTest("parity: array mapping", { // ── 4. Ternary / conditional wires ────────────────────────────────────────── regressionTest("parity: ternary / conditional wires", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -675,6 +679,7 @@ regressionTest("parity: force statements", { // ── 7. ToolDef support ────────────────────────────────────────────────────── regressionTest("parity: ToolDef support", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -827,6 +832,7 @@ regressionTest("parity: ToolDef support", { // ── 8. Tool context injection ─────────────────────────────────────────────── regressionTest("parity: tool context injection", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -859,6 +865,7 @@ regressionTest("parity: tool context injection", { // ── 9. Const blocks ───────────────────────────────────────────────────────── regressionTest("parity: const blocks", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -987,6 +994,7 @@ regressionTest("parity: expressions", { // ── 12. Nested scope blocks ───────────────────────────────────────────────── regressionTest("parity: nested scope blocks", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -1028,6 +1036,7 @@ regressionTest("parity: nested scope blocks", { // ── 13. Nested arrays ─────────────────────────────────────────────────────── regressionTest("parity: nested arrays", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -1115,6 +1124,7 @@ regressionTest("parity: nested arrays", { // ── 14. Pipe operators ────────────────────────────────────────────────────── regressionTest("parity: pipe operators", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -1143,6 +1153,7 @@ regressionTest("parity: pipe operators", { // ── 15. Define blocks ─────────────────────────────────────────────────────── regressionTest("parity: define blocks", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -1238,6 +1249,7 @@ regressionTest("parity: define blocks", { // ── 16. Alias declarations ────────────────────────────────────────────────── regressionTest("parity: alias declarations", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 diff --git a/packages/bridge/test/sync-tools.test.ts b/packages/bridge/test/sync-tools.test.ts index 7941c4a..dae0439 100644 --- a/packages/bridge/test/sync-tools.test.ts +++ b/packages/bridge/test/sync-tools.test.ts @@ -60,6 +60,7 @@ regressionTest("sync tool enforcement", { // ── 2. Sync tool execution ────────────────────────────────────────────────── regressionTest("sync tool execution", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -171,6 +172,7 @@ const syncEnrich = (input: any) => ({ (syncEnrich as any).bridge = { sync: true } satisfies ToolMetadata; regressionTest("sync array map", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 diff --git a/packages/bridge/test/tool-features.test.ts b/packages/bridge/test/tool-features.test.ts index 790da41..b2f10ef 100644 --- a/packages/bridge/test/tool-features.test.ts +++ b/packages/bridge/test/tool-features.test.ts @@ -15,6 +15,7 @@ import { bridge } from "@stackables/bridge"; // ── 1. Missing tool ───────────────────────────────────────────────────────── regressionTest("tool features: missing tool", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -41,6 +42,7 @@ regressionTest("tool features: missing tool", { // ── 2. Extends chain ──────────────────────────────────────────────────────── regressionTest("tool features: extends chain", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -101,6 +103,7 @@ regressionTest("tool features: extends chain", { // ── 3. Context pull ───────────────────────────────────────────────────────── regressionTest("tool features: context pull", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -139,6 +142,7 @@ regressionTest("tool features: context pull", { // ── 4. Tool-to-tool dependency ────────────────────────────────────────────── regressionTest("tool features: tool-to-tool dependency", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -214,6 +218,7 @@ regressionTest("tool features: tool-to-tool dependency", { // ── 5. Pipe operator (basic) ──────────────────────────────────────────────── regressionTest("tool features: pipe operator", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -242,6 +247,7 @@ regressionTest("tool features: pipe operator", { // ── 6. Pipe with extra tool params ────────────────────────────────────────── regressionTest("tool features: pipe with extra ToolDef params", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -300,6 +306,7 @@ regressionTest("tool features: pipe with extra ToolDef params", { // ── 7. Pipe forking ───────────────────────────────────────────────────────── regressionTest("tool features: pipe forking", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -331,6 +338,7 @@ regressionTest("tool features: pipe forking", { // ── 8. Named pipe input field ─────────────────────────────────────────────── regressionTest("tool features: named pipe input field", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 diff --git a/packages/bridge/test/tool-self-wires-runtime.test.ts b/packages/bridge/test/tool-self-wires-runtime.test.ts index 0eddf46..40e6748 100644 --- a/packages/bridge/test/tool-self-wires-runtime.test.ts +++ b/packages/bridge/test/tool-self-wires-runtime.test.ts @@ -3,6 +3,7 @@ import { tools } from "./utils/bridge-tools.ts"; import { bridge } from "@stackables/bridge"; regressionTest("tool self-wire runtime", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 diff --git a/packages/bridge/test/traces-on-errors.test.ts b/packages/bridge/test/traces-on-errors.test.ts index b55ef9b..9fd64cd 100644 --- a/packages/bridge/test/traces-on-errors.test.ts +++ b/packages/bridge/test/traces-on-errors.test.ts @@ -13,6 +13,7 @@ import { bridge } from "@stackables/bridge"; // ══════════════════════════════════════════════════════════════════════════════ regressionTest("traces on errors", { + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -49,6 +50,7 @@ regressionTest("traces on errors", { assertTraces: 2, }, "error carries traces from tools that completed before the failure": { + disable: ["v3"], input: { good: { greeting: "hello alice" }, bad: { _error: "tool boom" }, @@ -76,6 +78,7 @@ regressionTest("traces on errors", { }, "Query.soloFailure": { "error carries executionTraceId and traces array": { + disable: ["v3"], input: { bad: { _error: "tool boom" } }, assertError: (err: any) => { assert.ok(err instanceof BridgeRuntimeError); From c99f193f9584f4b019ab31ace579670d8d66be96 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Mon, 16 Mar 2026 16:35:04 +0100 Subject: [PATCH 12/23] specify v1 --- packages/bridge-parser/src/parser/parser.ts | 118 ++++++++++++++++++++ packages/bridge/test/tool-features.test.ts | 84 ++++++++++++++ 2 files changed, 202 insertions(+) diff --git a/packages/bridge-parser/src/parser/parser.ts b/packages/bridge-parser/src/parser/parser.ts index 98ca349..b729669 100644 --- a/packages/bridge-parser/src/parser/parser.ts +++ b/packages/bridge-parser/src/parser/parser.ts @@ -6490,6 +6490,116 @@ function buildBridgeBody( }); } + // ── Helper: flatten scope blocks in tool self-wires ─────────────────── + + function flattenSelfWireScopeLines( + scopeLines: CstNode[], + spreadLines: CstNode[], + pathPrefix: string[], + ): void { + 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: { + module: SELF_MODULE, + type: bridgeType, + field: bridgeField, + path: pathPrefix, + }, + sources: [ + { + expr: { + type: "ref", + ref: fromRef, + ...(spreadSafe ? { safe: true as const } : {}), + }, + }, + ], + spread: true as const, + }, + locFromNode(spreadLine), + ), + ); + } + + for (const scopeLine of scopeLines) { + const sc = scopeLine.children; + const scopeLineLoc = locFromNode(scopeLine); + const targetStr = extractDottedPathStr(sub(scopeLine, "scopeTarget")!); + const scopeSegs = parsePath(targetStr); + const fullPath = [...pathPrefix, ...scopeSegs]; + + const toRef: NodeRef = { + module: SELF_MODULE, + type: bridgeType, + field: bridgeField, + path: fullPath, + }; + + // Nested scope: .field { ... } + const nestedScopeLines = subs(scopeLine, "pathScopeLine"); + const nestedSpreadLines = subs(scopeLine, "scopeSpreadLine"); + if ( + (nestedScopeLines.length > 0 || nestedSpreadLines.length > 0) && + !sc.scopeEquals && + !sc.scopeArrow + ) { + flattenSelfWireScopeLines( + nestedScopeLines, + nestedSpreadLines, + fullPath, + ); + continue; + } + + // Constant: .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 + if (sc.scopeArrow) { + const scopeLineNum = line(findFirstToken(scopeLine)); + const { ref: srcRef, safe: srcSafe } = buildSourceExprSafe( + sub(scopeLine, "scopeSource")!, + scopeLineNum, + ); + wires.push( + withLoc( + { + to: toRef, + sources: [ + { + expr: { + type: "ref", + ref: srcRef, + ...(srcSafe ? { safe: true as const } : {}), + }, + }, + ], + }, + scopeLineLoc, + ), + ); + continue; + } + } + } + // ── Step 4: Process tool self-wires (elementLine CST nodes) ─────────── const selfWireNodes = options?.selfWireNodes; @@ -6521,6 +6631,14 @@ function buildBridgeBody( continue; } + // ── Scope block: .field { .sub <- ..., .sub = ... } ── + if (elemC.elemScopeBlock) { + const scopeLines = subs(elemLine, "elemScopeLine"); + const spreadLines = subs(elemLine, "elemSpreadLine"); + flattenSelfWireScopeLines(scopeLines, spreadLines, elemToPath); + continue; + } + if (!elemC.elemArrow) continue; // ── String source: .field <- "..." ── diff --git a/packages/bridge/test/tool-features.test.ts b/packages/bridge/test/tool-features.test.ts index b2f10ef..9237b6f 100644 --- a/packages/bridge/test/tool-features.test.ts +++ b/packages/bridge/test/tool-features.test.ts @@ -367,3 +367,87 @@ regressionTest("tool features: named pipe input field", { }, }, }); + +// ── 9. Scope blocks in ToolDef ────────────────────────────────────────────── + +regressionTest("tool features: scope blocks in tool body", { + disable: ["compiled", "parser"], + 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", { + disable: ["compiled", "parser"], + 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, + }, + }, + }, +}); From ca515c24bce4b0151d2b088a30bc61e5855d6988 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Mon, 16 Mar 2026 17:07:57 +0100 Subject: [PATCH 13/23] Phase 4: Control Flow --- docs/rearchitecture-plan.md | 52 +++++++--- packages/bridge-core/src/v3/execute-bridge.ts | 98 ++++++++++++++++--- packages/bridge/test/control-flow.test.ts | 7 ++ packages/bridge/test/force-wire.test.ts | 2 + 4 files changed, 133 insertions(+), 26 deletions(-) diff --git a/docs/rearchitecture-plan.md b/docs/rearchitecture-plan.md index 3c413ef..b952048 100644 --- a/docs/rearchitecture-plan.md +++ b/docs/rearchitecture-plan.md @@ -242,43 +242,60 @@ string interpolation) - `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 +#### 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 +#### V3-Phase 4: Control Flow ✅ COMPLETE **Unlocks:** control-flow.test.ts, shared-parity.test.ts (break/continue) -- `throw` — raises BridgeRuntimeError -- `panic` — raises BridgePanicError (fatal) -- `break` / `continue` — array iteration control +- `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` per-wire error isolation (non-fatal caught, first re-thrown) +- `evaluateArrayExpr` handles BREAK_SYM/CONTINUE_SYM/LoopControlSignal +- `applyCatchHandler` delegates to `applyControlFlow()` for all catch control flows +- Known limitation: panic trace count mismatch (lazy eval fires panic before tool wires) -#### V3-Phase 5: ToolDef / Define / Extends / on error +#### 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 -- Extends chain resolution -- `on error` handler on tool invocation +- 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 +#### 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 +#### 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: Overdefinition / Multi-wire @@ -287,6 +304,7 @@ shared-parity.test.ts (ToolDef, define), scope-and-edges.test.ts - Multiple wires to same target with cost-based prioritization - Nullish coalescing across wires +- Currently 8 scenarios disabled for v3 in coalesce-cost.test.ts #### V3-Phase 9: Advanced Features @@ -297,6 +315,18 @@ shared-parity.test.ts (ToolDef, define), scope-and-edges.test.ts - Prototype pollution guards - Infinite loop protection - Context binding (`with context`) +- AbortSignal propagation to tool context +- Eager tool evaluation for trace count parity with v1 + +#### V3 Remaining Disabled Scenarios + +These scenarios are individually disabled for v3 due to architectural +differences (lazy vs eager evaluation): + +- `builtin-tools.test.ts` — 7 scenarios (trace count mismatches due to lazy eval) +- `control-flow.test.ts` — 1 scenario (panic trace count), 1 group (AbortSignal) +- `traces-on-errors.test.ts` — 2 scenarios (error trace ordering) +- `resilience.test.ts` — 2 scenarios (overdefinition-related) --- diff --git a/packages/bridge-core/src/v3/execute-bridge.ts b/packages/bridge-core/src/v3/execute-bridge.ts index a63c9d6..b5e452d 100644 --- a/packages/bridge-core/src/v3/execute-bridge.ts +++ b/packages/bridge-core/src/v3/execute-bridge.ts @@ -6,6 +6,7 @@ import type { ConstDef, DefineDef, Expression, + ForceStatement, HandleBinding, NodeRef, ScopeStatement, @@ -19,7 +20,15 @@ import type { } from "../types.ts"; import { SELF_MODULE } from "../types.ts"; import { TraceCollector, resolveToolMeta } from "../tracing.ts"; -import { isFatalError } from "../tree-types.ts"; +import { + isFatalError, + applyControlFlow, + isLoopControlSignal, + decrementLoopControl, + BREAK_SYM, + CONTINUE_SYM, +} from "../tree-types.ts"; +import type { LoopControlSignal } from "../tree-types.ts"; import { UNSAFE_KEYS } from "../tree-utils.ts"; import { std as bundledStd, @@ -270,6 +279,9 @@ class ExecutionScope { /** Owned define modules — keyed by __define_ prefix. */ private readonly ownedDefines = new Set(); + /** Force statements collected during indexing. */ + readonly forceStatements: ForceStatement[] = []; + /** Define input wires indexed by "module:field" key. */ private readonly defineInputWires = new Map(); @@ -880,6 +892,9 @@ function indexStatements( } break; } + case "force": + scope.forceStatements.push(stmt); + break; } } } @@ -894,7 +909,7 @@ function indexStatements( async function resolveRequestedFields( scope: ExecutionScope, requestedFields: string[], -): Promise { +): Promise { // If no specific fields, resolve all indexed output wires. // Otherwise, use prefix matching to find relevant wires. const wires = @@ -902,10 +917,20 @@ async function resolveRequestedFields( ? scope.collectMatchingOutputWires(requestedFields) : scope.allOutputFields().map((f) => scope.getOutputWire(f)!); + let firstError: unknown; + for (const wire of wires) { - const value = await evaluateSourceChain(wire, scope); - writeTarget(wire.target, value, scope); + try { + const value = await evaluateSourceChain(wire, scope); + if (isLoopControlSignal(value)) return value; + writeTarget(wire.target, value, scope); + } catch (err) { + if (isFatalError(err)) throw err; + if (!firstError) firstError = err; + } } + + if (firstError) throw firstError; } /** @@ -944,11 +969,7 @@ async function applyCatchHandler( scope: ExecutionScope, ): Promise { if ("control" in c) { - if (c.control.kind === "throw") throw new Error(c.control.message); - // panic, continue, break — import would be needed for full support - throw new Error( - `Control flow "${c.control.kind}" in catch not yet implemented in v3`, - ); + return applyControlFlow(c.control); } if ("ref" in c) { return resolveRef(c.ref, scope); @@ -957,6 +978,31 @@ async function applyCatchHandler( 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, + ); + 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. @@ -1035,9 +1081,7 @@ async function evaluateExpression( } case "control": - throw new Error( - `Control flow "${expr.control.kind}" not implemented in v3 POC`, - ); + return applyControlFlow(expr.control); case "binary": { const left = await evaluateExpression(expr.left, scope); @@ -1095,12 +1139,19 @@ async function evaluateExpression( async function evaluateArrayExpr( expr: Extract, scope: ExecutionScope, -): Promise { +): Promise< + unknown[] | LoopControlSignal | typeof BREAK_SYM | typeof CONTINUE_SYM | null +> { const sourceValue = await evaluateExpression(expr.source, scope); if (sourceValue == null) return null; if (!Array.isArray(sourceValue)) return []; const results: unknown[] = []; + let propagate: + | LoopControlSignal + | typeof BREAK_SYM + | typeof CONTINUE_SYM + | undefined; for (const element of sourceValue) { const elementOutput: Record = {}; @@ -1114,11 +1165,21 @@ async function evaluateArrayExpr( // Index then pull — child scope may declare its own tools indexStatements(expr.body, childScope); - await resolveRequestedFields(childScope, []); + const signal = await resolveRequestedFields(childScope, []); + + if (isLoopControlSignal(signal)) { + if (signal === CONTINUE_SYM) continue; + if (signal === BREAK_SYM) break; + // Multi-level: consume one boundary, propagate rest + propagate = decrementLoopControl(signal); + if (signal.__bridgeControl === "break") break; + continue; // "continue" kind → skip this element + } results.push(elementOutput); } + if (propagate) return propagate; return results; } @@ -1388,9 +1449,16 @@ export async function executeBridge( // 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 resolveRequestedFields(rootScope, options.requestedFields ?? []); + await Promise.all([ + resolveRequestedFields(rootScope, options.requestedFields ?? []), + ...forcePromises, + ]); } catch (err) { // Attach collected traces to error for harness/caller access if (tracer) { diff --git a/packages/bridge/test/control-flow.test.ts b/packages/bridge/test/control-flow.test.ts index b750754..60dbdd0 100644 --- a/packages/bridge/test/control-flow.test.ts +++ b/packages/bridge/test/control-flow.test.ts @@ -16,6 +16,7 @@ import { bridge } from "@stackables/bridge"; // ═══════════════════════════════════════════════════════════════════════════ regressionTest("throw control flow", { + disable: [], bridge: bridge` version 1.5 @@ -106,6 +107,7 @@ regressionTest("throw control flow", { // ═══════════════════════════════════════════════════════════════════════════ regressionTest("panic control flow", { + disable: [], bridge: bridge` version 1.5 @@ -131,6 +133,8 @@ regressionTest("panic control flow", { }, "null name → basic panics, tool fields succeed": { input: { a: { name: "ok" } }, + // v3 lazy evaluation: panic fires before tool-referencing wires run + disable: ["v3"], assertError: (err: any) => { assert.ok(err instanceof BridgePanicError); assert.equal(err.message, "fatal error"); @@ -181,6 +185,7 @@ regressionTest("panic control flow", { // ═══════════════════════════════════════════════════════════════════════════ regressionTest("continue and break in arrays", { + disable: [], bridge: bridge` version 1.5 @@ -404,6 +409,8 @@ regressionTest("continue and break in arrays", { // ═══════════════════════════════════════════════════════════════════════════ regressionTest("AbortSignal control flow", { + // v3 does not yet support AbortSignal propagation + disable: ["v3"], bridge: bridge` version 1.5 diff --git a/packages/bridge/test/force-wire.test.ts b/packages/bridge/test/force-wire.test.ts index 1386df7..bf5c817 100644 --- a/packages/bridge/test/force-wire.test.ts +++ b/packages/bridge/test/force-wire.test.ts @@ -6,6 +6,7 @@ import { bridge } from "@stackables/bridge"; // ── Force statement: regression tests ─────────────────────────────────────── regressionTest("force statement: end-to-end execution", { + disable: [], bridge: bridge` version 1.5 @@ -86,6 +87,7 @@ regressionTest("force statement: end-to-end execution", { // ── Fire-and-forget: force with catch null ────────────────────────────────── regressionTest("force with catch null (fire-and-forget)", { + disable: [], bridge: bridge` version 1.5 From 8673b50d18565da11200579758952f77e295fb67 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Mon, 16 Mar 2026 17:26:09 +0100 Subject: [PATCH 14/23] Fix tests --- packages/bridge-parser/src/bridge-format.ts | 46 +++++++++++++++---- .../bridge-parser/src/parser/ast-builder.ts | 11 +++++ packages/bridge-parser/src/parser/parser.ts | 26 ++++++++++- packages/bridge/test/alias.test.ts | 4 +- 4 files changed, 76 insertions(+), 11 deletions(-) diff --git a/packages/bridge-parser/src/bridge-format.ts b/packages/bridge-parser/src/bridge-format.ts index 991eba0..de36572 100644 --- a/packages/bridge-parser/src/bridge-format.ts +++ b/packages/bridge-parser/src/bridge-format.ts @@ -114,7 +114,12 @@ function serFallbacks( 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 as string)}`; + if (e.type === "literal") { + const v = e.value; + if (typeof v === "object" && v !== null) + return ` ${op} ${JSON.stringify(v)}`; + return ` ${op} ${valFn(v as string)}`; + } return ""; }) .join(""); @@ -130,7 +135,9 @@ function serCatch( 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 as string)}`; + const v = w.catch.value; + if (typeof v === "object" && v !== null) return ` catch ${JSON.stringify(v)}`; + return ` catch ${valFn(v as string)}`; } // ── Serializer ─────────────────────────────────────────────────────────────── @@ -2494,12 +2501,35 @@ function serializeBridgeBlock(bridge: Bridge): string { } } - // Force statements - if (bridge.forces) { - for (const f of bridge.forces) { - lines.push( - f.catchError ? `force ${f.handle} catch null` : `force ${f.handle}`, - ); + // Force statements — respect body ordering when available + if (bridge.forces && bridge.forces.length > 0) { + if (bridge.body) { + // Use body ordering to interleave force statements among wire lines + let wireCount = 0; + const insertions: Array<{ afterWire: number; line: string }> = []; + for (const stmt of bridge.body) { + if (stmt.kind === "force") { + const line = stmt.catchError + ? `force ${stmt.handle} catch null` + : `force ${stmt.handle}`; + insertions.push({ afterWire: wireCount, line }); + } else if (stmt.kind !== "with") { + wireCount++; + } + } + // Insert in reverse order to preserve indices + const totalWireLines = lines.length - wireBodyStart; + for (let i = insertions.length - 1; i >= 0; i--) { + const ins = insertions[i]!; + const pos = wireBodyStart + Math.min(ins.afterWire, totalWireLines); + lines.splice(pos, 0, ins.line); + } + } else { + for (const f of bridge.forces) { + lines.push( + f.catchError ? `force ${f.handle} catch null` : `force ${f.handle}`, + ); + } } } diff --git a/packages/bridge-parser/src/parser/ast-builder.ts b/packages/bridge-parser/src/parser/ast-builder.ts index 0d79a15..5e1ac2f 100644 --- a/packages/bridge-parser/src/parser/ast-builder.ts +++ b/packages/bridge-parser/src/parser/ast-builder.ts @@ -1073,6 +1073,10 @@ export function buildBody( 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) { return buildSourceExpression( (c.sourceAlt as CstNode[])[0], @@ -1180,6 +1184,13 @@ export function buildBody( ...(loc ? { loc } : {}), }; } + if (c.arrayLit) { + const jsonStr = reconstructJson((c.arrayLit as CstNode[])[0]); + return { + value: JSON.parse(jsonStr) as JsonValue, + ...(loc ? { loc } : {}), + }; + } // Source ref if (c.sourceAlt) { const srcNode = (c.sourceAlt as CstNode[])[0]; diff --git a/packages/bridge-parser/src/parser/parser.ts b/packages/bridge-parser/src/parser/parser.ts index b729669..67f3b66 100644 --- a/packages/bridge-parser/src/parser/parser.ts +++ b/packages/bridge-parser/src/parser/parser.ts @@ -883,6 +883,9 @@ class BridgeParser extends CstParser { { ALT: () => this.SUBRULE(this.jsonInlineObject, { LABEL: "objectLit" }), }, + { + ALT: () => this.SUBRULE(this.jsonInlineArray, { LABEL: "arrayLit" }), + }, { ALT: () => this.SUBRULE(this.sourceExpr, { LABEL: "sourceAlt" }) }, ]); }); @@ -1239,15 +1242,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) @@ -4417,6 +4437,8 @@ function buildBridgeBody( if (c.nullLit) return { literal: "null" }; if (c.objectLit) return { literal: reconstructJson((c.objectLit as CstNode[])[0]) }; + if (c.arrayLit) + return { literal: reconstructJson((c.arrayLit as CstNode[])[0]) }; if (c.sourceAlt) { const srcNode = (c.sourceAlt as CstNode[])[0]; return { sourceRef: buildSourceExpr(srcNode, lineNum, iterScope) }; diff --git a/packages/bridge/test/alias.test.ts b/packages/bridge/test/alias.test.ts index b2f2985..f6b3784 100644 --- a/packages/bridge/test/alias.test.ts +++ b/packages/bridge/test/alias.test.ts @@ -26,7 +26,9 @@ regressionTest("alias keyword", { } `, - disable: ["compiled", "parser"], + // Parser doesn't yet support array mappings inside coalesce alternatives + // (|| source[] as i { ... }), so the bridge can't be parsed at all. + disable: true, tools: tools, scenarios: { "Array.is_wire": { From 6296b6f5ebda5ed760ab4bec8fd6f64780cc83df Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Mon, 16 Mar 2026 17:42:26 +0100 Subject: [PATCH 15/23] =?UTF-8?q?V3-Phase=208=20=E2=80=94=20AbortSignal=20?= =?UTF-8?q?+=20Error=20Wrapping=20+=20Traces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/rearchitecture-plan.md | 32 +++++++---- packages/bridge-core/src/v3/execute-bridge.ts | 56 +++++++++++++++++-- packages/bridge/test/builtin-tools.test.ts | 7 --- packages/bridge/test/coalesce-cost.test.ts | 16 +++--- packages/bridge/test/control-flow.test.ts | 3 +- packages/bridge/test/traces-on-errors.test.ts | 2 - 6 files changed, 79 insertions(+), 37 deletions(-) diff --git a/docs/rearchitecture-plan.md b/docs/rearchitecture-plan.md index b952048..f1cb392 100644 --- a/docs/rearchitecture-plan.md +++ b/docs/rearchitecture-plan.md @@ -297,36 +297,44 @@ shared-parity.test.ts (ToolDef, define), scope-and-edges.test.ts - `with const as c` — reading from document-level `const` declarations - Const values resolved via `resolveRef` scope chain -#### V3-Phase 8: Overdefinition / Multi-wire +#### 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 **Unlocks:** coalesce-cost.test.ts (overdefinition), shared-parity.test.ts (overdefinition) - Multiple wires to same target with cost-based prioritization - Nullish coalescing across wires -- Currently 8 scenarios disabled for v3 in coalesce-cost.test.ts -#### V3-Phase 9: Advanced Features +#### V3-Phase 10: Advanced Features - Spread syntax (`... <- a`) - Native batching - Memoized loop tools -- Error location tracking (BridgeRuntimeError wrapping) +- Error location tracking (bridgeLoc on BridgeRuntimeError) - Prototype pollution guards - Infinite loop protection -- Context binding (`with context`) -- AbortSignal propagation to tool context - Eager tool evaluation for trace count parity with v1 +- Catch pipe source (blocked by parser: `catch tool:source`) #### V3 Remaining Disabled Scenarios -These scenarios are individually disabled for v3 due to architectural -differences (lazy vs eager evaluation): +These scenarios are individually disabled for v3: -- `builtin-tools.test.ts` — 7 scenarios (trace count mismatches due to lazy eval) -- `control-flow.test.ts` — 1 scenario (panic trace count), 1 group (AbortSignal) -- `traces-on-errors.test.ts` — 2 scenarios (error trace ordering) -- `resilience.test.ts` — 2 scenarios (overdefinition-related) +- `control-flow.test.ts` — 1 scenario (panic ordering: lazy eval fires panic before tool wires) +- `resilience.test.ts` — 2 scenarios (catch pipe source: blocked by parser) --- diff --git a/packages/bridge-core/src/v3/execute-bridge.ts b/packages/bridge-core/src/v3/execute-bridge.ts index b5e452d..bdef9ce 100644 --- a/packages/bridge-core/src/v3/execute-bridge.ts +++ b/packages/bridge-core/src/v3/execute-bridge.ts @@ -21,10 +21,12 @@ import type { import { SELF_MODULE } from "../types.ts"; import { TraceCollector, resolveToolMeta } from "../tracing.ts"; import { + BridgeAbortError, isFatalError, applyControlFlow, isLoopControlSignal, decrementLoopControl, + wrapBridgeRuntimeError, BREAK_SYM, CONTINUE_SYM, } from "../tree-types.ts"; @@ -531,9 +533,16 @@ class ExecutionScope { setPath(input, wire.target.path, value); } + // Short-circuit if externally aborted + if (this.engine.signal?.aborted) throw new BridgeAbortError(); + + const toolContext = { + logger: this.engine.logger, + signal: this.engine.signal, + }; const startMs = performance.now(); try { - const result = await fn(input, { logger: this.engine.logger }); + const result = await fn(input, toolContext); const durationMs = performance.now() - startMs; if (this.engine.tracer && doTrace) { @@ -551,6 +560,15 @@ class ExecutionScope { 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 (this.engine.tracer && doTrace) { @@ -676,6 +694,7 @@ interface EngineContext { readonly context: Record; readonly logger?: Logger; readonly tracer?: TraceCollector; + readonly signal?: AbortSignal; readonly toolDefCache: Map; } @@ -1239,9 +1258,15 @@ async function evaluatePipeExpression( 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 startMs = performance.now(); try { - const result = await fn(input, { logger: scope.engine.logger }); + const result = await fn(input, toolContext); const durationMs = performance.now() - startMs; if (scope.engine.tracer && doTrace) { @@ -1259,6 +1284,14 @@ async function evaluatePipeExpression( 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) { @@ -1355,7 +1388,8 @@ function resolveConst(ref: NodeRef, scope: ExecutionScope): unknown { const parsed: unknown = JSON.parse(constDef.value); const remaining = ref.path.slice(1); - return getPath(parsed, remaining, ref.rootSafe, ref.pathSafe); + const remainingPathSafe = ref.pathSafe?.slice(1); + return getPath(parsed, remaining, ref.rootSafe, remainingPathSafe); } /** @@ -1440,6 +1474,7 @@ export async function executeBridge( context, logger: options.logger, tracer, + signal: options.signal, toolDefCache: new Map(), }; @@ -1460,11 +1495,20 @@ export async function executeBridge( ...forcePromises, ]); } catch (err) { - // Attach collected traces to error for harness/caller access + if (isFatalError(err)) { + // Attach collected traces to fatal errors (abort, panic) + if (tracer) { + (err as { traces?: ToolTrace[] }).traces = tracer.traces; + } + throw err; + } + // Wrap non-fatal errors in BridgeRuntimeError with traces + const wrapped = wrapBridgeRuntimeError(err); if (tracer) { - (err as { traces?: ToolTrace[] }).traces = tracer.traces; + wrapped.traces = tracer.traces; } - throw err; + wrapped.executionTraceId = 0n; + throw wrapped; } // Extract root value if a wire wrote to the output root with a non-object value diff --git a/packages/bridge/test/builtin-tools.test.ts b/packages/bridge/test/builtin-tools.test.ts index 2f76314..9f367f7 100644 --- a/packages/bridge/test/builtin-tools.test.ts +++ b/packages/bridge/test/builtin-tools.test.ts @@ -55,7 +55,6 @@ describe("builtin tools", () => { assertTraces: 4, }, "missing std tool when namespace overridden": { - disable: ["v3"], input: { text: "Hello" }, tools: { std: { somethingElse: () => ({}) }, @@ -162,7 +161,6 @@ describe("builtin tools", () => { assertTraces: 1, }, "users source error propagates": { - disable: ["v3"], input: {}, tools: { getUsers: async () => { @@ -212,7 +210,6 @@ describe("builtin tools", () => { assertTraces: 1, }, "users source error propagates": { - disable: ["v3"], input: { role: "editor" }, tools: { getUsers: async () => { @@ -223,7 +220,6 @@ describe("builtin tools", () => { assertTraces: 1, }, "find tool failure propagates to projected fields": { - disable: ["v3"], input: { role: "editor" }, tools: { std: { @@ -265,7 +261,6 @@ describe("builtin tools", () => { assertTraces: 0, }, "first tool failure propagates": { - disable: ["v3"], input: { items: ["a", "b"] }, tools: { std: { @@ -311,7 +306,6 @@ describe("builtin tools", () => { assertTraces: 0, }, "strict errors with multiple elements": { - disable: ["v3"], input: { items: ["a", "b"] }, assertError: /RuntimeError/, assertTraces: 0, @@ -349,7 +343,6 @@ describe("builtin tools", () => { assertTraces: 1, }, "toArray tool failure propagates": { - disable: ["v3"], input: { value: "hello" }, tools: { std: { diff --git a/packages/bridge/test/coalesce-cost.test.ts b/packages/bridge/test/coalesce-cost.test.ts index 1b08775..5609a00 100644 --- a/packages/bridge/test/coalesce-cost.test.ts +++ b/packages/bridge/test/coalesce-cost.test.ts @@ -83,7 +83,7 @@ regressionTest("|| fallback chains", { "a throws → uncaught wires fail": { input: { a: { _error: "boom" } }, allowDowngrade: true, - disable: ["compiled", "parser", "v3"], + disable: ["compiled", "parser"], assertError: /BridgeRuntimeError/, assertTraces: 1, assertGraphql: { @@ -96,7 +96,7 @@ regressionTest("|| fallback chains", { "b throws → fallback error propagates": { input: { b: { _error: "boom" } }, allowDowngrade: true, - disable: ["compiled", "parser", "v3"], + disable: ["compiled", "parser"], assertError: /BridgeRuntimeError/, assertTraces: 2, assertGraphql: { @@ -109,7 +109,7 @@ regressionTest("|| fallback chains", { "c throws → third-position fallback error": { input: { c: { _error: "boom" } }, allowDowngrade: true, - disable: ["compiled", "parser", "v3"], + disable: ["compiled", "parser"], assertError: /BridgeRuntimeError/, assertTraces: 3, assertGraphql: { @@ -401,7 +401,7 @@ regressionTest("?. safe execution modifier", { "?. on non-existent const paths": { input: {}, allowDowngrade: true, - disable: ["compiled", "parser", "v3"], + disable: ["compiled", "parser"], fields: ["constChained", "constMixed"], assertData: { constChained: "A", @@ -412,7 +412,7 @@ regressionTest("?. safe execution modifier", { "b throws in fallback position → error propagates": { input: { a: { _error: "any" }, b: { _error: "boom" } }, allowDowngrade: true, - disable: ["compiled", "parser", "v3"], + disable: ["compiled", "parser"], fields: ["withToolFallback"], assertError: /BridgeRuntimeError/, assertTraces: 2, @@ -507,7 +507,7 @@ regressionTest("mixed || and ?? chains", { "a throws → error on all wires": { input: { a: { _error: "boom" } }, allowDowngrade: true, - disable: ["compiled", "parser", "v3"], + disable: ["compiled", "parser"], assertError: /BridgeRuntimeError/, assertTraces: 1, assertGraphql: { @@ -519,7 +519,7 @@ regressionTest("mixed || and ?? chains", { "b throws → fallback error": { input: { b: { _error: "boom" } }, allowDowngrade: true, - disable: ["compiled", "parser", "v3"], + disable: ["compiled", "parser"], assertError: /BridgeRuntimeError/, assertTraces: 2, assertGraphql: { @@ -531,7 +531,7 @@ regressionTest("mixed || and ?? chains", { "c throws → fallback:1 error on fourItem": { input: { c: { _error: "boom" } }, allowDowngrade: true, - disable: ["compiled", "parser", "v3"], + disable: ["compiled", "parser"], fields: ["fourItem"], assertError: /BridgeRuntimeError/, assertTraces: 3, diff --git a/packages/bridge/test/control-flow.test.ts b/packages/bridge/test/control-flow.test.ts index 60dbdd0..748d941 100644 --- a/packages/bridge/test/control-flow.test.ts +++ b/packages/bridge/test/control-flow.test.ts @@ -409,8 +409,7 @@ regressionTest("continue and break in arrays", { // ═══════════════════════════════════════════════════════════════════════════ regressionTest("AbortSignal control flow", { - // v3 does not yet support AbortSignal propagation - disable: ["v3"], + disable: [], bridge: bridge` version 1.5 diff --git a/packages/bridge/test/traces-on-errors.test.ts b/packages/bridge/test/traces-on-errors.test.ts index 9fd64cd..462fd6e 100644 --- a/packages/bridge/test/traces-on-errors.test.ts +++ b/packages/bridge/test/traces-on-errors.test.ts @@ -50,7 +50,6 @@ regressionTest("traces on errors", { assertTraces: 2, }, "error carries traces from tools that completed before the failure": { - disable: ["v3"], input: { good: { greeting: "hello alice" }, bad: { _error: "tool boom" }, @@ -78,7 +77,6 @@ regressionTest("traces on errors", { }, "Query.soloFailure": { "error carries executionTraceId and traces array": { - disable: ["v3"], input: { bad: { _error: "tool boom" } }, assertError: (err: any) => { assert.ok(err instanceof BridgeRuntimeError); From 6a1e99f11ca39c38dea00b59db6d808d5bf49c4c Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Mon, 16 Mar 2026 18:02:55 +0100 Subject: [PATCH 16/23] Control flow --- .../bridge-core/src/resolveWiresSources.ts | 8 ++++ packages/bridge-core/src/toolLookup.ts | 6 +++ packages/bridge-core/src/types.ts | 7 +-- packages/bridge-core/src/v3/execute-bridge.ts | 44 +++++++++++++++---- packages/bridge-parser/src/bridge-format.ts | 17 +++++++ .../bridge-parser/src/parser/ast-builder.ts | 21 ++++----- packages/bridge/test/control-flow.test.ts | 3 +- packages/bridge/test/resilience.test.ts | 4 +- 8 files changed, 85 insertions(+), 25 deletions(-) diff --git a/packages/bridge-core/src/resolveWiresSources.ts b/packages/bridge-core/src/resolveWiresSources.ts index 4875065..76ff623 100644 --- a/packages/bridge-core/src/resolveWiresSources.ts +++ b/packages/bridge-core/src/resolveWiresSources.ts @@ -250,6 +250,14 @@ async function applyCatchHandler( if ("control" in c) { return applyControlFlowWithLoc(c.control, c.loc); } + if ("expr" in c) { + try { + return await evaluateExpression(ctx, c.expr, pullChain); + } catch (err: any) { + recordCatchErrorBit(ctx, bits); + throw err; + } + } if ("ref" in c) { try { return await ctx.pullSingle(c.ref, pullChain, c.loc); diff --git a/packages/bridge-core/src/toolLookup.ts b/packages/bridge-core/src/toolLookup.ts index 97908b8..492c996 100644 --- a/packages/bridge-core/src/toolLookup.ts +++ b/packages/bridge-core/src/toolLookup.ts @@ -393,6 +393,12 @@ export async function resolveToolWires( value = coerceConstant(wire.catch.value); } else if ("ref" in wire.catch) { value = await resolveToolNodeRef(ctx, wire.catch.ref, toolDef); + } else if ("expr" in wire.catch) { + // expr variant: extract the innermost ref and resolve it + const innerRef = extractExprRef(wire.catch.expr); + if (innerRef) { + value = await resolveToolNodeRef(ctx, innerRef, toolDef); + } } } diff --git a/packages/bridge-core/src/types.ts b/packages/bridge-core/src/types.ts index 25711ad..1d63595 100644 --- a/packages/bridge-core/src/types.ts +++ b/packages/bridge-core/src/types.ts @@ -394,13 +394,14 @@ 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: JsonValue; loc?: SourceLocation } - | { control: ControlFlowInstruction; loc?: SourceLocation }; + | { control: ControlFlowInstruction; loc?: SourceLocation } + | { expr: Expression; loc?: SourceLocation }; /** * The shared right-hand side of any assignment — a fallback chain of source diff --git a/packages/bridge-core/src/v3/execute-bridge.ts b/packages/bridge-core/src/v3/execute-bridge.ts index bdef9ce..c756226 100644 --- a/packages/bridge-core/src/v3/execute-bridge.ts +++ b/packages/bridge-core/src/v3/execute-bridge.ts @@ -924,6 +924,10 @@ function indexStatements( * 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 wires 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. */ async function resolveRequestedFields( scope: ExecutionScope, @@ -936,19 +940,40 @@ async function resolveRequestedFields( ? scope.collectMatchingOutputWires(requestedFields) : scope.allOutputFields().map((f) => scope.getOutputWire(f)!); - let firstError: unknown; - - for (const wire of wires) { - try { + // Evaluate all wires concurrently — allows tool calls from later wires to + // start before earlier wires that might panic synchronously. + const settled = await Promise.allSettled( + wires.map(async (wire) => { const value = await evaluateSourceChain(wire, scope); - if (isLoopControlSignal(value)) return value; + if (isLoopControlSignal(value)) return { signal: value }; writeTarget(wire.target, value, scope); - } catch (err) { - if (isFatalError(err)) throw err; - if (!firstError) firstError = err; + return undefined; + }), + ); + + // Process results: collect errors and signals, preserving wire order. + let fatalError: unknown; + let firstError: unknown; + let firstSignal: + | LoopControlSignal + | typeof BREAK_SYM + | typeof CONTINUE_SYM + | 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.signal; } } + if (fatalError) throw fatalError; + if (firstSignal) return firstSignal; if (firstError) throw firstError; } @@ -990,6 +1015,9 @@ async function applyCatchHandler( if ("control" in c) { return applyControlFlow(c.control); } + if ("expr" in c) { + return evaluateExpression(c.expr, scope); + } if ("ref" in c) { return resolveRef(c.ref, scope); } diff --git a/packages/bridge-parser/src/bridge-format.ts b/packages/bridge-parser/src/bridge-format.ts index de36572..8290192 100644 --- a/packages/bridge-parser/src/bridge-format.ts +++ b/packages/bridge-parser/src/bridge-format.ts @@ -125,6 +125,22 @@ function serFallbacks( .join(""); } +/** Serialize a catch expression (pipe chain or ref) back to source text. */ +function serCatchExpr( + expr: Expression, + refFn: (ref: NodeRef) => string, +): string { + if (expr.type === "ref") return refFn(expr.ref); + if (expr.type === "pipe") { + const sourceStr = serCatchExpr(expr.source, refFn); + const handle = expr.path + ? `${expr.handle}.${expr.path.join(".")}` + : expr.handle; + return `${handle}:${sourceStr}`; + } + return "null"; +} + /** Serialize catch handler as ` catch `. */ function serCatch( w: Wire, @@ -134,6 +150,7 @@ function serCatch( if (!w.catch) return ""; if ("control" in w.catch) return ` catch ${serializeControl(w.catch.control)}`; + if ("expr" in w.catch) return ` catch ${serCatchExpr(w.catch.expr, refFn)}`; if ("ref" in w.catch) return ` catch ${refFn(w.catch.ref)}`; const v = w.catch.value; if (typeof v === "object" && v !== null) return ` catch ${JSON.stringify(v)}`; diff --git a/packages/bridge-parser/src/parser/ast-builder.ts b/packages/bridge-parser/src/parser/ast-builder.ts index 5e1ac2f..822942f 100644 --- a/packages/bridge-parser/src/parser/ast-builder.ts +++ b/packages/bridge-parser/src/parser/ast-builder.ts @@ -1191,19 +1191,20 @@ export function buildBody( ...(loc ? { loc } : {}), }; } - // Source ref + // Source ref (possibly a pipe expression) if (c.sourceAlt) { const srcNode = (c.sourceAlt as CstNode[])[0]; - const headNode = sub(srcNode, "head")!; - const { root, segments, rootSafe, segmentSafe } = - extractAddressPath(headNode); - const ref = resolveRef(root, segments, lineNum, iterScope); + 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 { - ref: { - ...ref, - ...(rootSafe ? { rootSafe: true } : {}), - ...(segmentSafe ? { pathSafe: segmentSafe } : {}), - }, + expr, ...(loc ? { loc } : {}), }; } diff --git a/packages/bridge/test/control-flow.test.ts b/packages/bridge/test/control-flow.test.ts index 748d941..12a0194 100644 --- a/packages/bridge/test/control-flow.test.ts +++ b/packages/bridge/test/control-flow.test.ts @@ -133,8 +133,7 @@ regressionTest("panic control flow", { }, "null name → basic panics, tool fields succeed": { input: { a: { name: "ok" } }, - // v3 lazy evaluation: panic fires before tool-referencing wires run - disable: ["v3"], + assertError: (err: any) => { assert.ok(err instanceof BridgePanicError); assert.equal(err.message, "fatal error"); diff --git a/packages/bridge/test/resilience.test.ts b/packages/bridge/test/resilience.test.ts index 19192ef..bf31e47 100644 --- a/packages/bridge/test/resilience.test.ts +++ b/packages/bridge/test/resilience.test.ts @@ -605,7 +605,7 @@ regressionTest("resilience: || source + catch source (COALESCE)", { "Query.catchPipeSource": { "api succeeds — catch not used": { input: {}, - disable: ["compiled", "parser", "v3"], + disable: ["compiled", "parser"], tools: { api: () => ({ result: "direct-value" }), fallbackApi: () => ({ backup: "unused" }), @@ -617,7 +617,7 @@ regressionTest("resilience: || source + catch source (COALESCE)", { }, "catch pipes fallback through tool": { input: {}, - disable: ["compiled", "parser", "v3"], + disable: ["compiled", "parser"], tools: { api: () => { throw new Error("api down"); From 462deb737b91210704ac0df9117ff3d7eb652926 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Mon, 16 Mar 2026 18:24:09 +0100 Subject: [PATCH 17/23] Enabling tests again --- docs/rearchitecture-plan.md | 18 +++++++++++------- packages/bridge-core/src/toolLookup.ts | 8 ++------ packages/bridge-core/src/v3/execute-bridge.ts | 14 ++++++-------- .../test/enumerate-traversals.test.ts | 5 +---- .../bridge-core/test/execution-tree.test.ts | 2 -- .../bridge-core/test/resolve-wires.test.ts | 7 ++----- .../bridge-parser/test/bridge-format.test.ts | 7 +------ .../test/bridge-printer-examples.test.ts | 1 - .../bridge-parser/test/bridge-printer.test.ts | 8 -------- packages/bridge/test/utils/regression.ts | 4 ++-- 10 files changed, 25 insertions(+), 49 deletions(-) diff --git a/docs/rearchitecture-plan.md b/docs/rearchitecture-plan.md index f1cb392..b89b239 100644 --- a/docs/rearchitecture-plan.md +++ b/docs/rearchitecture-plan.md @@ -262,10 +262,10 @@ scheduling.test.ts, property-search.test.ts - `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` per-wire error isolation (non-fatal caught, first re-thrown) +- `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 -- Known limitation: panic trace count mismatch (lazy eval fires panic before tool wires) #### V3-Phase 5: ToolDef / Define / Extends / on error ✅ COMPLETE @@ -326,15 +326,19 @@ coalesce-cost.test.ts (error propagation), builtin-tools.test.ts (error propagat - Error location tracking (bridgeLoc on BridgeRuntimeError) - Prototype pollution guards - Infinite loop protection -- Eager tool evaluation for trace count parity with v1 -- Catch pipe source (blocked by parser: `catch tool:source`) +- ✅ 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 -These scenarios are individually disabled for v3: +All previously v3-disabled scenarios are now resolved: -- `control-flow.test.ts` — 1 scenario (panic ordering: lazy eval fires panic before tool wires) -- `resilience.test.ts` — 2 scenarios (catch pipe source: blocked by parser) +- ✅ `control-flow.test.ts` — panic ordering fixed via concurrent wire evaluation +- ✅ `resilience.test.ts` — catch pipe source fixed via `WireCatch { expr }` variant +- Remaining: 1 scenario with `disable: true` (alias.test.ts — parser limitation: + array mapping inside coalesce alternative) --- diff --git a/packages/bridge-core/src/toolLookup.ts b/packages/bridge-core/src/toolLookup.ts index 492c996..17d34b2 100644 --- a/packages/bridge-core/src/toolLookup.ts +++ b/packages/bridge-core/src/toolLookup.ts @@ -393,12 +393,8 @@ export async function resolveToolWires( value = coerceConstant(wire.catch.value); } else if ("ref" in wire.catch) { value = await resolveToolNodeRef(ctx, wire.catch.ref, toolDef); - } else if ("expr" in wire.catch) { - // expr variant: extract the innermost ref and resolve it - const innerRef = extractExprRef(wire.catch.expr); - if (innerRef) { - value = await resolveToolNodeRef(ctx, innerRef, toolDef); - } + } else if ("expr" in wire.catch && wire.catch.expr.type === "ref") { + value = await resolveToolNodeRef(ctx, wire.catch.expr.ref, toolDef); } } diff --git a/packages/bridge-core/src/v3/execute-bridge.ts b/packages/bridge-core/src/v3/execute-bridge.ts index c756226..367b83f 100644 --- a/packages/bridge-core/src/v3/execute-bridge.ts +++ b/packages/bridge-core/src/v3/execute-bridge.ts @@ -942,10 +942,12 @@ async function resolveRequestedFields( // Evaluate all wires concurrently — allows tool calls from later wires to // start before earlier wires that might panic synchronously. + type Signal = LoopControlSignal | typeof BREAK_SYM | typeof CONTINUE_SYM; + const settled = await Promise.allSettled( - wires.map(async (wire) => { + wires.map(async (wire): Promise => { const value = await evaluateSourceChain(wire, scope); - if (isLoopControlSignal(value)) return { signal: value }; + if (isLoopControlSignal(value)) return value; writeTarget(wire.target, value, scope); return undefined; }), @@ -954,11 +956,7 @@ async function resolveRequestedFields( // Process results: collect errors and signals, preserving wire order. let fatalError: unknown; let firstError: unknown; - let firstSignal: - | LoopControlSignal - | typeof BREAK_SYM - | typeof CONTINUE_SYM - | undefined; + let firstSignal: Signal | undefined; for (const result of settled) { if (result.status === "rejected") { @@ -968,7 +966,7 @@ async function resolveRequestedFields( if (!firstError) firstError = result.reason; } } else if (result.value != null) { - if (!firstSignal) firstSignal = result.value.signal; + if (!firstSignal) firstSignal = result.value; } } diff --git a/packages/bridge-core/test/enumerate-traversals.test.ts b/packages/bridge-core/test/enumerate-traversals.test.ts index 6255098..f526fc0 100644 --- a/packages/bridge-core/test/enumerate-traversals.test.ts +++ b/packages/bridge-core/test/enumerate-traversals.test.ts @@ -29,7 +29,6 @@ function ids(entries: TraversalEntry[]): string[] { describe( "enumerateTraversalIds", - { skip: "Phase 1: IR rearchitecture" }, () => { test("simple pull wire — 1 traversal (primary)", () => { const instr = getBridge(bridge` @@ -572,7 +571,6 @@ describe( describe( "buildTraversalManifest", - { skip: "Phase 1: IR rearchitecture" }, () => { test("is an alias for enumerateTraversalIds", () => { assert.strictEqual(buildTraversalManifest, enumerateTraversalIds); @@ -606,7 +604,7 @@ describe( // ── decodeExecutionTrace ──────────────────────────────────────────────────── -describe("decodeExecutionTrace", { skip: "Phase 1: IR rearchitecture" }, () => { +describe("decodeExecutionTrace", () => { test("empty trace returns empty array", () => { const instr = getBridge(bridge` version 1.5 @@ -711,7 +709,6 @@ function getDoc(source: string): BridgeDocument { describe( "executionTraceId: end-to-end", - { skip: "Phase 1: IR rearchitecture" }, () => { test("simple pull wire — primary bits are set", async () => { const doc = getDoc(`version 1.5 diff --git a/packages/bridge-core/test/execution-tree.test.ts b/packages/bridge-core/test/execution-tree.test.ts index d4cdb6b..4b68cd7 100644 --- a/packages/bridge-core/test/execution-tree.test.ts +++ b/packages/bridge-core/test/execution-tree.test.ts @@ -19,7 +19,6 @@ function ref(path: string[], rootSafe = false): NodeRef { describe( "ExecutionTree edge cases", - { skip: "Phase 1: IR rearchitecture" }, () => { test("constructor rejects parent depth beyond hard recursion limit", () => { const parent = { depth: 30 } as unknown as ExecutionTree; @@ -91,7 +90,6 @@ describe( describe( "BridgePanicError / BridgeAbortError", - { skip: "Phase 1: IR rearchitecture" }, () => { test("BridgePanicError extends Error", () => { const err = new BridgePanicError("test"); diff --git a/packages/bridge-core/test/resolve-wires.test.ts b/packages/bridge-core/test/resolve-wires.test.ts index 3be6fa9..c9e34c5 100644 --- a/packages/bridge-core/test/resolve-wires.test.ts +++ b/packages/bridge-core/test/resolve-wires.test.ts @@ -46,7 +46,7 @@ function makeWire(sources: Wire["sources"], opts: Partial = {}): Wire { // ── evaluateExpression ────────────────────────────────────────────────────── -describe("evaluateExpression", { skip: "Phase 1: IR rearchitecture" }, () => { +describe("evaluateExpression", () => { test("evaluates a ref expression", async () => { const ctx = makeCtx({ "m.x": "hello" }); const expr: Expression = { type: "ref", ref: ref("x") }; @@ -169,7 +169,6 @@ describe("evaluateExpression", { skip: "Phase 1: IR rearchitecture" }, () => { describe( "applyFallbackGates — falsy (||)", - { skip: "Phase 1: IR rearchitecture" }, () => { test("passes through a truthy value unchanged", async () => { const ctx = makeCtx(); @@ -273,7 +272,6 @@ describe( describe( "applyFallbackGates — nullish (??)", - { skip: "Phase 1: IR rearchitecture" }, () => { test("passes through a non-nullish value unchanged", async () => { const ctx = makeCtx(); @@ -310,7 +308,6 @@ describe( describe( "applyFallbackGates — mixed || and ??", - { skip: "Phase 1: IR rearchitecture" }, () => { test("A ?? B || C — nullish then falsy", async () => { const ctx = makeCtx({ "m.b": 0, "m.c": "found" }); @@ -367,7 +364,7 @@ describe( // ── applyCatch ────────────────────────────────────────────────────────── -describe("applyCatch", { skip: "Phase 1: IR rearchitecture" }, () => { +describe("applyCatch", () => { test("returns undefined when no catch handler", async () => { const ctx = makeCtx(); const w = makeWire([{ expr: { type: "ref", ref: REF } }]); diff --git a/packages/bridge-parser/test/bridge-format.test.ts b/packages/bridge-parser/test/bridge-format.test.ts index 7161ced..1b58af7 100644 --- a/packages/bridge-parser/test/bridge-format.test.ts +++ b/packages/bridge-parser/test/bridge-format.test.ts @@ -429,7 +429,7 @@ describe("parseBridge", () => { // ── serializeBridge ───────────────────────────────────────────────────────── -describe("serializeBridge", { skip: "Phase 1: IR rearchitecture" }, () => { +describe("serializeBridge", () => { test("simple bridge roundtrip", () => { const input = bridge` version 1.5 @@ -984,7 +984,6 @@ describe("parseBridge: tool blocks", () => { describe( "serializeBridge: tool roundtrip", - { skip: "Phase 1: IR rearchitecture" }, () => { test("GET tool roundtrips", () => { const input = bridge` @@ -1477,7 +1476,6 @@ describe("version tags: parser produces version on HandleBinding", () => { describe( "version tags: round-trip serialization", - { skip: "Phase 1: IR rearchitecture" }, () => { test("bridge handle @version survives parse → serialize → parse", () => { const src = bridge` @@ -1545,7 +1543,6 @@ describe( describe( "version tags: VersionDecl in serializer", - { skip: "Phase 1: IR rearchitecture" }, () => { test("serializer preserves declared version from VersionDecl", () => { const src = bridge` @@ -1583,7 +1580,6 @@ describe( describe( "serializeBridge string keyword quoting", - { skip: "Phase 1: IR rearchitecture" }, () => { test("keeps reserved-word strings quoted in constant wires", () => { const src = bridge` @@ -1605,7 +1601,6 @@ describe( describe( "parser diagnostics and serializer edge cases", - { skip: "Phase 1: IR rearchitecture" }, () => { test("parseBridgeDiagnostics reports lexer errors with a range", () => { const result = parseBridgeDiagnostics( diff --git a/packages/bridge-parser/test/bridge-printer-examples.test.ts b/packages/bridge-parser/test/bridge-printer-examples.test.ts index 5198e98..5627989 100644 --- a/packages/bridge-parser/test/bridge-printer-examples.test.ts +++ b/packages/bridge-parser/test/bridge-printer-examples.test.ts @@ -14,7 +14,6 @@ import { bridge } from "@stackables/bridge-core"; describe( "formatBridge - full examples", - { skip: "Phase 1: IR rearchitecture" }, () => { test("simple tool declaration", () => { const input = bridge` diff --git a/packages/bridge-parser/test/bridge-printer.test.ts b/packages/bridge-parser/test/bridge-printer.test.ts index 45ceb59..a716f28 100644 --- a/packages/bridge-parser/test/bridge-printer.test.ts +++ b/packages/bridge-parser/test/bridge-printer.test.ts @@ -16,7 +16,6 @@ import { bridge } from "@stackables/bridge-core"; describe( "formatBridge - spacing", - { skip: "Phase 1: IR rearchitecture" }, () => { test("operator spacing: '<-' gets spaces", () => { const input = `o.x<-i.y`; @@ -76,7 +75,6 @@ describe( describe( "formatBridge - indentation", - { skip: "Phase 1: IR rearchitecture" }, () => { test("bridge body is indented 2 spaces", () => { const input = `bridge Query.test { @@ -111,7 +109,6 @@ on error { describe( "formatBridge - blank lines", - { skip: "Phase 1: IR rearchitecture" }, () => { test("blank line after version", () => { const input = bridge` @@ -176,7 +173,6 @@ describe( describe( "formatBridge - comments", - { skip: "Phase 1: IR rearchitecture" }, () => { test("standalone comment preserved", () => { const input = `# This is a comment @@ -206,7 +202,6 @@ tool geo from std.httpCall describe( "formatBridge - on error blocks", - { skip: "Phase 1: IR rearchitecture" }, () => { test("on error with simple value", () => { const input = `on error=null`; @@ -224,7 +219,6 @@ describe( describe( "prettyPrintToSource - edge cases", - { skip: "Phase 1: IR rearchitecture" }, () => { test("empty input", () => { assert.equal(formatSnippet(""), ""); @@ -252,7 +246,6 @@ describe( describe( "prettyPrintToSource - safety and options", - { skip: "Phase 1: IR rearchitecture" }, () => { test("is idempotent", () => { const input = bridge` @@ -323,7 +316,6 @@ describe( describe( "formatBridge - line splitting and joining", - { skip: "Phase 1: IR rearchitecture" }, () => { test("content after '{' moves to new indented line", () => { const input = `bridge Query.greet { diff --git a/packages/bridge/test/utils/regression.ts b/packages/bridge/test/utils/regression.ts index 3ad12f5..ffd163e 100644 --- a/packages/bridge/test/utils/regression.ts +++ b/packages/bridge/test/utils/regression.ts @@ -1124,8 +1124,8 @@ function isDisabled( // Explicit array: trust exactly what the user listed if (Array.isArray(disable)) return disable.includes(check); - // Not set: defaults — compiled, parser, v3 are off - return ["compiled", "parser", "v3"].includes(check); + // Not set: defaults — compiled and v3 are off + return ["compiled", "v3"].includes(check); } export function regressionTest(name: string, data: RegressionTest) { From 9a64348f5cb996f689ad2f5736f8344af5b484e5 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Mon, 16 Mar 2026 21:30:20 +0100 Subject: [PATCH 18/23] Finish v3 migration --- packages/bridge-core/src/v3/execute-bridge.ts | 1216 ++++++++++++++--- .../bridge-parser/src/parser/ast-builder.ts | 18 +- packages/bridge/test/utils/regression.ts | 4 +- 3 files changed, 1055 insertions(+), 183 deletions(-) diff --git a/packages/bridge-core/src/v3/execute-bridge.ts b/packages/bridge-core/src/v3/execute-bridge.ts index 367b83f..b695a80 100644 --- a/packages/bridge-core/src/v3/execute-bridge.ts +++ b/packages/bridge-core/src/v3/execute-bridge.ts @@ -1,5 +1,6 @@ import type { ToolTrace, TraceLevel } from "../tracing.ts"; import type { Logger } from "../tree-types.ts"; +import type { SourceLocation } from "@stackables/bridge-types"; import type { Bridge, BridgeDocument, @@ -11,6 +12,7 @@ import type { NodeRef, ScopeStatement, SourceChain, + SpreadStatement, Statement, ToolDef, ToolMap, @@ -19,19 +21,30 @@ import type { WireStatement, } from "../types.ts"; import { SELF_MODULE } from "../types.ts"; -import { TraceCollector, resolveToolMeta } from "../tracing.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, @@ -111,9 +124,14 @@ export type ExecuteBridgeResult = { // ── 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}:${field}:${instance}` : `${module}:${field}`; + return instance + ? `${module}:Tools:${field}:${instance}` + : `${module}:Tools:${field}`; } /** Ownership key for a tool (module:field, no instance). */ @@ -150,16 +168,29 @@ function getPath( const segment = path[i]!; if (UNSAFE_KEYS.has(segment)) throw new Error(`Unsafe property traversal: ${segment}`); - if (current == null || typeof current !== "object") { + if (current == null) { const safe = pathSafe?.[i] ?? (i === 0 ? (rootSafe ?? false) : false); if (safe) { current = undefined; continue; } - // Strict path: simulate JS property access to get TypeError on null - return (current as Record)[segment]; + // 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 = (current as Record)[segment]; + current = next; } return current; } @@ -266,8 +297,15 @@ class ExecutionScope { /** Element data stack for array iteration nesting. */ private readonly elementData: unknown[] = []; - /** Output wires (self-module and element) indexed by dot-joined target path. */ - private readonly outputWires = new Map(); + /** 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 readonly outputWires = new Map(); + + /** Spread statements collected during indexing, with optional path prefix for scope blocks. */ + private readonly spreadStatements: { + stmt: SpreadStatement; + pathPrefix: string[]; + }[] = []; /** Alias statements indexed by name — evaluated lazily on first read. */ private readonly aliases = new Map(); @@ -290,21 +328,32 @@ class ExecutionScope { /** 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 readonly memoizedToolKeys = new Set(); + constructor( parent: ExecutionScope | null, selfInput: Record, output: Record, engine: EngineContext, + depth = 0, ) { this.parent = parent; this.selfInput = selfInput; this.output = output; this.engine = engine; + this.depth = depth; } /** Register that this scope owns a tool declared via `with`. */ - declareToolBinding(name: string): void { + declareToolBinding(name: string, memoize?: true): void { this.ownedTools.add(bindingOwnerKey(name)); + if (memoize) { + this.memoizedToolKeys.add(bindingOwnerKey(name)); + } } /** Register that this scope owns a define block declared via `with`. */ @@ -343,7 +392,7 @@ class ExecutionScope { 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}:${field}`; + const prefix = `${module}:Tools:${field}`; const result: WireStatement[] = []; for (const [key, wires] of this.toolInputWires) { if (key === prefix || key.startsWith(prefix + ":")) { @@ -368,14 +417,30 @@ class ExecutionScope { wires.push(wire); } - /** Index an output wire (self-module or element) by its target path. */ + /** 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 key = wire.target.path.join("."); - this.outputWires.set(key, wire); + let wires = this.outputWires.get(key); + if (!wires) { + wires = []; + this.outputWires.set(key, wires); + } + wires.push(wire); } - /** Get an output wire by field path key. */ - getOutputWire(field: string): WireStatement | undefined { + /** Add a spread statement with an optional path prefix for scope blocks. */ + addSpread(stmt: SpreadStatement, pathPrefix: string[] = []): void { + this.spreadStatements.push({ stmt, pathPrefix }); + } + + /** Get all spread statements with their path prefixes. */ + getSpreads(): { stmt: SpreadStatement; pathPrefix: string[] }[] { + return this.spreadStatements; + } + + /** Get output wires by field path key. Returns array (may have multiple for overdefinition). */ + getOutputWires(field: string): WireStatement[] | undefined { return this.outputWires.get(field); } @@ -385,25 +450,48 @@ class ExecutionScope { } /** - * Collect all output wires matching the requested fields via prefix matching. - * - Requesting "profile" matches wires "profile", "profile.name", "profile.age" - * - Requesting "profile.name" matches wire "profile" (parent provides the object) + * Collect all output wire groups matching the requested fields via prefix matching. + * Returns arrays of wires (one array per matched path, for overdefinition). */ - collectMatchingOutputWires(requestedFields: string[]): WireStatement[] { + collectMatchingOutputWireGroups( + requestedFields: string[], + ): WireStatement[][] { + // Bare "*" means all fields — skip filtering + if (requestedFields.includes("*")) { + return this.allOutputFields().map((f) => this.getOutputWires(f)!); + } + const matched = new Set(); - const result: WireStatement[] = []; + const result: WireStatement[][] = []; for (const field of requestedFields) { - for (const [key, wire] of this.outputWires) { + for (const [key, wires] of this.outputWires) { if (matched.has(key)) continue; - // Exact match, or prefix match in either direction + + // 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(wire); + result.push(wires); } } } @@ -422,22 +510,39 @@ class ExecutionScope { */ resolveAlias( name: string, - evaluator: (chain: SourceChain, scope: ExecutionScope) => Promise, + evaluator: ( + chain: SourceChain, + scope: ExecutionScope, + requestedFields: undefined, + pullPath: ReadonlySet, + ) => Promise, + pullPath: ReadonlySet = EMPTY_PULL_PATH, ): Promise { - // Check local cache + 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) { - const promise = evaluator(alias, this); + // 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); + return this.parent.resolveAlias(name, evaluator, pullPath); } throw new Error(`Alias "${name}" not found in any scope`); @@ -471,25 +576,53 @@ class ExecutionScope { * (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); - // Check local memoization cache - if (this.toolResults.has(key)) return this.toolResults.get(key)!; + // 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? - if (this.ownedTools.has(toolOwnerKey(module, field))) { - return this.callTool(key, module, field); + 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); + return this.parent.resolveToolResult( + module, + field, + instance, + bridgeLoc, + pullPath, + ); } throw new Error(`Tool "${module}.${field}" not found in any scope`); @@ -499,12 +632,15 @@ class ExecutionScope { * Lazily call a tool — evaluates input wires on demand, invokes the * tool function, and caches the result. * - * Supports ToolDef resolution (extends chain, base wires, onError). + * 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}`; @@ -518,33 +654,151 @@ class ExecutionScope { const fnName = toolDef?.fn ?? toolName; const fn = lookupToolFn(this.engine.tools, fnName); if (!fn) throw new Error(`No tool found for "${fnName}"`); - const { doTrace } = resolveToolMeta(fn); - - // Build input: ToolDef base wires first, then bridge wires override + const { + doTrace, + sync: isSyncTool, + batch: batchMeta, + log: toolLog, + } = resolveToolMeta(fn); + + // Build input: ToolDef base wires first, then bridge wires override. + // 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); + await evaluateToolDefBody(toolDef.body, input, this, pullPath); } const wires = this.toolInputWires.get(key) ?? []; - for (const wire of wires) { - const value = await evaluateSourceChain(wire, this); - setPath(input, wire.target.path, value); - } + await Promise.all( + wires.map(async (wire) => { + const value = await evaluateSourceChain( + wire, + this, + undefined, + pullPath, + ); + setPath(input, wire.target.path, value); + }), + ); // Short-circuit if externally aborted if (this.engine.signal?.aborted) throw new BridgeAbortError(); - const toolContext = { - logger: this.engine.logger, - signal: this.engine.signal, - }; - const startMs = performance.now(); - try { - const result = await fn(input, toolContext); - const durationMs = performance.now() - startMs; + // 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({ @@ -557,20 +811,29 @@ class ExecutionScope { }), ); } + 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(); - } + 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; + const durationMs = performance.now() - startMs; + if (!batchMeta) { if (this.engine.tracer && doTrace) { this.engine.tracer.record( this.engine.tracer.entry({ @@ -583,30 +846,40 @@ class ExecutionScope { }), ); } + logToolError( + this.engine.logger, + toolLog.errors, + toolName, + fnName, + err as Error, + ); + } - if (isFatalError(err)) throw err; + if (isFatalError(err)) throw err; - 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); - } + 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); } } - - throw err; } - })(); - this.toolResults.set(key, promise); - return promise; + // Attach bridgeLoc to error for source location reporting + throw wrapBridgeRuntimeError(err, { bridgeLoc }); + } } /** @@ -617,20 +890,30 @@ class ExecutionScope { module: string, field: string, instance: number | undefined, + pullPath: ReadonlySet = EMPTY_PULL_PATH, ): Promise { const key = `${module}:${field}`; - // Check memoization + // 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)) { - return this.executeDefine(key, module); + // 3. Branch the path + const nextPath = new Set(pullPath).add(key); + return this.executeDefine(key, module, nextPath); } // Delegate to parent if (this.parent) { - return this.parent.resolveDefine(module, field, instance); + return this.parent.resolveDefine(module, field, instance, pullPath); } throw new Error(`Define "${module}" not found in any scope`); @@ -640,7 +923,11 @@ class ExecutionScope { * Execute a define block — build input from bridge wires, create * child scope with define body, pull output. */ - private executeDefine(key: string, module: string): Promise { + private executeDefine( + key: string, + module: string, + pullPath: ReadonlySet, + ): Promise { const promise = (async () => { // Map from handle alias to define name via handle bindings const handle = module.substring("__define_".length); @@ -656,10 +943,17 @@ class ExecutionScope { // Collect bridge wires targeting this define (input wires) const inputWires = this.defineInputWires.get(key) ?? []; const defineInput: Record = {}; - for (const wire of inputWires) { - const value = await evaluateSourceChain(wire, this); - setPath(defineInput, wire.target.path, value); - } + await Promise.all( + inputWires.map(async (wire) => { + const value = await evaluateSourceChain( + wire, + this, + undefined, + pullPath, + ); + setPath(defineInput, wire.target.path, value); + }), + ); // Create child scope with define input as selfInput const defineOutput: Record = {}; @@ -673,7 +967,7 @@ class ExecutionScope { // Index define body and pull output indexStatements(defineDef.body, defineScope); - await resolveRequestedFields(defineScope, []); + await resolveRequestedFields(defineScope, [], pullPath); return "__rootValue__" in defineOutput ? defineOutput.__rootValue__ @@ -696,6 +990,53 @@ interface EngineContext { 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; +} + +/** 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 ────────────────────────────────────────────────────── @@ -766,6 +1107,7 @@ 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( @@ -793,14 +1135,26 @@ async function evaluateToolDefBody( } // Evaluate wires targeting the tool itself (no instance = tool config) - for (const stmt of body) { - if (stmt.kind === "wire" && stmt.target.instance == null) { - const value = await evaluateSourceChain(stmt, toolDefScope); - setPath(input, stmt.target.path, value); - } else if (stmt.kind === "scope") { - await evaluateToolDefScope(stmt, input, toolDefScope); - } - } + const configStmts = body.filter( + (stmt): stmt is WireStatement | ScopeStatement => + (stmt.kind === "wire" && stmt.target.instance == null) || + stmt.kind === "scope", + ); + 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 { + await evaluateToolDefScope(stmt, input, toolDefScope, pullPath); + } + }), + ); } /** Recursively evaluate scope blocks inside ToolDef bodies. */ @@ -808,22 +1162,181 @@ async function evaluateToolDefScope( scope: ScopeStatement, input: Record, toolDefScope: ExecutionScope, + pullPath: ReadonlySet, ): Promise { const prefix = scope.target.path; - for (const inner of scope.body) { - if (inner.kind === "wire" && inner.target.instance == null) { - const value = await evaluateSourceChain(inner, toolDefScope); - setPath(input, [...prefix, ...inner.target.path], value); - } else if (inner.kind === "scope") { - // Nest the inner scope under the current prefix - const nested: ScopeStatement = { - ...inner, - target: { - ...inner.target, - path: [...prefix, ...inner.target.path], - }, - }; - await evaluateToolDefScope(nested, input, toolDefScope); + 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); } } } @@ -843,12 +1356,15 @@ function indexStatements( switch (stmt.kind) { case "with": if (stmt.binding.kind === "tool") { - scope.declareToolBinding(stmt.binding.name); + 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 @@ -918,6 +1434,35 @@ function indexStatements( } } +/** + * 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). @@ -925,34 +1470,109 @@ function indexStatements( * * If no specific fields are requested, all indexed output wires are resolved. * - * All output wires 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. + * 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 { - // If no specific fields, resolve all indexed output wires. - // Otherwise, use prefix matching to find relevant wires. - const wires = + // Get wire groups — each group is an array of wires targeting the same path + const wireGroups: WireStatement[][] = requestedFields.length > 0 - ? scope.collectMatchingOutputWires(requestedFields) - : scope.allOutputFields().map((f) => scope.getOutputWire(f)!); + ? scope.collectMatchingOutputWireGroups(requestedFields) + : scope.allOutputFields().map((f) => scope.getOutputWires(f)!); - // Evaluate all wires concurrently — allows tool calls from later wires to - // start before earlier wires that might panic synchronously. + // Evaluate all wire groups concurrently type Signal = LoopControlSignal | typeof BREAK_SYM | typeof CONTINUE_SYM; const settled = await Promise.allSettled( - wires.map(async (wire): Promise => { - const value = await evaluateSourceChain(wire, scope); - if (isLoopControlSignal(value)) return value; - writeTarget(wire.target, value, scope); + 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. + // Strip the wire's target path prefix from the parent requestedFields. + 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; // First non-null wins + } catch (err) { + if (isFatalError(err)) throw err; + lastError = err; + // Continue to next wire — maybe a cheaper fallback succeeds + } + } + + // If all wires returned null and there was an error, throw it + if (value == null && lastError) throw lastError; + + writeTarget(ordered[0]!.target, value, scope); return undefined; }), ); + // Evaluate spread statements concurrently — merge source objects into output + await Promise.all( + scope.getSpreads().map(async ({ stmt: spread, pathPrefix }) => { + try { + const spreadValue = await evaluateSourceChain( + spread, + scope, + undefined, + pullPath, + ); + if ( + spreadValue != null && + typeof spreadValue === "object" && + !Array.isArray(spreadValue) + ) { + // Spreads always target the root output (self-module output) + const targetOutput = scope.root().output; + if (pathPrefix.length > 0) { + // Spread inside a scope block — navigate to the nested object and merge + 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); + } + } + } catch (err) { + if (isFatalError(err)) throw err; + throw err; + } + }), + ); + // Process results: collect errors and signals, preserving wire order. let fatalError: unknown; let firstError: unknown; @@ -975,29 +1595,143 @@ async function resolveRequestedFields( if (firstError) throw firstError; } +/** + * 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. + * Wraps with catch handler if present. Attaches bridgeLoc on error. */ async function evaluateSourceChain( chain: SourceChain, scope: ExecutionScope, + requestedFields?: string[], + pullPath: ReadonlySet = EMPTY_PULL_PATH, ): Promise { + let lastEntryLoc: SourceLocation | undefined; + let firstExprLoc: SourceLocation | undefined; try { let value: unknown; for (const entry of chain.sources) { if (entry.gate === "falsy" && value) continue; if (entry.gate === "nullish" && value != null) continue; - value = await evaluateExpression(entry.expr, scope); + lastEntryLoc = entry.loc; + if (!firstExprLoc) firstExprLoc = entry.expr.loc; + value = await evaluateExpression( + entry.expr, + scope, + requestedFields, + pullPath, + ); } return value; } catch (err) { - if (isFatalError(err)) throw err; + if (isFatalError(err)) { + // Attach bridgeLoc to fatal errors (panic) so they carry source location + const fatLoc = + firstExprLoc ?? lastEntryLoc ?? (chain as { loc?: SourceLocation }).loc; + if (fatLoc && !(err as { bridgeLoc?: SourceLocation }).bridgeLoc) { + (err as { bridgeLoc?: SourceLocation }).bridgeLoc = fatLoc; + } + throw err; + } if (chain.catch) { - return applyCatchHandler(chain.catch, scope); + return applyCatchHandler(chain.catch, scope, pullPath); } + // Use the first source entry's expression loc (start of source chain) + const loc = + firstExprLoc ?? lastEntryLoc ?? (chain as { loc?: SourceLocation }).loc; + if (loc) throw wrapBridgeRuntimeError(err, { bridgeLoc: loc }); throw err; } } @@ -1009,15 +1743,16 @@ async function evaluateSourceChain( 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); + return evaluateExpression(c.expr, scope, undefined, pullPath); } if ("ref" in c) { - return resolveRef(c.ref, scope); + return resolveRef(c.ref, scope, undefined, pullPath); } // Literal value return c.value; @@ -1037,6 +1772,8 @@ function executeForced(scope: ExecutionScope): Promise[] { stmt.module, stmt.field, stmt.instance, + undefined, + EMPTY_PULL_PATH, ); if (stmt.catchError) { promise.catch(() => {}); @@ -1076,61 +1813,100 @@ async function evaluateExprSafe( async function evaluateExpression( expr: Expression, scope: ExecutionScope, + requestedFields?: string[], + pullPath: ReadonlySet = EMPTY_PULL_PATH, ): Promise { switch (expr.type) { case "ref": if (expr.safe) { - return evaluateExprSafe(() => resolveRef(expr.ref, scope)); + return evaluateExprSafe(() => + resolveRef(expr.ref, scope, expr.refLoc ?? expr.loc, pullPath), + ); } - return resolveRef(expr.ref, scope); + return resolveRef(expr.ref, scope, expr.refLoc ?? expr.loc, pullPath); case "literal": return expr.value; case "array": - return evaluateArrayExpr(expr, scope); + return evaluateArrayExpr(expr, scope, requestedFields, pullPath); case "ternary": { - const cond = await evaluateExpression(expr.cond, scope); - return cond - ? evaluateExpression(expr.then, scope) - : evaluateExpression(expr.else, scope); + 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 evaluateExprSafe(() => evaluateExpression(expr.left, scope)) - : await evaluateExpression(expr.left, scope); + ? await evaluateExprSafe(() => + evaluateExpression(expr.left, scope, undefined, pullPath), + ) + : 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 evaluateExprSafe(() => evaluateExpression(expr.right, scope)) - : await evaluateExpression(expr.right, scope); + ? await evaluateExprSafe(() => + evaluateExpression(expr.right, scope, undefined, pullPath), + ) + : await evaluateExpression(expr.right, scope, undefined, pullPath); return Boolean(right); } case "or": { const left = expr.leftSafe - ? await evaluateExprSafe(() => evaluateExpression(expr.left, scope)) - : await evaluateExpression(expr.left, scope); + ? await evaluateExprSafe(() => + evaluateExpression(expr.left, scope, undefined, pullPath), + ) + : 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 evaluateExprSafe(() => evaluateExpression(expr.right, scope)) - : await evaluateExpression(expr.right, scope); + ? await evaluateExprSafe(() => + evaluateExpression(expr.right, scope, undefined, pullPath), + ) + : await evaluateExpression(expr.right, scope, undefined, pullPath); return Boolean(right); } - case "control": - return applyControlFlow(expr.control); + 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 = await evaluateExpression(expr.left, scope); - const right = await evaluateExpression(expr.right, scope); + const [left, right] = await Promise.all([ + evaluateExpression(expr.left, scope, undefined, pullPath), + evaluateExpression(expr.right, scope, undefined, pullPath), + ]); switch (expr.op) { case "add": return Number(left) + Number(right); @@ -1157,17 +1933,24 @@ async function evaluateExpression( } case "unary": - return !(await evaluateExpression(expr.operand, scope)); + return !(await evaluateExpression( + expr.operand, + scope, + undefined, + pullPath, + )); case "concat": { const parts = await Promise.all( - expr.parts.map((p) => evaluateExpression(p, scope)), + expr.parts.map((p) => + evaluateExpression(p, scope, undefined, pullPath), + ), ); return parts.map((v) => (v == null ? "" : String(v))).join(""); } case "pipe": - return evaluatePipeExpression(expr, scope); + return evaluatePipeExpression(expr, scope, pullPath); default: throw new Error(`Unknown expression type: ${(expr as Expression).type}`); @@ -1184,33 +1967,64 @@ async function evaluateExpression( async function evaluateArrayExpr( expr: Extract, scope: ExecutionScope, + requestedFields?: string[], + pullPath: ReadonlySet = EMPTY_PULL_PATH, ): Promise< unknown[] | LoopControlSignal | typeof BREAK_SYM | typeof CONTINUE_SYM | null > { - const sourceValue = await evaluateExpression(expr.source, scope); + const sourceValue = await evaluateExpression( + expr.source, + scope, + undefined, + pullPath, + ); if (sourceValue == null) return null; if (!Array.isArray(sourceValue)) return []; + // Depth protection — prevent infinite nesting + 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 results: unknown[] = []; + + // Launch all loop body evaluations concurrently so that batched tool calls + // accumulate within the same microtask tick before the batch queue flushes. + const settled = await Promise.allSettled( + sourceValue.map(async (element) => { + const elementOutput: Record = {}; + const childScope = new ExecutionScope( + scope, + scope.selfInput, + elementOutput, + scope.engine, + childDepth, + ); + childScope.pushElement(element); + + // Index then pull — child scope may declare its own tools + indexStatements(expr.body, childScope); + const signal = await resolveRequestedFields( + childScope, + requestedFields ?? [], + pullPath, + ); + return { elementOutput, signal }; + }), + ); + let propagate: | LoopControlSignal | typeof BREAK_SYM | typeof CONTINUE_SYM | undefined; - for (const element of sourceValue) { - const elementOutput: Record = {}; - const childScope = new ExecutionScope( - scope, - scope.selfInput, - elementOutput, - scope.engine, - ); - childScope.pushElement(element); - - // Index then pull — child scope may declare its own tools - indexStatements(expr.body, childScope); - const signal = await resolveRequestedFields(childScope, []); + for (const result of settled) { + if (result.status === "rejected") throw result.reason; + const { elementOutput, signal } = result.value; if (isLoopControlSignal(signal)) { if (signal === CONTINUE_SYM) continue; @@ -1238,11 +2052,29 @@ async function evaluateArrayExpr( async function evaluatePipeExpression( expr: Extract, scope: ExecutionScope, + pullPath: ReadonlySet = EMPTY_PULL_PATH, ): Promise { - // 1. Evaluate source - const sourceValue = await evaluateExpression(expr.source, scope); + 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, + ); - // 2. Look up handle binding + // 4. Look up handle binding const binding = scope.getHandleBinding(expr.handle); if (!binding) throw new Error(`Pipe handle "${expr.handle}" not found in scope`); @@ -1252,7 +2084,7 @@ async function evaluatePipeExpression( `Pipe handle "${expr.handle}" must reference a tool, got "${binding.kind}"`, ); - // 3. Resolve ToolDef + // 5. Resolve ToolDef const toolName = binding.name; const toolDef = resolveToolDefByName( scope.engine.instructions, @@ -1264,20 +2096,22 @@ async function evaluatePipeExpression( if (!fn) throw new Error(`No tool found for "${fnName}"`); const { doTrace } = resolveToolMeta(fn); - // 4. Build input + // 6. Build input const input: Record = {}; - // 4a. ToolDef body wires (base configuration) + // 6a. ToolDef body wires (base configuration) if (toolDef?.body) { - await evaluateToolDefBody(toolDef.body, input, scope); + await evaluateToolDefBody(toolDef.body, input, scope, nextPath); } - // 4b. Bridge wires for this tool (non-pipe input wires) + // 6b. Bridge wires for this tool (non-pipe input wires) const bridgeWires = scope.collectToolInputWiresFor(toolName); - for (const wire of bridgeWires) { - const value = await evaluateSourceChain(wire, scope); - setPath(input, wire.target.path, value); - } + await Promise.all( + bridgeWires.map(async (wire) => { + const value = await evaluateSourceChain(wire, scope, undefined, nextPath); + setPath(input, wire.target.path, value); + }), + ); // 4c. Pipe source → "in" or named field const pipePath = expr.path && expr.path.length > 0 ? expr.path : ["in"]; @@ -1290,9 +2124,21 @@ async function evaluatePipeExpression( logger: scope.engine.logger, signal: scope.engine.signal, }; + const timeoutMs = scope.engine.toolTimeoutMs; const startMs = performance.now(); try { - const result = await fn(input, toolContext); + 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) { @@ -1349,6 +2195,8 @@ async function evaluatePipeExpression( async function resolveRef( ref: NodeRef, scope: ExecutionScope, + bridgeLoc?: SourceLocation, + pullPath: ReadonlySet = EMPTY_PULL_PATH, ): Promise { // Element reference — reading from array iterator binding if (ref.element) { @@ -1362,6 +2210,7 @@ async function resolveRef( const aliasResult = await scope.resolveAlias( ref.field, evaluateSourceChain, + pullPath, ); return getPath(aliasResult, ref.path, ref.rootSafe, ref.pathSafe); } @@ -1382,6 +2231,7 @@ async function resolveRef( ref.module, ref.field, ref.instance, + pullPath, ); return getPath(result, ref.path, ref.rootSafe, ref.pathSafe); } @@ -1396,6 +2246,8 @@ async function resolveRef( ref.module, ref.field, ref.instance, + bridgeLoc, + pullPath, ); return getPath(toolResult, ref.path, ref.rootSafe, ref.pathSafe); } @@ -1502,6 +2354,10 @@ export async function executeBridge( tracer, signal: options.signal, toolDefCache: new Map(), + toolTimeoutMs: options.toolTimeoutMs ?? 15_000, + toolMemoCache: new Map(), + toolBatchQueues: new Map(), + maxDepth: options.maxDepth ?? MAX_EXECUTION_DEPTH, }; // Create root scope and execute @@ -1526,7 +2382,7 @@ export async function executeBridge( if (tracer) { (err as { traces?: ToolTrace[] }).traces = tracer.traces; } - throw err; + throw attachBridgeErrorDocumentContext(err, doc); } // Wrap non-fatal errors in BridgeRuntimeError with traces const wrapped = wrapBridgeRuntimeError(err); @@ -1534,7 +2390,7 @@ export async function executeBridge( wrapped.traces = tracer.traces; } wrapped.executionTraceId = 0n; - throw wrapped; + throw attachBridgeErrorDocumentContext(wrapped, doc); } // Extract root value if a wire wrote to the output root with a non-object value diff --git a/packages/bridge-parser/src/parser/ast-builder.ts b/packages/bridge-parser/src/parser/ast-builder.ts index 822942f..d6c7193 100644 --- a/packages/bridge-parser/src/parser/ast-builder.ts +++ b/packages/bridge-parser/src/parser/ast-builder.ts @@ -1482,7 +1482,23 @@ export function buildBody( } let binding: HandleBinding; - if (lastDot !== -1) { + 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}`; diff --git a/packages/bridge/test/utils/regression.ts b/packages/bridge/test/utils/regression.ts index ffd163e..bb3bea4 100644 --- a/packages/bridge/test/utils/regression.ts +++ b/packages/bridge/test/utils/regression.ts @@ -1124,8 +1124,8 @@ function isDisabled( // Explicit array: trust exactly what the user listed if (Array.isArray(disable)) return disable.includes(check); - // Not set: defaults — compiled and v3 are off - return ["compiled", "v3"].includes(check); + // Not set: defaults — compiled and parser are off + return ["compiled", "parser"].includes(check); } export function regressionTest(name: string, data: RegressionTest) { From 405c5ae9e5a820bff4567d766e901a3d20cef7a9 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Tue, 17 Mar 2026 07:53:46 +0100 Subject: [PATCH 19/23] ExecutionTraceId --- .../bridge-core/src/enumerate-traversals.ts | 594 ++++++++++- packages/bridge-core/src/index.ts | 1 + packages/bridge-core/src/v3/execute-bridge.ts | 136 ++- .../test/enumerate-traversals.test.ts | 935 +++++++++--------- .../test/traversal-manifest-locations.test.ts | 76 +- packages/bridge/test/coalesce-cost.test.ts | 2 +- .../bridge/test/runtime-error-format.test.ts | 2 + packages/bridge/test/tool-features.test.ts | 2 +- packages/bridge/test/utils/regression.ts | 48 +- .../test/trace-highlighting.test.ts | 6 +- 10 files changed, 1264 insertions(+), 538 deletions(-) diff --git a/packages/bridge-core/src/enumerate-traversals.ts b/packages/bridge-core/src/enumerate-traversals.ts index fd5b0c7..66aafd8 100644 --- a/packages/bridge-core/src/enumerate-traversals.ts +++ b/packages/bridge-core/src/enumerate-traversals.ts @@ -22,6 +22,9 @@ import type { NodeRef, ControlFlowInstruction, SourceLocation, + Expression, + SourceChain, + Statement, } from "./types.ts"; // ── Public types ──────────────────────────────────────────────────────────── @@ -593,12 +596,593 @@ export function enumerateTraversalIds(bridge: Bridge): TraversalEntry[] { /** * 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. + * Prefers the nested `body` representation when available (V1.5+ engine); + * falls back to the legacy `wires` array for older documents. + * + * When built from `body`, 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[] { + if (bridge.body) { + return buildBodyTraversalMaps(bridge).manifest; + } + return enumerateTraversalIds(bridge); +} + +// ── Body-based traversal enumeration ──────────────────────────────────────── + +/** 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[]; +}; + +/** + * 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[], +): 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); + } 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); + } + } + break; + } + case "alias": + items.push({ chain: stmt, target: [stmt.name] }); + for (const source of stmt.sources) { + collectArrayExprs(source.expr, [stmt.name], items, emptyArrayItems); + } + break; + case "spread": + items.push({ + chain: stmt, + target: pathPrefix.length > 0 ? [...pathPrefix] : [], + }); + break; + case "scope": + collectTraceableItems( + stmt.body, + [...pathPrefix, ...stmt.target.path], + items, + emptyArrayItems, + ); + 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[], +): void { + switch (expr.type) { + case "array": + emptyArrayItems.push({ expr, target: [...target] }); + collectTraceableItems(expr.body, target, items, emptyArrayItems); + collectArrayExprs(expr.source, target, items, emptyArrayItems); + break; + case "ternary": + collectArrayExprs(expr.cond, target, items, emptyArrayItems); + collectArrayExprs(expr.then, target, items, emptyArrayItems); + collectArrayExprs(expr.else, target, items, emptyArrayItems); + break; + case "and": + case "or": + case "binary": + collectArrayExprs(expr.left, target, items, emptyArrayItems); + collectArrayExprs(expr.right, target, items, emptyArrayItems); + break; + case "unary": + collectArrayExprs(expr.operand, target, items, emptyArrayItems); + break; + case "pipe": + collectArrayExprs(expr.source, target, items, emptyArrayItems); + break; + case "concat": + for (const part of expr.parts) { + collectArrayExprs(part, target, items, emptyArrayItems); + } + break; + case "ref": + case "literal": + case "control": + break; // Leaves: no nested arrays possible + } +} + +/** + * Generate TraversalEntry items for a single SourceChain. + * Mirrors the wire-based logic but works on the SourceChain interface. + */ +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: primary.refLoc ?? primary.loc ?? chainLoc, + 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; + } + + // 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; +} + +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 addChainFallbacks( + entries: TraversalEntry[], + base: string, + target: string[], + chain: SourceChain, + hmap: Map, +): void { + 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: -1, + target, + kind: "fallback", + fallbackIndex: i - 1, + gateType: entry.gate, + bitIndex: -1, + loc: entry.loc, + wireLoc: chainLoc, + description: sourceEntryDescription(entry, hmap), + }); + } +} + +function addChainCatch( + entries: TraversalEntry[], + base: string, + target: string[], + chain: SourceChain, + hmap: Map, +): void { + 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), + }); +} + +/** + * True when an expression can throw at runtime (e.g., pipes or unsafe refs). */ -export const buildTraversalManifest = enumerateTraversalIds; +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, + target: string[], + chain: SourceChain, + hmap: Map, + primaryExpr: Expression | undefined, + wireSafe: boolean, + elseExpr?: Expression | undefined, +): void { + 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: -1, + target, + kind: "catch", + error: true, + bitIndex: -1, + loc: chain.catch.loc, + wireLoc: chainLoc, + description: `${chainCatchDesc(chain, hmap)} error`, + }); + } + return; + } + + 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: -1, + target, + kind: "primary", + error: true, + bitIndex: -1, + loc: pLoc, + wireLoc: chainLoc, + description: desc ? `${desc} error` : "error", + }); + } + + if (canExprThrow(elseExpr)) { + const elseLoc = elseExpr!.loc ?? chainLoc; + entries.push({ + id: `${base}/else/error`, + wireIndex: -1, + target, + kind: "else", + error: true, + bitIndex: -1, + loc: elseLoc, + wireLoc: chainLoc, + description: + elseExpr!.type === "ref" + ? `${refLabel(elseExpr!.ref, hmap)} error` + : "else error", + }); + } + + 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: -1, + target, + kind: "fallback", + error: true, + fallbackIndex: i - 1, + gateType: entry.gate, + bitIndex: -1, + loc: entry.loc, + wireLoc: chainLoc, + description: `${sourceEntryDescription(entry, hmap)} error`, + }); + } + } +} + +/** + * Build traversal manifest and runtime trace maps from a Bridge's Statement[] body. + * + * 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). + * + * 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 buildBodyTraversalMaps(bridge: Bridge): { + manifest: TraversalEntry[]; + chainBitsMap: Map; + emptyArrayBits: Map; +} { + // 1. Collect all traceable chains from body + const items: BodyTraceItem[] = []; + const emptyArrayItems: EmptyArrayItem[] = []; + collectTraceableItems(bridge.body!, [], items, emptyArrayItems); + + // 2. Generate traversal entries for each chain + const hmap = buildHandleMap(bridge); + const targetCounts = new Map(); + const allEntries: { entry: TraversalEntry; chain: SourceChain }[] = []; + + for (const { chain, target } of items) { + const tKey = pathKey(target); + const seen = targetCounts.get(tKey) ?? 0; + targetCounts.set(tKey, seen + 1); + const base = seen > 0 ? `${tKey}#${seen}` : tKey; + + for (const entry of generateChainEntries(chain, base, target, hmap)) { + allEntries.push({ entry, chain }); + } + } + + // 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: "empty-array", + bitIndex: -1, + description: `[] empty`, + }; + allEntries.push({ entry, chain: { sources: [] } }); + emptyArrayEntries.push({ entry, expr }); + } + + // 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; + } + } + + // 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 { + manifest: allEntries.map((e) => e.entry), + chainBitsMap, + emptyArrayBits, + }; +} /** * Decode a runtime execution trace bitmask against a traversal manifest. diff --git a/packages/bridge-core/src/index.ts b/packages/bridge-core/src/index.ts index 3f7e81d..e9698ca 100644 --- a/packages/bridge-core/src/index.ts +++ b/packages/bridge-core/src/index.ts @@ -111,6 +111,7 @@ export { export { enumerateTraversalIds, buildTraversalManifest, + buildBodyTraversalMaps, decodeExecutionTrace, buildTraceBitsMap, buildEmptyArrayBitsMap, diff --git a/packages/bridge-core/src/v3/execute-bridge.ts b/packages/bridge-core/src/v3/execute-bridge.ts index b695a80..bcd0070 100644 --- a/packages/bridge-core/src/v3/execute-bridge.ts +++ b/packages/bridge-core/src/v3/execute-bridge.ts @@ -18,6 +18,7 @@ import type { ToolMap, WireAliasStatement, WireCatch, + WireSourceEntry, WireStatement, } from "../types.ts"; import { SELF_MODULE } from "../types.ts"; @@ -50,6 +51,8 @@ import { STD_VERSION as BUNDLED_STD_VERSION, } from "@stackables/bridge-stdlib"; 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`). */ @@ -1000,6 +1003,19 @@ interface EngineContext { >; /** Maximum nesting depth for array mappings / shadow scopes. */ readonly maxDepth: number; + /** 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. */ @@ -1689,6 +1705,7 @@ function computeExprCost( /** * 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, @@ -1696,22 +1713,71 @@ async function evaluateSourceChain( requestedFields?: string[], pullPath: ReadonlySet = EMPTY_PULL_PATH, ): Promise { + const bits = scope.engine.traceBits?.get(chain.sources); let lastEntryLoc: SourceLocation | undefined; let firstExprLoc: SourceLocation | undefined; + let activeSourceIndex = -1; + let ternaryElsePath = false; + try { let value: unknown; - for (const entry of chain.sources) { + 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; lastEntryLoc = entry.loc; if (!firstExprLoc) firstExprLoc = entry.expr.loc; - value = await evaluateExpression( - entry.expr, - scope, - requestedFields, - pullPath, - ); + 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; @@ -1726,7 +1792,35 @@ async function evaluateSourceChain( throw err; } if (chain.catch) { - return applyCatchHandler(chain.catch, scope, pullPath); + // 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], + ); + } } // Use the first source entry's expression loc (start of source chain) const loc = @@ -1978,9 +2072,21 @@ async function evaluateArrayExpr( undefined, pullPath, ); - if (sourceValue == null) return null; + if (sourceValue == null) { + // Null/undefined source — record empty-array bit + const emptyBit = scope.engine.emptyArrayBits?.get(expr); + if (emptyBit != null) recordTraceBit(scope.engine, emptyBit); + return null; + } if (!Array.isArray(sourceValue)) return []; + // Empty array — record empty-array bit + if (sourceValue.length === 0) { + const emptyBit = scope.engine.emptyArrayBits?.get(expr); + if (emptyBit != null) recordTraceBit(scope.engine, emptyBit); + return []; + } + // Depth protection — prevent infinite nesting const childDepth = scope["depth"] + 1; if (childDepth > scope.engine.maxDepth) { @@ -2343,6 +2449,10 @@ export async function executeBridge( 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, @@ -2358,6 +2468,9 @@ export async function executeBridge( toolMemoCache: new Map(), toolBatchQueues: new Map(), maxDepth: options.maxDepth ?? MAX_EXECUTION_DEPTH, + traceBits: chainBitsMap, + emptyArrayBits, + traceMask, }; // Create root scope and execute @@ -2382,6 +2495,7 @@ export async function executeBridge( 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 @@ -2389,7 +2503,7 @@ export async function executeBridge( if (tracer) { wrapped.traces = tracer.traces; } - wrapped.executionTraceId = 0n; + wrapped.executionTraceId = traceMask[0]; throw attachBridgeErrorDocumentContext(wrapped, doc); } @@ -2400,6 +2514,6 @@ export async function executeBridge( return { data, traces: tracer?.traces ?? [], - executionTraceId: 0n, + executionTraceId: traceMask[0], }; } diff --git a/packages/bridge-core/test/enumerate-traversals.test.ts b/packages/bridge-core/test/enumerate-traversals.test.ts index f526fc0..c5a5418 100644 --- a/packages/bridge-core/test/enumerate-traversals.test.ts +++ b/packages/bridge-core/test/enumerate-traversals.test.ts @@ -5,7 +5,7 @@ import { enumerateTraversalIds, buildTraversalManifest, decodeExecutionTrace, - executeBridge, + executeBridgeV3 as executeBridge, } from "@stackables/bridge-core"; import type { Bridge, @@ -27,11 +27,9 @@ function ids(entries: TraversalEntry[]): string[] { // ── Simple wires ──────────────────────────────────────────────────────────── -describe( - "enumerateTraversalIds", - () => { - test("simple pull wire — 1 traversal (primary)", () => { - const instr = getBridge(bridge` +describe("enumerateTraversalIds", () => { + test("simple pull wire — 1 traversal (primary)", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with api @@ -41,17 +39,17 @@ describe( 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", - ); - }); + 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` + test("constant wire — 1 traversal (const)", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with api @@ -60,16 +58,16 @@ describe( 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")); - }); + 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 ─────────────────────────────────────────────────────── + // ── Fallback chains ─────────────────────────────────────────────────────── - test("|| fallback — 2 non-error traversals (primary + fallback)", () => { - const instr = getBridge(bridge` + test("|| fallback — 2 non-error traversals (primary + fallback)", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with a @@ -81,19 +79,19 @@ describe( 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); - }); + 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` + test("?? fallback — 2 non-error traversals (primary + nullish fallback)", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with api @@ -103,18 +101,18 @@ describe( 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"); - }); + 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` + test("|| || — 3 non-error traversals (primary + 2 fallbacks)", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with a @@ -126,22 +124,22 @@ describe( 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); - }); + 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 ───────────────────────────────────────────────────────────────── + // ── Catch ───────────────────────────────────────────────────────────────── - test("catch — 2 traversals (primary + catch)", () => { - const instr = getBridge(bridge` + test("catch — 2 traversals (primary + catch)", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with api @@ -151,19 +149,19 @@ describe( 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"); - }); + 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 ───────────────────────────────── + // ── Problem statement example: || + catch ───────────────────────────────── - test("o <- i.a || i.b catch i.c — 3 traversals", () => { - const instr = getBridge(bridge` + test("o <- i.a || i.b catch i.c — 3 traversals", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with a @@ -175,20 +173,20 @@ describe( 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"); - }); + 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 ─────────────────────────────────────────────── + // ── Error traversal entries ─────────────────────────────────────────────── - test("a.label || b.label — 4 traversals (primary, fallback, primary/error, fallback/error)", () => { - const instr = getBridge(bridge` + 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 @@ -200,25 +198,25 @@ describe( 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); - }); + 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` + test("a.label || b?.label — 3 traversals (primary, fallback, primary/error)", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with a @@ -230,23 +228,23 @@ describe( 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); - }); + 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` + test("a.label || b.label catch 'whatever' — 3 traversals (primary, fallback, catch)", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with a @@ -258,22 +256,22 @@ describe( 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); - }); + 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` + test("catch with tool ref — catch/error entry added", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with a @@ -285,22 +283,22 @@ describe( 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); - }); + 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` + test("simple pull wire — primary + primary/error", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with api @@ -310,19 +308,19 @@ describe( 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); - }); + 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` + test("input ref wire — no error entry (inputs cannot throw)", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with api @@ -332,18 +330,18 @@ describe( 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); - }); + 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` + test("safe (?.) wire — no primary/error entry", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with api @@ -353,18 +351,18 @@ describe( 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); - }); + 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` + test("error entries have unique IDs", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with a @@ -376,20 +374,20 @@ describe( 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)}`, - ); - }); + 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 ─────────────────────────────────────────────────────── + // ── Array iterators ─────────────────────────────────────────────────────── - test("array block — adds empty-array traversal", () => { - const instr = getBridge(bridge` + test("array block — adds empty-array traversal", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with api @@ -400,16 +398,16 @@ describe( } } `); - const entries = enumerateTraversalIds(instr); - const emptyArr = entries.filter((e) => e.kind === "empty-array"); - assert.equal(emptyArr.length, 1); - assert.equal(emptyArr[0].wireIndex, -1); - }); + 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 + ?? ───────────────────────────────── + // ── Problem statement example: array + ?? ───────────────────────────────── - test("o.out <- i.array[] as a { .data <- a.a ?? a.b } — 3 traversals", () => { - const instr = getBridge(bridge` + 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 @@ -419,24 +417,24 @@ describe( } } `); - 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"); - }); + 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 ───────────────────────────────────────────────────────── + // ── Nested arrays ───────────────────────────────────────────────────────── - test("nested array blocks — 2 empty-array entries", () => { - const instr = getBridge(bridge` + test("nested array blocks — 2 empty-array entries", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with api @@ -449,15 +447,15 @@ describe( } } `); - const entries = enumerateTraversalIds(instr); - const emptyArr = entries.filter((e) => e.kind === "empty-array"); - assert.equal(emptyArr.length, 2, "two array scopes"); - }); + const entries = enumerateTraversalIds(instr); + const emptyArr = entries.filter((e) => e.kind === "empty-array"); + assert.equal(emptyArr.length, 2, "two array scopes"); + }); - // ── IDs are unique ──────────────────────────────────────────────────────── + // ── IDs are unique ──────────────────────────────────────────────────────── - test("all IDs within a bridge are unique", () => { - const instr = getBridge(bridge` + test("all IDs within a bridge are unique", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with a @@ -470,20 +468,20 @@ describe( 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)}`, - ); - }); + 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 ────────────────────────────────────────────────── + // ── TraversalEntry shape ────────────────────────────────────────────────── - test("entries have correct structure", () => { - const instr = getBridge(bridge` + test("entries have correct structure", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with api @@ -493,23 +491,23 @@ describe( 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"); - }); + 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 ────────────────────────────────────────────────────── + // ── Conditional wire ────────────────────────────────────────────────────── - test("conditional (ternary) wire — 2 traversals (then + else)", () => { - const instr = getBridge(bridge` + test("conditional (ternary) wire — 2 traversals (then + else)", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with api @@ -519,21 +517,21 @@ describe( 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"); - }); + 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 ───────────────────────────────────── + // ── Total count is a complexity proxy ───────────────────────────────────── - test("total traversal count reflects complexity", () => { - const simple = getBridge(bridge` + test("total traversal count reflects complexity", () => { + const simple = getBridge(bridge` version 1.5 bridge Query.simple { with api @@ -541,7 +539,7 @@ describe( o.value <- api.value } `); - const complex = getBridge(bridge` + const complex = getBridge(bridge` version 1.5 bridge Query.complex { with a @@ -557,27 +555,37 @@ describe( } } `); - const simpleCount = enumerateTraversalIds(simple).length; - const complexCount = enumerateTraversalIds(complex).length; - assert.ok( - complexCount > simpleCount, - `complex (${complexCount}) should exceed simple (${simpleCount})`, - ); - }); - }, -); + 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); - }); +describe("buildTraversalManifest", () => { + 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", () => { - const instr = getBridge(bridge` + test("entries have sequential bitIndex starting at 0", () => { + const instr = getBridge(bridge` version 1.5 bridge Query.demo { with a @@ -590,17 +598,16 @@ describe( o.score <- a.score ?? 0 } `); - const manifest = buildTraversalManifest(instr); - for (let i = 0; i < manifest.length; i++) { - assert.equal( - manifest[i].bitIndex, - i, - `entry ${i} should have bitIndex ${i}`, - ); - } - }); - }, -); + const manifest = buildTraversalManifest(instr); + for (let i = 0; i < manifest.length; i++) { + assert.equal( + manifest[i].bitIndex, + i, + `entry ${i} should have bitIndex ${i}`, + ); + } + }); +}); // ── decodeExecutionTrace ──────────────────────────────────────────────────── @@ -671,10 +678,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", () => { @@ -707,11 +715,9 @@ function getDoc(source: string): BridgeDocument { return JSON.parse(JSON.stringify(raw)) as BridgeDocument; } -describe( - "executionTraceId: end-to-end", - () => { - test("simple pull wire — primary bits are set", async () => { - const doc = getDoc(`version 1.5 +describe("executionTraceId: end-to-end", () => { + test("simple pull wire — primary bits are set", async () => { + const doc = getDoc(`version 1.5 bridge Query.demo { with api with input as i @@ -719,27 +725,27 @@ bridge Query.demo { api.q <- i.q o.result <- api.label }`); - const { executionTraceId } = await executeBridge({ - document: doc, - operation: "Query.demo", - input: { q: "test" }, - tools: { api: async () => ({ label: "Hello" }) }, - }); + const { executionTraceId } = await executeBridge({ + document: doc, + operation: "Query.demo", + input: { q: "test" }, + tools: { api: async () => ({ label: "Hello" }) }, + }); - assert.ok(executionTraceId > 0n, "trace should have bits set"); + assert.ok(executionTraceId > 0n, "trace should have bits set"); - // Decode and verify - const instr = doc.instructions.find( - (i): i is Bridge => i.kind === "bridge", - )!; - const manifest = buildTraversalManifest(instr); - const decoded = decodeExecutionTrace(manifest, executionTraceId); - const kinds = decoded.map((e) => e.kind); - assert.ok(kinds.includes("primary"), "should include primary paths"); - }); + // Decode and verify + const instr = doc.instructions.find( + (i): i is Bridge => i.kind === "bridge", + )!; + const manifest = buildTraversalManifest(instr); + const decoded = decodeExecutionTrace(manifest, executionTraceId); + const kinds = decoded.map((e) => e.kind); + assert.ok(kinds.includes("primary"), "should include primary paths"); + }); - test("fallback fires — fallback bit is set", async () => { - const doc = getDoc(`version 1.5 + test("fallback fires — fallback bit is set", async () => { + const doc = getDoc(`version 1.5 bridge Query.demo { with api with input as i @@ -747,26 +753,26 @@ bridge Query.demo { api.q <- i.q o.label <- api.label || "default" }`); - const { executionTraceId, data } = await executeBridge({ - document: doc, - operation: "Query.demo", - input: { q: "test" }, - tools: { api: async () => ({ label: null }) }, - }); + const { executionTraceId, data } = await executeBridge({ + document: doc, + operation: "Query.demo", + input: { q: "test" }, + tools: { api: async () => ({ label: null }) }, + }); - assert.equal((data as any).label, "default"); + assert.equal((data as any).label, "default"); - const instr = doc.instructions.find( - (i): i is Bridge => i.kind === "bridge", - )!; - const manifest = buildTraversalManifest(instr); - const decoded = decodeExecutionTrace(manifest, executionTraceId); - const kinds = decoded.map((e) => e.kind); - assert.ok(kinds.includes("fallback"), "should include fallback path"); - }); + const instr = doc.instructions.find( + (i): i is Bridge => i.kind === "bridge", + )!; + const manifest = buildTraversalManifest(instr); + const decoded = decodeExecutionTrace(manifest, executionTraceId); + const kinds = decoded.map((e) => e.kind); + assert.ok(kinds.includes("fallback"), "should include fallback path"); + }); - test("catch fires — catch bit is set", async () => { - const doc = getDoc(`version 1.5 + test("catch fires — catch bit is set", async () => { + const doc = getDoc(`version 1.5 bridge Query.demo { with api with input as i @@ -774,30 +780,30 @@ bridge Query.demo { api.q <- i.q o.lat <- api.lat catch 0 }`); - const { executionTraceId, data } = await executeBridge({ - document: doc, - operation: "Query.demo", - input: { q: "test" }, - tools: { - api: async () => { - throw new Error("boom"); - }, + const { executionTraceId, data } = await executeBridge({ + document: doc, + operation: "Query.demo", + input: { q: "test" }, + tools: { + api: async () => { + throw new Error("boom"); }, - }); + }, + }); - assert.equal((data as any).lat, 0); + assert.equal((data as any).lat, 0); - const instr = doc.instructions.find( - (i): i is Bridge => i.kind === "bridge", - )!; - const manifest = buildTraversalManifest(instr); - const decoded = decodeExecutionTrace(manifest, executionTraceId); - const kinds = decoded.map((e) => e.kind); - assert.ok(kinds.includes("catch"), "should include catch path"); - }); + const instr = doc.instructions.find( + (i): i is Bridge => i.kind === "bridge", + )!; + const manifest = buildTraversalManifest(instr); + const decoded = decodeExecutionTrace(manifest, executionTraceId); + const kinds = decoded.map((e) => e.kind); + assert.ok(kinds.includes("catch"), "should include catch path"); + }); - test("ternary — then branch bit is set", async () => { - const doc = getDoc(`version 1.5 + test("ternary — then branch bit is set", async () => { + const doc = getDoc(`version 1.5 bridge Query.demo { with api with input as i @@ -805,25 +811,25 @@ bridge Query.demo { api.q <- i.q o.label <- i.flag ? api.a : api.b }`); - const { executionTraceId } = await executeBridge({ - document: doc, - operation: "Query.demo", - input: { q: "test", flag: true }, - tools: { api: async () => ({ a: "yes", b: "no" }) }, - }); - - const instr = doc.instructions.find( - (i): i is Bridge => i.kind === "bridge", - )!; - const manifest = buildTraversalManifest(instr); - const decoded = decodeExecutionTrace(manifest, executionTraceId); - const kinds = decoded.map((e) => e.kind); - assert.ok(kinds.includes("then"), "should include then path"); - assert.ok(!kinds.includes("else"), "should NOT include else path"); + const { executionTraceId } = await executeBridge({ + document: doc, + operation: "Query.demo", + input: { q: "test", flag: true }, + tools: { api: async () => ({ a: "yes", b: "no" }) }, }); - test("ternary — else branch bit is set", async () => { - const doc = getDoc(`version 1.5 + const instr = doc.instructions.find( + (i): i is Bridge => i.kind === "bridge", + )!; + const manifest = buildTraversalManifest(instr); + const decoded = decodeExecutionTrace(manifest, executionTraceId); + const kinds = decoded.map((e) => e.kind); + assert.ok(kinds.includes("then"), "should include then path"); + assert.ok(!kinds.includes("else"), "should NOT include else path"); + }); + + test("ternary — else branch bit is set", async () => { + const doc = getDoc(`version 1.5 bridge Query.demo { with api with input as i @@ -831,49 +837,49 @@ bridge Query.demo { api.q <- i.q o.label <- i.flag ? api.a : api.b }`); - const { executionTraceId } = await executeBridge({ - document: doc, - operation: "Query.demo", - input: { q: "test", flag: false }, - tools: { api: async () => ({ a: "yes", b: "no" }) }, - }); - - const instr = doc.instructions.find( - (i): i is Bridge => i.kind === "bridge", - )!; - const manifest = buildTraversalManifest(instr); - const decoded = decodeExecutionTrace(manifest, executionTraceId); - const kinds = decoded.map((e) => e.kind); - assert.ok(kinds.includes("else"), "should include else path"); - assert.ok(!kinds.includes("then"), "should NOT include then path"); + const { executionTraceId } = await executeBridge({ + document: doc, + operation: "Query.demo", + input: { q: "test", flag: false }, + tools: { api: async () => ({ a: "yes", b: "no" }) }, }); - test("constant wire — const bit is set", async () => { - const doc = getDoc(`version 1.5 + const instr = doc.instructions.find( + (i): i is Bridge => i.kind === "bridge", + )!; + const manifest = buildTraversalManifest(instr); + const decoded = decodeExecutionTrace(manifest, executionTraceId); + const kinds = decoded.map((e) => e.kind); + assert.ok(kinds.includes("else"), "should include else path"); + assert.ok(!kinds.includes("then"), "should NOT include then path"); + }); + + test("constant wire — const bit is set", async () => { + const doc = getDoc(`version 1.5 bridge Query.demo { with api with output as o api.mode = "fast" o.result <- api.label }`); - const { executionTraceId } = await executeBridge({ - document: doc, - operation: "Query.demo", - input: {}, - tools: { api: async () => ({ label: "done" }) }, - }); - - const instr = doc.instructions.find( - (i): i is Bridge => i.kind === "bridge", - )!; - const manifest = buildTraversalManifest(instr); - const decoded = decodeExecutionTrace(manifest, executionTraceId); - const kinds = decoded.map((e) => e.kind); - assert.ok(kinds.includes("const"), "should include const path"); + const { executionTraceId } = await executeBridge({ + document: doc, + operation: "Query.demo", + input: {}, + tools: { api: async () => ({ label: "done" }) }, }); - test("executionTraceId is a bigint suitable for hex encoding", async () => { - const doc = getDoc(`version 1.5 + const instr = doc.instructions.find( + (i): i is Bridge => i.kind === "bridge", + )!; + const manifest = buildTraversalManifest(instr); + const decoded = decodeExecutionTrace(manifest, executionTraceId); + const kinds = decoded.map((e) => e.kind); + assert.ok(kinds.includes("const"), "should include const path"); + }); + + test("executionTraceId is a bigint suitable for hex encoding", async () => { + const doc = getDoc(`version 1.5 bridge Query.demo { with api with input as i @@ -881,81 +887,78 @@ bridge Query.demo { api.q <- i.q o.result <- api.label }`); - const { executionTraceId } = await executeBridge({ - document: doc, - operation: "Query.demo", - input: { q: "Berlin" }, - tools: { api: async () => ({ label: "Berlin" }) }, - }); - - assert.equal(typeof executionTraceId, "bigint"); - const hex = `0x${executionTraceId.toString(16)}`; - assert.ok(hex.startsWith("0x"), "should be hex-encodable"); + const { executionTraceId } = await executeBridge({ + document: doc, + operation: "Query.demo", + input: { q: "Berlin" }, + tools: { api: async () => ({ label: "Berlin" }) }, }); - test("primary error bit is set when tool throws", async () => { - const doc = getDoc(`version 1.5 -bridge Query.demo { - with api - with input as i - with output as o - api.q <- i.q - o.lat <- api.lat -}`); - try { - await executeBridge({ - document: doc, - operation: "Query.demo", - input: { q: "test" }, - tools: { - api: async () => { - throw new Error("boom"); - }, - }, - }); - assert.fail("should have thrown"); - } catch (err: any) { - const executionTraceId: bigint = err.executionTraceId; - assert.ok( - typeof executionTraceId === "bigint", - "error should carry executionTraceId", - ); - - const instr = doc.instructions.find( - (i): i is Bridge => i.kind === "bridge", - )!; - const manifest = buildTraversalManifest(instr); - const decoded = decodeExecutionTrace(manifest, executionTraceId); - const primaryError = decoded.find( - (e) => e.kind === "primary" && e.error, - ); - assert.ok(primaryError, "primary error bit should be set"); - } - }); + assert.equal(typeof executionTraceId, "bigint"); + const hex = `0x${executionTraceId.toString(16)}`; + assert.ok(hex.startsWith("0x"), "should be hex-encodable"); + }); - test("no error bit when tool succeeds", async () => { - const doc = getDoc(`version 1.5 + test("primary error bit is set when tool throws", async () => { + const doc = getDoc(`version 1.5 bridge Query.demo { with api with input as i with output as o api.q <- i.q - o.result <- api.value + o.lat <- api.lat }`); - const { executionTraceId } = await executeBridge({ + try { + await executeBridge({ document: doc, operation: "Query.demo", input: { q: "test" }, - tools: { api: async () => ({ value: "ok" }) }, + tools: { + api: async () => { + throw new Error("boom"); + }, + }, }); + assert.fail("should have thrown"); + } catch (err: any) { + const executionTraceId: bigint = err.executionTraceId; + assert.ok( + typeof executionTraceId === "bigint", + "error should carry executionTraceId", + ); const instr = doc.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; const manifest = buildTraversalManifest(instr); const decoded = decodeExecutionTrace(manifest, executionTraceId); - const errorEntries = decoded.filter((e) => e.error); - assert.equal(errorEntries.length, 0, "no error bits when tool succeeds"); + const primaryError = decoded.find((e) => e.kind === "primary" && e.error); + assert.ok(primaryError, "primary error bit should be set"); + } + }); + + test("no error bit when tool succeeds", async () => { + const doc = getDoc(`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 { executionTraceId } = await executeBridge({ + document: doc, + operation: "Query.demo", + input: { q: "test" }, + tools: { api: async () => ({ value: "ok" }) }, }); - }, -); + + const instr = doc.instructions.find( + (i): i is Bridge => i.kind === "bridge", + )!; + const manifest = buildTraversalManifest(instr); + const decoded = decodeExecutionTrace(manifest, executionTraceId); + const errorEntries = decoded.filter((e) => e.error); + assert.equal(errorEntries.length, 0, "no error bits when tool succeeds"); + }); +}); diff --git a/packages/bridge-core/test/traversal-manifest-locations.test.ts b/packages/bridge-core/test/traversal-manifest-locations.test.ts index af963c4..e8aac77 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" } - >; + + // Body ref entries use expr.loc (the expression's own location span) + const msgPrimaryExpr = messageStmt.sources[0]!.expr; assertLoc( manifest.find((entry) => entry.id === "message/primary"), - msgExpr.refLoc, + msgPrimaryExpr.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" } - >; + const aliasPrimaryExpr = aliasStmt.sources[0]!.expr; assertLoc( manifest.find((entry) => entry.id === "clean/primary"), - aliasExpr.refLoc, + aliasPrimaryExpr.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/test/coalesce-cost.test.ts b/packages/bridge/test/coalesce-cost.test.ts index 5609a00..ef407e8 100644 --- a/packages/bridge/test/coalesce-cost.test.ts +++ b/packages/bridge/test/coalesce-cost.test.ts @@ -370,7 +370,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" } `, diff --git a/packages/bridge/test/runtime-error-format.test.ts b/packages/bridge/test/runtime-error-format.test.ts index 7eba8c8..69a7165 100644 --- a/packages/bridge/test/runtime-error-format.test.ts +++ b/packages/bridge/test/runtime-error-format.test.ts @@ -158,6 +158,7 @@ regressionTest("error formatting – panic fallback", { }); regressionTest("error formatting – ternary branch", { + disable: ["compiled", "parser", "v3"], bridge: bridge` version 1.5 @@ -274,6 +275,7 @@ regressionTest("error formatting – array throw", { }); regressionTest("error formatting – ternary condition", { + disable: ["compiled", "parser", "v3"], bridge: bridge` version 1.5 diff --git a/packages/bridge/test/tool-features.test.ts b/packages/bridge/test/tool-features.test.ts index 9237b6f..529fa47 100644 --- a/packages/bridge/test/tool-features.test.ts +++ b/packages/bridge/test/tool-features.test.ts @@ -15,7 +15,7 @@ import { bridge } from "@stackables/bridge"; // ── 1. Missing tool ───────────────────────────────────────────────────────── regressionTest("tool features: missing tool", { - disable: ["compiled", "parser"], + disable: ["compiled", "parser", "v3"], bridge: bridge` version 1.5 diff --git a/packages/bridge/test/utils/regression.ts b/packages/bridge/test/utils/regression.ts index bb3bea4..d7026c0 100644 --- a/packages/bridge/test/utils/regression.ts +++ b/packages/bridge/test/utils/regression.ts @@ -1175,14 +1175,29 @@ export function regressionTest(name: string, data: RegressionTest) { } }); - afterEach((t) => { - if (t.name !== "runtime") { - return; + let pendingV3Tests = scenarioNames.filter( + (name) => !isDisabled(scenarios[name]!.disable ?? data.disable, "v3"), + ).length; + let resolveV3Collection!: () => void; + + const v3CollectionDone = new Promise((resolve) => { + resolveV3Collection = resolve; + if (pendingV3Tests === 0) { + resolve(); } + }); - pendingRuntimeTests -= 1; - if (pendingRuntimeTests === 0) { - resolveRuntimeCollection(); + afterEach((t) => { + if (t.name === "runtime") { + pendingRuntimeTests -= 1; + if (pendingRuntimeTests === 0) { + resolveRuntimeCollection(); + } + } else if (t.name === "v3") { + pendingV3Tests -= 1; + if (pendingV3Tests === 0) { + resolveV3Collection(); + } } }); @@ -1253,8 +1268,8 @@ export function regressionTest(name: string, data: RegressionTest) { assert.fail("Expected an error but execution succeeded"); } - // Accumulate runtime trace coverage - if (engineName === "runtime") { + // Accumulate v3 trace coverage + if (engineName === "v3") { traceMasks.set( operation, (traceMasks.get(operation) ?? 0n) | executionTraceId, @@ -1287,10 +1302,7 @@ export function regressionTest(name: string, data: RegressionTest) { assertCtx, ); // Accumulate trace from errors too - if ( - engineName === "runtime" && - e.executionTraceId != null - ) { + if (engineName === "v3" && e.executionTraceId != null) { traceMasks.set( operation, (traceMasks.get(operation) ?? 0n) | @@ -1588,16 +1600,16 @@ 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) => - isDisabled(scenarios[name]!.disable ?? data.disable, "runtime"), + const allV3Disabled = scenarioNames.every((name) => + isDisabled(scenarios[name]!.disable ?? data.disable, "v3"), ); - if (allRuntimeDisabled) { - t.skip("all scenarios have runtime disabled"); + if (allV3Disabled) { + t.skip("all scenarios have v3 disabled"); return; } - // Wait for all runtime scenario tests to finish populating traceMasks - await runtimeCollectionDone; + // Wait for all v3 scenario tests to finish populating traceMasks + await v3CollectionDone; const [type, field] = operation.split(".") as [string, string]; const bridge = document.instructions.find( diff --git a/packages/playground/test/trace-highlighting.test.ts b/packages/playground/test/trace-highlighting.test.ts index 0bdc96c..7e9dc1a 100644 --- a/packages/playground/test/trace-highlighting.test.ts +++ b/packages/playground/test/trace-highlighting.test.ts @@ -51,8 +51,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"); From 239ba269da4a195f00b51d7317fed1a83c43a473 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Tue, 17 Mar 2026 08:25:36 +0100 Subject: [PATCH 20/23] V3 is main now --- packages/bridge-core/src/index.ts | 8 +-- .../test/enumerate-traversals.test.ts | 2 +- .../bridge/test/runtime-error-format.test.ts | 22 +++--- packages/bridge/test/tool-features.test.ts | 2 +- packages/bridge/test/utils/regression.ts | 72 +++++++------------ 5 files changed, 42 insertions(+), 64 deletions(-) diff --git a/packages/bridge-core/src/index.ts b/packages/bridge-core/src/index.ts index e9698ca..7a4ebee 100644 --- a/packages/bridge-core/src/index.ts +++ b/packages/bridge-core/src/index.ts @@ -13,11 +13,11 @@ export { bridge } from "./tag.ts"; // ── Runtime engine ────────────────────────────────────────────────────────── -export { executeBridge } from "./execute-bridge.ts"; +export { executeBridge } from "./v3/execute-bridge.ts"; export type { ExecuteBridgeOptions, ExecuteBridgeResult, -} from "./execute-bridge.ts"; +} from "./v3/execute-bridge.ts"; // ── Version check ─────────────────────────────────────────────────────────── @@ -125,7 +125,3 @@ export { matchesRequestedFields, filterOutputFields, } from "./requested-fields.ts"; - -// ── V3 scope-based engine (POC) ───────────────────────────────────────────── - -export { executeBridge as executeBridgeV3 } from "./v3/execute-bridge.ts"; diff --git a/packages/bridge-core/test/enumerate-traversals.test.ts b/packages/bridge-core/test/enumerate-traversals.test.ts index c5a5418..6a0190c 100644 --- a/packages/bridge-core/test/enumerate-traversals.test.ts +++ b/packages/bridge-core/test/enumerate-traversals.test.ts @@ -5,7 +5,7 @@ import { enumerateTraversalIds, buildTraversalManifest, decodeExecutionTrace, - executeBridgeV3 as executeBridge, + executeBridge, } from "@stackables/bridge-core"; import type { Bridge, diff --git a/packages/bridge/test/runtime-error-format.test.ts b/packages/bridge/test/runtime-error-format.test.ts index 69a7165..1599292 100644 --- a/packages/bridge/test/runtime-error-format.test.ts +++ b/packages/bridge/test/runtime-error-format.test.ts @@ -158,7 +158,7 @@ regressionTest("error formatting – panic fallback", { }); regressionTest("error formatting – ternary branch", { - disable: ["compiled", "parser", "v3"], + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -171,7 +171,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 }); @@ -179,12 +179,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, }, @@ -275,7 +278,7 @@ regressionTest("error formatting – array throw", { }); regressionTest("error formatting – ternary condition", { - disable: ["compiled", "parser", "v3"], + disable: ["compiled", "parser"], bridge: bridge` version 1.5 @@ -290,7 +293,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 }); @@ -298,12 +301,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, }, diff --git a/packages/bridge/test/tool-features.test.ts b/packages/bridge/test/tool-features.test.ts index 529fa47..9237b6f 100644 --- a/packages/bridge/test/tool-features.test.ts +++ b/packages/bridge/test/tool-features.test.ts @@ -15,7 +15,7 @@ import { bridge } from "@stackables/bridge"; // ── 1. Missing tool ───────────────────────────────────────────────────────── regressionTest("tool features: missing tool", { - disable: ["compiled", "parser", "v3"], + disable: ["compiled", "parser"], bridge: bridge` version 1.5 diff --git a/packages/bridge/test/utils/regression.ts b/packages/bridge/test/utils/regression.ts index d7026c0..8ea2c24 100644 --- a/packages/bridge/test/utils/regression.ts +++ b/packages/bridge/test/utils/regression.ts @@ -22,10 +22,7 @@ import { type BridgeDocument, } from "../../src/index.ts"; import { bridgeTransform, getBridgeTraces } from "@stackables/bridge-graphql"; -import { - executeBridge as executeRuntime, - executeBridgeV3, -} from "@stackables/bridge-core"; +import { executeBridge as executeRuntime } from "@stackables/bridge-core"; import { executeBridge as executeCompiled, type ExecuteBridgeOptions, @@ -830,8 +827,8 @@ function synthesizeSelectedGraphQLData( * Lets assertions branch on engine or inspect wall-clock timing. */ export type AssertContext = { - /** Which engine is running: "runtime" | "compiled" | "graphql" | "v3". */ - engine: "runtime" | "compiled" | "graphql" | "v3"; + /** Which engine is running: "runtime" | "compiled" | "graphql". */ + engine: "runtime" | "compiled" | "graphql"; /** High-resolution timestamp (ms) captured just before execution started. */ startMs: number; }; @@ -864,9 +861,9 @@ export type Scenario = { * * - `true` — skip this scenario entirely * - explicit array — only listed engines are disabled; unlisted ones run - * - omitted — defaults apply (compiled, parser, v3 are off) + * - omitted — defaults apply (compiled, parser are off) */ - disable?: true | ("runtime" | "compiled" | "graphql" | "parser" | "v3")[]; + disable?: true | ("runtime" | "compiled" | "graphql" | "parser")[]; }; export type RegressionTest = { @@ -880,9 +877,9 @@ export type RegressionTest = { * * - `true` — skip this test entirely * - explicit array — only listed engines are disabled; unlisted ones run - * - omitted — defaults apply (compiled, parser, v3 are off) + * - omitted — defaults apply (compiled, parser are off) */ - disable?: true | ("runtime" | "compiled" | "graphql" | "parser" | "v3")[]; + disable?: true | ("runtime" | "compiled" | "graphql" | "parser")[]; scenarios: Record>; }; @@ -891,7 +888,6 @@ export type RegressionTest = { const engines = [ { name: "runtime", execute: executeRuntime }, { name: "compiled", execute: executeCompiled }, - { name: "v3", execute: executeBridgeV3 as typeof executeRuntime }, ] as const; function assertDataExpectation( @@ -1113,11 +1109,8 @@ export function assertGraphqlExpectation( // ── Harness ───────────────────────────────────────────────────────────────── function isDisabled( - disable: - | true - | ("runtime" | "compiled" | "graphql" | "parser" | "v3")[] - | undefined, - check: "runtime" | "compiled" | "graphql" | "parser" | "v3", + disable: true | ("runtime" | "compiled" | "graphql" | "parser")[] | undefined, + check: "runtime" | "compiled" | "graphql" | "parser", ): boolean { if (disable === true) return true; @@ -1175,29 +1168,12 @@ export function regressionTest(name: string, data: RegressionTest) { } }); - let pendingV3Tests = scenarioNames.filter( - (name) => !isDisabled(scenarios[name]!.disable ?? data.disable, "v3"), - ).length; - let resolveV3Collection!: () => void; - - const v3CollectionDone = new Promise((resolve) => { - resolveV3Collection = resolve; - if (pendingV3Tests === 0) { - resolve(); - } - }); - afterEach((t) => { if (t.name === "runtime") { pendingRuntimeTests -= 1; if (pendingRuntimeTests === 0) { resolveRuntimeCollection(); } - } else if (t.name === "v3") { - pendingV3Tests -= 1; - if (pendingV3Tests === 0) { - resolveV3Collection(); - } } }); @@ -1262,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 v3 trace coverage - if (engineName === "v3") { + // 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, @@ -1302,7 +1275,10 @@ export function regressionTest(name: string, data: RegressionTest) { assertCtx, ); // Accumulate trace from errors too - if (engineName === "v3" && e.executionTraceId != null) { + if ( + engineName === "runtime" && + e.executionTraceId != null + ) { traceMasks.set( operation, (traceMasks.get(operation) ?? 0n) | @@ -1600,16 +1576,16 @@ export function regressionTest(name: string, data: RegressionTest) { // After all scenarios for this operation, verify traversal coverage test("traversal coverage", async (t) => { - const allV3Disabled = scenarioNames.every((name) => - isDisabled(scenarios[name]!.disable ?? data.disable, "v3"), + const allRuntimeDisabled = scenarioNames.every((name) => + isDisabled(scenarios[name]!.disable ?? data.disable, "runtime"), ); - if (allV3Disabled) { - t.skip("all scenarios have v3 disabled"); + if (allRuntimeDisabled) { + t.skip("all scenarios have runtime disabled"); return; } - // Wait for all v3 scenario tests to finish populating traceMasks - await v3CollectionDone; + // Wait for all runtime scenario tests to finish populating traceMasks + await runtimeCollectionDone; const [type, field] = operation.split(".") as [string, string]; const bridge = document.instructions.find( From 5ee1c405a8ade251a0fc4147e97fa634f886ec7a Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Tue, 17 Mar 2026 10:47:04 +0100 Subject: [PATCH 21/23] Park --- packages/bridge-core/src/v3/execute-bridge.ts | 204 ++++++-- .../bridge-graphql/src/bridge-transform.ts | 484 +++++------------- .../bridge-graphql/test/executeGraph.test.ts | 22 +- packages/bridge-graphql/test/logging.test.ts | 11 +- packages/bridge/test/utils/regression.ts | 1 + 5 files changed, 311 insertions(+), 411 deletions(-) diff --git a/packages/bridge-core/src/v3/execute-bridge.ts b/packages/bridge-core/src/v3/execute-bridge.ts index bcd0070..4db878c 100644 --- a/packages/bridge-core/src/v3/execute-bridge.ts +++ b/packages/bridge-core/src/v3/execute-bridge.ts @@ -116,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 = { @@ -328,6 +340,15 @@ class ExecutionScope { /** Define input wires indexed by "module:field" key. */ private readonly defineInputWires = new Map(); + /** + * 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; @@ -656,15 +677,10 @@ class ExecutionScope { ); const fnName = toolDef?.fn ?? toolName; const fn = lookupToolFn(this.engine.tools, fnName); - if (!fn) throw new Error(`No tool found for "${fnName}"`); - const { - doTrace, - sync: isSyncTool, - batch: batchMeta, - log: toolLog, - } = resolveToolMeta(fn); // 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 = {}; @@ -686,6 +702,14 @@ class ExecutionScope { }), ); + 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(); @@ -888,12 +912,18 @@ class ExecutionScope { /** * 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}`; @@ -911,17 +941,52 @@ class ExecutionScope { if (this.ownedDefines.has(module)) { // 3. Branch the path const nextPath = new Set(pullPath).add(key); - return this.executeDefine(key, module, nextPath); + return this.executeDefine(key, module, nextPath, subFields); } // Delegate to parent if (this.parent) { - return this.parent.resolveDefine(module, field, instance, pullPath); + 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.split("."), value); + return value; + }); + this.lazyInputCache.set(pathKey, cached); + } + return cached; + } + /** * Execute a define block — build input from bridge wires, create * child scope with define body, pull output. @@ -930,6 +995,7 @@ class ExecutionScope { key: string, module: string, pullPath: ReadonlySet, + subFields?: string[], ): Promise { const promise = (async () => { // Map from handle alias to define name via handle bindings @@ -943,22 +1009,11 @@ class ExecutionScope { if (!defineDef?.body) throw new Error(`Define "${defineName}" not found or has no body`); - // Collect bridge wires targeting this define (input wires) + // 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 = {}; - await Promise.all( - inputWires.map(async (wire) => { - const value = await evaluateSourceChain( - wire, - this, - undefined, - pullPath, - ); - setPath(defineInput, wire.target.path, value); - }), - ); - - // Create child scope with define input as selfInput const defineOutput: Record = {}; const defineScope = new ExecutionScope( this, @@ -968,9 +1023,21 @@ class ExecutionScope { ); defineScope.isRootScope = true; - // Index define body and pull output + // Register each input wire as a lazy factory so it only fires when + // the define body actually reads that selfInput field. + const parentScope = this; + for (const wire of inputWires) { + const pathKey = wire.target.path.join("."); + defineScope.registerLazyInput(pathKey, () => + evaluateSourceChain(wire, parentScope, undefined, pullPath), + ); + } + + // 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, [], pullPath); + await resolveRequestedFields(defineScope, subFields ?? [], pullPath); return "__rootValue__" in defineOutput ? defineOutput.__rootValue__ @@ -1003,6 +1070,8 @@ interface EngineContext { >; /** 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. */ @@ -1531,14 +1600,32 @@ async function resolveRequestedFields( if (isLoopControlSignal(value)) return value; if (value != null) break; // First non-null wins } catch (err) { - if (isFatalError(err)) throw err; + // With partialSuccess, even fatal errors are scoped to the field — + // they become per-field Error Sentinels instead of killing the whole + // execution. Without partialSuccess, fatal errors always propagate. + if (isFatalError(err) && !scope.engine.partialSuccess) throw err; lastError = err; // Continue to next wire — maybe a cheaper fallback succeeds } } - // If all wires returned null and there was an error, throw it - if (value == null && lastError) throw lastError; + // THE FIX: If all wires returned null/undefined and there was an error, + // plant the error as an Error Sentinel in the output tree instead of + // throwing. This allows GraphQL to deliver partial success — the field + // becomes null with an error entry, while sibling fields still resolve. + 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; @@ -1599,6 +1686,9 @@ async function resolveRequestedFields( if (isFatalError(result.reason)) { if (!fatalError) fatalError = result.reason; } else { + // Collect non-fatal errors. With partialSuccess, evaluation errors + // become sentinels (no rejection), so only unplantable writeTarget + // failures reach here — those should always surface. if (!firstError) firstError = result.reason; } } else if (result.value != null) { @@ -1914,10 +2004,22 @@ async function evaluateExpression( case "ref": if (expr.safe) { return evaluateExprSafe(() => - resolveRef(expr.ref, scope, expr.refLoc ?? expr.loc, pullPath), + resolveRef( + expr.ref, + scope, + expr.refLoc ?? expr.loc, + pullPath, + requestedFields, + ), ); } - return resolveRef(expr.ref, scope, expr.refLoc ?? expr.loc, pullPath); + return resolveRef( + expr.ref, + scope, + expr.refLoc ?? expr.loc, + pullPath, + requestedFields, + ); case "literal": return expr.value; @@ -2303,6 +2405,7 @@ async function resolveRef( scope: ExecutionScope, bridgeLoc?: SourceLocation, pullPath: ReadonlySet = EMPTY_PULL_PATH, + requestedFields?: string[], ): Promise { // Element reference — reading from array iterator binding if (ref.element) { @@ -2333,17 +2436,55 @@ async function resolveRef( // 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 + // 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) + if (ref.path.length > 1) { + for (let len = ref.path.length - 1; len >= 1; 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); } @@ -2468,6 +2609,7 @@ export async function executeBridge( toolMemoCache: new Map(), toolBatchQueues: new Map(), maxDepth: options.maxDepth ?? MAX_EXECUTION_DEPTH, + partialSuccess: options.partialSuccess ?? false, traceBits: chainBitsMap, emptyArrayBits, traceMask, diff --git a/packages/bridge-graphql/src/bridge-transform.ts b/packages/bridge-graphql/src/bridge-transform.ts index cea8328..9698a3c 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 a390c02..ec08e2b 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 d9f5ecd..73a4e7b 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/test/utils/regression.ts b/packages/bridge/test/utils/regression.ts index 8ea2c24..09df7bb 100644 --- a/packages/bridge/test/utils/regression.ts +++ b/packages/bridge/test/utils/regression.ts @@ -1475,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( From 9168d368626443acb17623235a544eaaba141cf7 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Tue, 17 Mar 2026 10:59:58 +0100 Subject: [PATCH 22/23] Bugfixes --- packages/bridge-core/src/v3/execute-bridge.ts | 111 +++++++++++++++--- .../test/bugfixes/overdef-input-race.test.ts | 82 +++++++++++++ 2 files changed, 175 insertions(+), 18 deletions(-) create mode 100644 packages/bridge/test/bugfixes/overdef-input-race.test.ts diff --git a/packages/bridge-core/src/v3/execute-bridge.ts b/packages/bridge-core/src/v3/execute-bridge.ts index 4db878c..abbc4ec 100644 --- a/packages/bridge-core/src/v3/execute-bridge.ts +++ b/packages/bridge-core/src/v3/execute-bridge.ts @@ -690,15 +690,31 @@ class ExecutionScope { } const wires = this.toolInputWires.get(key) ?? []; + const wireGroups = groupWiresByPath(wires); await Promise.all( - wires.map(async (wire) => { - const value = await evaluateSourceChain( - wire, - this, - undefined, - pullPath, - ); - setPath(input, wire.target.path, value); + 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; }), ); @@ -1023,14 +1039,36 @@ class ExecutionScope { ); defineScope.isRootScope = true; - // Register each input wire as a lazy factory so it only fires when - // the define body actually reads that selfInput field. + // 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; - for (const wire of inputWires) { - const pathKey = wire.target.path.join("."); - defineScope.registerLazyInput(pathKey, () => - evaluateSourceChain(wire, parentScope, undefined, pullPath), - ); + 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. @@ -1701,6 +1739,24 @@ async function resolveRequestedFields( 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. @@ -2314,10 +2370,29 @@ async function evaluatePipeExpression( // 6b. Bridge wires for this tool (non-pipe input wires) const bridgeWires = scope.collectToolInputWiresFor(toolName); + const bridgeWireGroups = groupWiresByPath(bridgeWires); await Promise.all( - bridgeWires.map(async (wire) => { - const value = await evaluateSourceChain(wire, scope, undefined, nextPath); - setPath(input, wire.target.path, value); + 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; }), ); 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 0000000..cbdfade --- /dev/null +++ b/packages/bridge/test/bugfixes/overdef-input-race.test.ts @@ -0,0 +1,82 @@ +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", { + disable: ["compiled", "parser"], + 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: () => {}, + }, + }, + }, +}); From f213523688fc57dec8915822d96ce97a4c0a4953 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Tue, 17 Mar 2026 11:07:28 +0100 Subject: [PATCH 23/23] Fix e2e tests --- packages/bridge-core/src/v3/execute-bridge.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/bridge-core/src/v3/execute-bridge.ts b/packages/bridge-core/src/v3/execute-bridge.ts index abbc4ec..b874786 100644 --- a/packages/bridge-core/src/v3/execute-bridge.ts +++ b/packages/bridge-core/src/v3/execute-bridge.ts @@ -995,7 +995,7 @@ class ExecutionScope { if (!cached) { cached = factory().then((value) => { // Hydrate selfInput so subsequent getPath reads work - setPath(this.selfInput, pathKey.split("."), value); + setPath(this.selfInput, pathKey ? pathKey.split(".") : [], value); return value; }); this.lazyInputCache.set(pathKey, cached); @@ -2549,15 +2549,14 @@ async function resolveRef( 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) - if (ref.path.length > 1) { - for (let len = ref.path.length - 1; len >= 1; 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); - } + // 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);