From 8185c38a434ad5a6626635ed29748e8e2b7dc576 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Sat, 7 Mar 2026 14:58:06 +0100 Subject: [PATCH 1/8] Store SourceLocation --- docs/runtime-error-source-mapping-plan.md | 172 ++++ packages/bridge-core/src/index.ts | 1 + packages/bridge-core/src/types.ts | 9 +- packages/bridge-parser/src/parser/parser.ts | 743 ++++++++++++------ packages/bridge-types/src/index.ts | 7 + packages/bridge/test/bridge-format.test.ts | 111 +-- packages/bridge/test/coalesce-cost.test.ts | 49 +- packages/bridge/test/control-flow.test.ts | 76 +- packages/bridge/test/engine-hardening.test.ts | 6 +- packages/bridge/test/force-wire.test.ts | 13 +- packages/bridge/test/parse-test-utils.ts | 28 + packages/bridge/test/path-scoping.test.ts | 67 +- packages/bridge/test/resilience.test.ts | 57 +- packages/bridge/test/source-locations.test.ts | 73 ++ 14 files changed, 1031 insertions(+), 381 deletions(-) create mode 100644 docs/runtime-error-source-mapping-plan.md create mode 100644 packages/bridge/test/parse-test-utils.ts create mode 100644 packages/bridge/test/source-locations.test.ts diff --git a/docs/runtime-error-source-mapping-plan.md b/docs/runtime-error-source-mapping-plan.md new file mode 100644 index 00000000..2846ec34 --- /dev/null +++ b/docs/runtime-error-source-mapping-plan.md @@ -0,0 +1,172 @@ +# Runtime Error Source Mapping Plan + +## Goal + +When a runtime error occurs during Bridge execution, surface the `.bridge` source location instead of an engine-internal stack frame. + +Target experience: + +```text +Bridge Execution Error: Cannot read properties of undefined (reading 'name') + --> src/catalog.bridge:14:5 + | +13 | o.items <- catalog.results[] as item { +14 | .name <- item.details.name ?? panic "Missing name" + | ^^^^^^^^^^^^^^^^^^^^^^^^^^ +15 | .price <- item.price +``` + +## Current Code Reality + +### Shared Types + +- `packages/bridge-types/src/index.ts` does not currently define a shared source-location type. +- `packages/bridge-core/src/types.ts` owns the `Wire` union and already re-exports shared types from `@stackables/bridge-types`. + +### Parser + +- `packages/bridge-parser/src/parser/parser.ts` already has token helpers: + - `line(...)` + - `findFirstToken(...)` + - `collectTokens(...)` +- `BridgeParseResult` already exposes `startLines: Map`. +- Wire construction is spread across multiple helpers, not only `buildBridgeBody(...)`: + - `processElementLines(...)` + - `processElementHandleWires(...)` + - `processScopeLines(...)` + - top-level alias handling in `buildBridgeBody(...)` + - top-level bridge wire handling in `buildBridgeBody(...)` + - synthetic wire emitters: + - `desugarTemplateString(...)` + - `desugarExprChain(...)` + - `desugarNot(...)` +- Define inlining clones wires from `defineDef.wires`, so wire-level locations should survive automatically if present on the parsed wires. + +### Runtime + +- Runtime errors currently propagate through: + - `packages/bridge-core/src/resolveWires.ts` + - `packages/bridge-core/src/ExecutionTree.ts` + - `packages/bridge-core/src/tree-types.ts` +- Fatal-error classification currently lives in `isFatalError(...)` in `tree-types.ts`. +- `resolveWiresAsync(...)` already has the right catch boundary for associating a thrown error with the active wire. +- `ExecutionTree.applyPath(...)` is still the main source of bad-path traversal errors. + +### Public Exports + +- `packages/bridge-core/src/index.ts` is the public export surface for new runtime error helpers. +- `packages/bridge/src/index.ts` re-exports `@stackables/bridge-core`, so new core exports automatically flow through the umbrella package. + +### Tests + +- Parser tests do not live under `packages/bridge-parser/test` in this repo. +- Parser and language tests run from `packages/bridge/test/*.test.ts` via the umbrella package. +- New parser coverage for wire locations should therefore be added under `packages/bridge/test/`. + +## Dependency Order + +```text +Phase 1 (shared type + parser wire locs) + ↓ +Phase 2 (runtime enrichment + formatter) + ↓ +Phase 3 (compiler loc stamping) + ↓ +Phase 4 (source text + filename threading in user-facing entry points) +``` + +Phase 1 is the only prerequisite for the later phases. + +## Phase 1 — Shared Wire Locations + +### Scope + +Files: + +- `packages/bridge-types/src/index.ts` +- `packages/bridge-core/src/types.ts` +- `packages/bridge-core/src/index.ts` +- `packages/bridge-parser/src/parser/parser.ts` +- `packages/bridge/test/source-locations.test.ts` (new) + +### Plan + +1. Add a shared `SourceLocation` type in `bridge-types`. +2. Add optional `loc?: SourceLocation` to every `Wire` union arm in `bridge-core/src/types.ts`. +3. Re-export `SourceLocation` from `bridge-core`. +4. Add parser helpers for building locations from CST/token spans. +5. Stamp `loc` on all parser-emitted wire variants, including synthetic/desugared wires: + - constant wires + - pull wires + - ternary wires + - `condAnd` / `condOr` wires + - spread wires + - alias wires + - template-string concat forks + - expression/not synthetic forks +6. Add tests in `packages/bridge/test/source-locations.test.ts` covering representative wire variants and one desugared/internal path. + +### Acceptance + +- Parsed wires carry `loc` data with 1-based line/column spans. +- Existing parser behavior remains unchanged apart from the added metadata. +- `define`-inlined wires preserve locations because cloning keeps the `loc` field. + +## Phase 2 — Runtime Error Enrichment + +### Scope + +Files: + +- `packages/bridge-core/src/tree-types.ts` +- `packages/bridge-core/src/resolveWires.ts` +- `packages/bridge-core/src/ExecutionTree.ts` +- `packages/bridge-core/src/formatBridgeError.ts` (new) +- `packages/bridge-core/src/index.ts` +- `packages/bridge-core/test` coverage currently lives through `packages/bridge/test`, so new runtime tests can be placed there unless a direct core test harness is introduced first. + +### Plan + +1. Introduce `BridgeRuntimeError` in `tree-types.ts` with optional `bridgeLoc` and `cause`. +2. In `resolveWiresAsync(...)`, wrap non-fatal errors with `BridgeRuntimeError` using the active wire's `loc`, preserving the innermost location when one already exists. +3. Thread optional `loc` into `ExecutionTree.applyPath(...)` so bad-path traversal errors can be stamped at the point of throw. +4. Add `formatBridgeError(...)` to render a compact location header and optional source snippet. +5. Export the new error class and formatter from `bridge-core`. + +## Phase 3 — AOT Compiler Loc Stamping + +### Scope + +Files: + +- `packages/bridge-compiler/src/codegen.ts` +- compiler tests under `packages/bridge-compiler/test/` + +### Plan + +1. Thread `w.loc` into emitted try/catch templates for compiled wire execution. +2. Stamp `bridgeLoc` only when the caught error does not already carry one. +3. Skip fire-and-forget branches that intentionally swallow errors. + +## Phase 4 — Source Text Threading + +### Scope + +Likely files: + +- execution options in `bridge-core` +- `packages/bridge-graphql` +- `examples/without-graphql` +- any other user-facing entry points that print runtime errors + +### Plan + +1. Add optional `source` and `filename` to execution options. +2. Call `formatBridgeError(...)` in GraphQL and CLI-style entry points. +3. Add one end-to-end test that verifies a formatted snippet is surfaced from a real `.bridge` source. + +## Notes + +- This plan does not include JS source maps for compiled output. +- Locations are attached at the Bridge DSL layer only, not inside tool implementations. +- Parser coverage should prefer representative cases over exhaustively asserting every construction site line-by-line. diff --git a/packages/bridge-core/src/index.ts b/packages/bridge-core/src/index.ts index 650a050b..6f0c1793 100644 --- a/packages/bridge-core/src/index.ts +++ b/packages/bridge-core/src/index.ts @@ -56,6 +56,7 @@ export type { HandleBinding, Instruction, NodeRef, + SourceLocation, ToolCallFn, ToolContext, ToolDef, diff --git a/packages/bridge-core/src/types.ts b/packages/bridge-core/src/types.ts index 0485e82b..803589a9 100644 --- a/packages/bridge-core/src/types.ts +++ b/packages/bridge-core/src/types.ts @@ -1,3 +1,5 @@ +type SourceLocation = import("@stackables/bridge-types").SourceLocation; + /** * Structured node reference — identifies a specific data point in the execution graph. * @@ -58,6 +60,7 @@ export type Wire = | { from: NodeRef; to: NodeRef; + loc?: SourceLocation; pipe?: true; /** When true, this wire merges source properties into target (from `...source` syntax). */ spread?: true; @@ -67,7 +70,7 @@ export type Wire = catchFallbackRef?: NodeRef; catchControl?: ControlFlowInstruction; } - | { value: string; to: NodeRef } + | { value: string; to: NodeRef; loc?: SourceLocation } | { cond: NodeRef; thenRef?: NodeRef; @@ -75,6 +78,7 @@ export type Wire = elseRef?: NodeRef; elseValue?: string; to: NodeRef; + loc?: SourceLocation; fallbacks?: WireFallback[]; catchFallback?: string; catchFallbackRef?: NodeRef; @@ -90,6 +94,7 @@ export type Wire = rightSafe?: true; }; to: NodeRef; + loc?: SourceLocation; fallbacks?: WireFallback[]; catchFallback?: string; catchFallbackRef?: NodeRef; @@ -105,6 +110,7 @@ export type Wire = rightSafe?: true; }; to: NodeRef; + loc?: SourceLocation; fallbacks?: WireFallback[]; catchFallback?: string; catchFallbackRef?: NodeRef; @@ -246,6 +252,7 @@ export type { ToolMap, ToolMetadata, CacheStore, + SourceLocation, } from "@stackables/bridge-types"; /** diff --git a/packages/bridge-parser/src/parser/parser.ts b/packages/bridge-parser/src/parser/parser.ts index 7604a763..b00b6cc7 100644 --- a/packages/bridge-parser/src/parser/parser.ts +++ b/packages/bridge-parser/src/parser/parser.ts @@ -77,6 +77,7 @@ import type { HandleBinding, Instruction, NodeRef, + SourceLocation, ToolDef, ToolDep, ToolWire, @@ -1501,6 +1502,20 @@ 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, + }; +} + /* ── extractNameToken: get string from nameToken CST node ── */ function extractNameToken(node: CstNode): string { const c = node.children; @@ -1615,6 +1630,24 @@ function findFirstToken(node: CstNode): IToken | undefined { return undefined; } +function findLastToken(node: CstNode): IToken | undefined { + const tokens: IToken[] = []; + collectTokens(node, tokens); + if (tokens.length === 0) return undefined; + tokens.sort((left, right) => left.startOffset - right.startOffset); + return tokens[tokens.length - 1]; +} + +function locFromNode(node: CstNode | undefined): SourceLocation | undefined { + if (!node) return undefined; + return makeLoc(findFirstToken(node), findLastToken(node)); +} + +function withLoc(wire: T, loc: SourceLocation | undefined): T { + if (!loc) return wire; + return { ...wire, loc } as T; +} + /* ── parsePath: split "a.b[0].c" → ["a","b","0","c"] ── */ function parsePath(text: string): string[] { return text.split(/\.|\[|\]/).filter(Boolean); @@ -1776,6 +1809,7 @@ function processElementLines( lineNum: number, iterScope?: string | string[], safe?: boolean, + loc?: SourceLocation, ) => NodeRef, extractTernaryBranchFn: ( branchNode: CstNode, @@ -1799,17 +1833,20 @@ function processElementLines( segs: TemplateSeg[], lineNum: number, iterScope?: string | string[], + loc?: SourceLocation, ) => NodeRef, desugarNotFn: ( sourceRef: NodeRef, lineNum: number, safe?: boolean, + loc?: SourceLocation, ) => NodeRef, resolveParenExprFn: ( parenNode: CstNode, lineNum: number, iterScope?: string | string[], safe?: boolean, + loc?: SourceLocation, ) => NodeRef, ): void { const iterNames = Array.isArray(iterScope) ? iterScope : [iterScope]; @@ -1864,6 +1901,7 @@ function processElementLines( for (const elemLine of elemLines) { const elemC = elemLine.children; const elemLineNum = line(findFirstToken(elemLine)); + const elemLineLoc = locFromNode(elemLine); const elemTargetPathStr = extractDottedPathStr( sub(elemLine, "elemTarget")!, ); @@ -1871,16 +1909,21 @@ function processElementLines( if (elemC.elemEquals) { const value = extractBareValue(sub(elemLine, "elemValue")!); - wires.push({ - value, - to: { - module: SELF_MODULE, - type: bridgeType, - field: bridgeField, - element: true, - path: elemToPath, - }, - }); + wires.push( + withLoc( + { + value, + to: { + module: SELF_MODULE, + type: bridgeType, + field: bridgeField, + element: true, + path: elemToPath, + }, + }, + elemLineLoc, + ), + ); } else if (elemC.elemArrow) { // ── String source in element context: .field <- "..." ── const elemStrToken = ( @@ -1946,16 +1989,24 @@ function processElementLines( segs, elemLineNum, iterNames, + elemLineLoc, ); const elemToRefWithElement: NodeRef = { ...elemToRef, element: true }; - wires.push({ - from: concatOutRef, - to: elemToRefWithElement, - pipe: true, - ...lastAttrs, - }); + wires.push( + withLoc( + { + from: concatOutRef, + to: elemToRefWithElement, + pipe: true, + ...lastAttrs, + }, + elemLineLoc, + ), + ); } else { - wires.push({ value: raw, to: elemToRef, ...lastAttrs }); + wires.push( + withLoc({ value: raw, to: elemToRef, ...lastAttrs }, elemLineLoc), + ); } wires.push(...fallbackInternalWires); wires.push(...catchFallbackInternalWires); @@ -2006,7 +2057,9 @@ function processElementLines( field: bridgeField, path: elemToPath, }; - wires.push({ from: innerFromRef, to: innerToRef }); + wires.push( + withLoc({ from: innerFromRef, to: innerToRef }, elemLineLoc), + ); // Register the inner iterator const innerIterName = extractNameToken( @@ -2092,6 +2145,7 @@ function processElementLines( elemLineNum, iterNames, elemSafe || undefined, + elemLineLoc, ); } else { elemCondRef = parenRef; @@ -2117,6 +2171,7 @@ function processElementLines( elemLineNum, iterNames, elemSafe || undefined, + elemLineLoc, ); elemCondIsPipeFork = true; } else if (elemPipeSegs.length === 0) { @@ -2146,6 +2201,7 @@ function processElementLines( elemCondRef, elemLineNum, elemSafe || undefined, + elemLineLoc, ); elemCondIsPipeFork = true; } @@ -2208,24 +2264,29 @@ function processElementLines( } } - wires.push({ - cond: elemCondRef, - ...(thenBranch.kind === "ref" - ? { thenRef: thenBranch.ref } - : { thenValue: thenBranch.value }), - ...(elseBranch.kind === "ref" - ? { elseRef: elseBranch.ref } - : { elseValue: elseBranch.value }), - ...(elemFallbacks.length > 0 ? { fallbacks: elemFallbacks } : {}), - ...(elemCatchFallback !== undefined - ? { catchFallback: elemCatchFallback } - : {}), - ...(elemCatchFallbackRef !== undefined - ? { catchFallbackRef: elemCatchFallbackRef } - : {}), - ...(elemCatchControl ? { catchControl: elemCatchControl } : {}), - to: elemToRef, - }); + wires.push( + withLoc( + { + cond: elemCondRef, + ...(thenBranch.kind === "ref" + ? { thenRef: thenBranch.ref } + : { thenValue: thenBranch.value }), + ...(elseBranch.kind === "ref" + ? { elseRef: elseBranch.ref } + : { elseValue: elseBranch.value }), + ...(elemFallbacks.length > 0 ? { fallbacks: elemFallbacks } : {}), + ...(elemCatchFallback !== undefined + ? { catchFallback: elemCatchFallback } + : {}), + ...(elemCatchFallbackRef !== undefined + ? { catchFallbackRef: elemCatchFallbackRef } + : {}), + ...(elemCatchControl ? { catchControl: elemCatchControl } : {}), + to: elemToRef, + }, + elemLineLoc, + ), + ); wires.push(...elemFallbackInternalWires); wires.push(...elemCatchFallbackInternalWires); continue; @@ -2281,7 +2342,9 @@ function processElementLines( ...(catchFallbackRef ? { catchFallbackRef } : {}), ...(catchControl ? { catchControl } : {}), }; - wires.push({ from: fromRef, to: elemToRef, ...wireAttrs }); + wires.push( + withLoc({ from: fromRef, to: elemToRef, ...wireAttrs }, elemLineLoc), + ); wires.push(...fallbackInternalWires); wires.push(...catchFallbackInternalWires); } else if (elemC.elemScopeBlock) { @@ -2299,18 +2362,23 @@ function processElementLines( const actualNode = pipeNodes.length > 0 ? pipeNodes[pipeNodes.length - 1]! : headNode; const { safe: spreadSafe } = extractAddressPath(actualNode); - wires.push({ - from: fromRef, - to: { - module: SELF_MODULE, - type: bridgeType, - field: bridgeField, - element: true, - path: elemToPath, - }, - spread: true as const, - ...(spreadSafe ? { safe: true as const } : {}), - }); + wires.push( + withLoc( + { + from: fromRef, + to: { + module: SELF_MODULE, + type: bridgeType, + field: bridgeField, + element: true, + path: elemToPath, + }, + spread: true as const, + ...(spreadSafe ? { safe: true as const } : {}), + }, + locFromNode(spreadLine), + ), + ); } processElementScopeLines( scopeLines, @@ -2367,6 +2435,7 @@ function processElementScopeLines( lineNum: number, iterScope?: string | string[], safe?: boolean, + loc?: SourceLocation, ) => NodeRef, extractTernaryBranchFn?: ( branchNode: CstNode, @@ -2377,17 +2446,20 @@ function processElementScopeLines( segs: TemplateSeg[], lineNum: number, iterScope?: string | string[], + loc?: SourceLocation, ) => NodeRef, desugarNotFn?: ( sourceRef: NodeRef, lineNum: number, safe?: boolean, + loc?: SourceLocation, ) => NodeRef, resolveParenExprFn?: ( parenNode: CstNode, lineNum: number, iterScope?: string | string[], safe?: boolean, + loc?: SourceLocation, ) => NodeRef, ): void { const iterNames = Array.isArray(iterScope) ? iterScope : [iterScope]; @@ -2442,6 +2514,7 @@ function processElementScopeLines( for (const scopeLine of scopeLines) { const sc = scopeLine.children; const scopeLineNum = line(findFirstToken(scopeLine)); + const scopeLineLoc = locFromNode(scopeLine); const targetStr = extractDottedPathStr(sub(scopeLine, "scopeTarget")!); const scopeSegs = parsePath(targetStr); const fullSegs = [...pathPrefix, ...scopeSegs]; @@ -2577,15 +2650,23 @@ function processElementScopeLines( segs, scopeLineNum, iterNames, + scopeLineLoc, + ); + wires.push( + withLoc( + { + from: concatOutRef, + to: { ...elemToRef, element: true }, + pipe: true, + ...lastAttrs, + }, + scopeLineLoc, + ), ); - wires.push({ - from: concatOutRef, - to: { ...elemToRef, element: true }, - pipe: true, - ...lastAttrs, - }); } else { - wires.push({ value: raw, to: elemToRef, ...lastAttrs }); + wires.push( + withLoc({ value: raw, to: elemToRef, ...lastAttrs }, scopeLineLoc), + ); } wires.push(...fallbackInternalWires); wires.push(...catchFallbackInternalWires); @@ -2618,6 +2699,7 @@ function processElementScopeLines( scopeLineNum, iterNames, scopeSafe || undefined, + scopeLineLoc, ); if (exprOps.length > 0 && desugarExprChain) { const exprRights = subs(scopeLine, "scopeExprRight"); @@ -2628,6 +2710,7 @@ function processElementScopeLines( scopeLineNum, iterNames, scopeSafe || undefined, + scopeLineLoc, ); } else { condRef = parenRef; @@ -2652,6 +2735,7 @@ function processElementScopeLines( scopeLineNum, iterNames, scopeSafe || undefined, + scopeLineLoc, ); condIsPipeFork = true; } else if (scopePipeSegs.length === 0) { @@ -3645,6 +3729,7 @@ function buildBridgeBody( for (const wireNode of wireNodes) { const wc = wireNode.children; const lineNum = line(findFirstToken(wireNode)); + const wireLoc = locFromNode(wireNode); const { root: targetRoot, segments: targetSegs } = extractAddressPath( sub(wireNode, "target")!, ); @@ -3660,7 +3745,7 @@ function buildBridgeBody( if (wc.equalsOp) { const value = extractBareValue(sub(wireNode, "constValue")!); - wires.push({ value, to: toRef }); + wires.push(withLoc({ value, to: toRef }, wireLoc)); continue; } @@ -3711,15 +3796,25 @@ function buildBridgeBody( ...(catchControl ? { catchControl } : {}), }; if (segs) { - const concatOutRef = desugarTemplateString(segs, lineNum, iterNames); - wires.push({ - from: concatOutRef, - to: toRef, - pipe: true, - ...lastAttrs, - }); + const concatOutRef = desugarTemplateString( + segs, + lineNum, + iterNames, + wireLoc, + ); + wires.push( + withLoc( + { + from: concatOutRef, + to: toRef, + pipe: true, + ...lastAttrs, + }, + wireLoc, + ), + ); } else { - wires.push({ value: raw, to: toRef, ...lastAttrs }); + wires.push(withLoc({ value: raw, to: toRef, ...lastAttrs }, wireLoc)); } wires.push(...fallbackInternalWires); wires.push(...catchFallbackInternalWires); @@ -3742,6 +3837,7 @@ function buildBridgeBody( lineNum, iterNames, isSafe, + wireLoc, ); if (exprOps.length > 0) { const exprRights = subs(wireNode, "exprRight"); @@ -3752,6 +3848,7 @@ function buildBridgeBody( lineNum, iterNames, isSafe, + wireLoc, ); } else { condRef = parenRef; @@ -3767,6 +3864,7 @@ function buildBridgeBody( lineNum, iterNames, isSafe, + wireLoc, ); condIsPipeFork = true; } else { @@ -3779,7 +3877,7 @@ function buildBridgeBody( } if (wc.notPrefix) { - condRef = desugarNot(condRef, lineNum, isSafe); + condRef = desugarNot(condRef, lineNum, isSafe, wireLoc); condIsPipeFork = true; } @@ -3827,20 +3925,25 @@ function buildBridgeBody( } } - wires.push({ - cond: condRef, - ...(thenBranch.kind === "ref" - ? { thenRef: thenBranch.ref } - : { thenValue: thenBranch.value }), - ...(elseBranch.kind === "ref" - ? { elseRef: elseBranch.ref } - : { elseValue: elseBranch.value }), - ...(fallbacks.length > 0 ? { fallbacks } : {}), - ...(catchFallback !== undefined ? { catchFallback } : {}), - ...(catchFallbackRef !== undefined ? { catchFallbackRef } : {}), - ...(catchControl ? { catchControl } : {}), - to: toRef, - }); + wires.push( + withLoc( + { + cond: condRef, + ...(thenBranch.kind === "ref" + ? { thenRef: thenBranch.ref } + : { thenValue: thenBranch.value }), + ...(elseBranch.kind === "ref" + ? { elseRef: elseBranch.ref } + : { elseValue: elseBranch.value }), + ...(fallbacks.length > 0 ? { fallbacks } : {}), + ...(catchFallback !== undefined ? { catchFallback } : {}), + ...(catchFallbackRef !== undefined ? { catchFallbackRef } : {}), + ...(catchControl ? { catchControl } : {}), + to: toRef, + }, + wireLoc, + ), + ); wires.push(...fallbackInternalWires); wires.push(...catchFallbackInternalWires); continue; @@ -3888,15 +3991,20 @@ function buildBridgeBody( } } - wires.push({ - from: condRef, - to: toRef, - ...(condIsPipeFork ? { pipe: true as const } : {}), - ...(fallbacks.length > 0 ? { fallbacks } : {}), - ...(catchFallback !== undefined ? { catchFallback } : {}), - ...(catchFallbackRef !== undefined ? { catchFallbackRef } : {}), - ...(catchControl ? { catchControl } : {}), - }); + wires.push( + withLoc( + { + from: condRef, + to: toRef, + ...(condIsPipeFork ? { pipe: true as const } : {}), + ...(fallbacks.length > 0 ? { fallbacks } : {}), + ...(catchFallback !== undefined ? { catchFallback } : {}), + ...(catchFallbackRef !== undefined ? { catchFallbackRef } : {}), + ...(catchControl ? { catchControl } : {}), + }, + wireLoc, + ), + ); wires.push(...fallbackInternalWires); wires.push(...catchFallbackInternalWires); } @@ -3909,6 +4017,7 @@ function buildBridgeBody( lineNum: number, iterScope?: string | string[], ): { ref: NodeRef; safe?: boolean } { + const sourceLoc = locFromNode(sourceNode); const headNode = sub(sourceNode, "head")!; const pipeNodes = subs(sourceNode, "pipeSegment"); @@ -3997,11 +4106,16 @@ function buildBridgeBody( instance: forkInstance, path: [], }; - wires.push({ - from: prevOutRef, - to: forkInRef, - pipe: true, - }); + wires.push( + withLoc( + { + from: prevOutRef, + to: forkInRef, + pipe: true, + }, + sourceLoc, + ), + ); prevOutRef = forkRootRef; } return { @@ -4029,6 +4143,7 @@ function buildBridgeBody( segs: TemplateSeg[], lineNum: number, iterScope?: string | string[], + loc?: SourceLocation, ): NodeRef { const forkInstance = 100000 + nextForkSeq++; const forkModule = SELF_MODULE; @@ -4055,7 +4170,7 @@ function buildBridgeBody( path: ["parts", String(idx)], }; if (seg.kind === "text") { - wires.push({ value: seg.value, to: partRef }); + wires.push(withLoc({ value: seg.value, to: partRef }, loc)); } else { // Parse the ref path: e.g. "i.id" → root="i", segments=["id"] const dotParts = seg.path.split("."); @@ -4065,12 +4180,17 @@ function buildBridgeBody( // Check for iterator-relative refs const fromRef = resolveIterRef(root, segments, iterScope); if (fromRef) { - wires.push({ from: fromRef, to: partRef }); + wires.push(withLoc({ from: fromRef, to: partRef }, loc)); } else { - wires.push({ - from: resolveAddress(root, segments, lineNum), - to: partRef, - }); + wires.push( + withLoc( + { + from: resolveAddress(root, segments, lineNum), + to: partRef, + }, + loc, + ), + ); } } } @@ -4130,7 +4250,14 @@ function buildBridgeBody( const raw = (c.stringLit as IToken[])[0].image; const segs = parseTemplateString(raw.slice(1, -1)); if (segs) - return { sourceRef: desugarTemplateString(segs, lineNum, iterScope) }; + return { + sourceRef: desugarTemplateString( + segs, + lineNum, + iterScope, + locFromNode(altNode), + ), + }; return { literal: raw }; } if (c.numberLit) return { literal: (c.numberLit as IToken[])[0].image }; @@ -4166,7 +4293,12 @@ function buildBridgeBody( if (segs) return { kind: "ref", - ref: desugarTemplateString(segs, lineNum, iterScope), + ref: desugarTemplateString( + segs, + lineNum, + iterScope, + locFromNode(branchNode), + ), }; return { kind: "literal", value: raw }; } @@ -4261,7 +4393,12 @@ function buildBridgeBody( if (segs) return { kind: "ref", - ref: desugarTemplateString(segs, lineNum, iterScope), + ref: desugarTemplateString( + segs, + lineNum, + iterScope, + locFromNode(operandNode), + ), }; return { kind: "literal", value: content }; } @@ -4296,7 +4433,13 @@ function buildBridgeBody( } if (c.parenExpr) { const parenNode = (c.parenExpr as CstNode[])[0]; - const ref = resolveParenExpr(parenNode, lineNum, iterScope); + const ref = resolveParenExpr( + parenNode, + lineNum, + iterScope, + undefined, + locFromNode(operandNode), + ); return { kind: "ref", ref }; } throw new Error(`Line ${lineNum}: Invalid expression operand`); @@ -4311,6 +4454,7 @@ function buildBridgeBody( lineNum: number, iterScope?: string | string[], safe?: boolean, + loc = locFromNode(parenNode), ): NodeRef { const pc = parenNode.children; const innerSourceNode = sub(parenNode, "parenSource")!; @@ -4347,6 +4491,7 @@ function buildBridgeBody( lineNum, iterScope, innerSafe, + loc, ); } else { resultRef = innerRef; @@ -4354,7 +4499,7 @@ function buildBridgeBody( // Apply not prefix if present if (hasNot) { - resultRef = desugarNot(resultRef, lineNum, innerSafe); + resultRef = desugarNot(resultRef, lineNum, innerSafe, loc); } return resultRef; @@ -4377,6 +4522,7 @@ function buildBridgeBody( lineNum: number, iterScope?: string | string[], safe?: boolean, + loc?: SourceLocation, ): NodeRef { // Build flat operand/operator lists for the precedence parser. // operands[0] = leftRef, operands[i+1] = resolved exprRights[i] @@ -4448,7 +4594,7 @@ function buildBridgeBody( instance: litInstance, path: [], }; - wires.push({ value: left.value, to: litRef }); + wires.push(withLoc({ value: left.value, to: litRef }, loc)); return litRef; })(); @@ -4462,15 +4608,35 @@ function buildBridgeBody( const rightSafeAttr = rightSafe ? { rightSafe: true as const } : {}; if (opStr === "and") { - wires.push({ - condAnd: { leftRef, ...rightSide, ...safeAttr, ...rightSafeAttr }, - to: toRef, - }); + wires.push( + withLoc( + { + condAnd: { + leftRef, + ...rightSide, + ...safeAttr, + ...rightSafeAttr, + }, + to: toRef, + }, + loc, + ), + ); } else { - wires.push({ - condOr: { leftRef, ...rightSide, ...safeAttr, ...rightSafeAttr }, - to: toRef, - }); + wires.push( + withLoc( + { + condOr: { + leftRef, + ...rightSide, + ...safeAttr, + ...rightSafeAttr, + }, + to: toRef, + }, + loc, + ), + ); } return { kind: "ref", ref: toRef }; @@ -4507,28 +4673,38 @@ function buildBridgeBody( // Wire left → fork.a (propagate safe flag from operand) if (left.kind === "literal") { - wires.push({ value: left.value, to: makeTarget("a") }); + wires.push(withLoc({ value: left.value, to: makeTarget("a") }, loc)); } else { const safeAttr = leftSafe ? { safe: true as const } : {}; - wires.push({ - from: left.ref, - to: makeTarget("a"), - pipe: true, - ...safeAttr, - }); + wires.push( + withLoc( + { + from: left.ref, + to: makeTarget("a"), + pipe: true, + ...safeAttr, + }, + loc, + ), + ); } // Wire right → fork.b (propagate safe flag from operand) if (right.kind === "literal") { - wires.push({ value: right.value, to: makeTarget("b") }); + wires.push(withLoc({ value: right.value, to: makeTarget("b") }, loc)); } else { const safeAttr = rightSafe ? { safe: true as const } : {}; - wires.push({ - from: right.ref, - to: makeTarget("b"), - pipe: true, - ...safeAttr, - }); + wires.push( + withLoc( + { + from: right.ref, + to: makeTarget("b"), + pipe: true, + ...safeAttr, + }, + loc, + ), + ); } return { @@ -4583,6 +4759,7 @@ function buildBridgeBody( sourceRef: NodeRef, _lineNum: number, safe?: boolean, + loc?: SourceLocation, ): NodeRef { const forkInstance = 100000 + nextForkSeq++; const forkTrunkModule = SELF_MODULE; @@ -4601,18 +4778,23 @@ function buildBridgeBody( }); const safeAttr = safe ? { safe: true as const } : {}; - wires.push({ - from: sourceRef, - to: { - module: forkTrunkModule, - type: forkTrunkType, - field: forkTrunkField, - instance: forkInstance, - path: ["a"], - }, - pipe: true, - ...safeAttr, - }); + wires.push( + withLoc( + { + from: sourceRef, + to: { + module: forkTrunkModule, + type: forkTrunkType, + field: forkTrunkField, + instance: forkInstance, + path: ["a"], + }, + pipe: true, + ...safeAttr, + }, + loc, + ), + ); return { module: forkTrunkModule, @@ -4635,6 +4817,7 @@ function buildBridgeBody( for (const scopeLine of scopeLines) { const sc = scopeLine.children; const scopeLineNum = line(findFirstToken(scopeLine)); + const scopeLineLoc = locFromNode(scopeLine); const targetStr = extractDottedPathStr(sub(scopeLine, "scopeTarget")!); const scopeSegs = parsePath(targetStr); const fullSegs = [...pathPrefix, ...scopeSegs]; @@ -4675,11 +4858,16 @@ function buildBridgeBody( field: alias, path: [], }; - wires.push({ - from: sourceRef, - to: localToRef, - ...(aliasSafe ? { safe: true as const } : {}), - }); + wires.push( + withLoc( + { + from: sourceRef, + to: localToRef, + ...(aliasSafe ? { safe: true as const } : {}), + }, + locFromNode(aliasNode), + ), + ); } // Process spread lines inside this nested scope block: ...sourceExpr const nestedToRef = resolveAddress(targetRoot, fullSegs, scopeLineNum); @@ -4690,12 +4878,17 @@ function buildBridgeBody( sourceNode, spreadLineNum, ); - wires.push({ - from: fromRef, - to: nestedToRef, - spread: true as const, - ...(spreadSafe ? { safe: true as const } : {}), - }); + wires.push( + withLoc( + { + from: fromRef, + to: nestedToRef, + spread: true as const, + ...(spreadSafe ? { safe: true as const } : {}), + }, + locFromNode(spreadLine), + ), + ); } processScopeLines(nestedScopeLines, targetRoot, fullSegs); continue; @@ -4707,7 +4900,7 @@ function buildBridgeBody( // ── Constant wire: .field = value ── if (sc.scopeEquals) { const value = extractBareValue(sub(scopeLine, "scopeValue")!); - wires.push({ value, to: toRef }); + wires.push(withLoc({ value, to: toRef }, scopeLineLoc)); continue; } @@ -4761,15 +4954,27 @@ function buildBridgeBody( ...(catchControl ? { catchControl } : {}), }; if (segs) { - const concatOutRef = desugarTemplateString(segs, scopeLineNum); - wires.push({ - from: concatOutRef, - to: toRef, - pipe: true, - ...lastAttrs, - }); + const concatOutRef = desugarTemplateString( + segs, + scopeLineNum, + undefined, + scopeLineLoc, + ); + wires.push( + withLoc( + { + from: concatOutRef, + to: toRef, + pipe: true, + ...lastAttrs, + }, + scopeLineLoc, + ), + ); } else { - wires.push({ value: raw, to: toRef, ...lastAttrs }); + wires.push( + withLoc({ value: raw, to: toRef, ...lastAttrs }, scopeLineLoc), + ); } wires.push(...fallbackInternalWires); wires.push(...catchFallbackInternalWires); @@ -4809,6 +5014,7 @@ function buildBridgeBody( scopeLineNum, undefined, scopeBlockSafe || undefined, + scopeLineLoc, ); } else { condRef = parenRef; @@ -4824,6 +5030,7 @@ function buildBridgeBody( scopeLineNum, undefined, scopeBlockSafe || undefined, + scopeLineLoc, ); condIsPipeFork = true; } else { @@ -4841,6 +5048,7 @@ function buildBridgeBody( condRef, scopeLineNum, scopeBlockSafe || undefined, + scopeLineLoc, ); condIsPipeFork = true; } @@ -4885,20 +5093,25 @@ function buildBridgeBody( catchFallbackInternalWires = wires.splice(preLen); } } - wires.push({ - cond: condRef, - ...(thenBranch.kind === "ref" - ? { thenRef: thenBranch.ref } - : { thenValue: thenBranch.value }), - ...(elseBranch.kind === "ref" - ? { elseRef: elseBranch.ref } - : { elseValue: elseBranch.value }), - ...(fallbacks.length > 0 ? { fallbacks } : {}), - ...(catchFallback !== undefined ? { catchFallback } : {}), - ...(catchFallbackRef !== undefined ? { catchFallbackRef } : {}), - ...(catchControl ? { catchControl } : {}), - to: toRef, - }); + wires.push( + withLoc( + { + cond: condRef, + ...(thenBranch.kind === "ref" + ? { thenRef: thenBranch.ref } + : { thenValue: thenBranch.value }), + ...(elseBranch.kind === "ref" + ? { elseRef: elseBranch.ref } + : { elseValue: elseBranch.value }), + ...(fallbacks.length > 0 ? { fallbacks } : {}), + ...(catchFallback !== undefined ? { catchFallback } : {}), + ...(catchFallbackRef !== undefined ? { catchFallbackRef } : {}), + ...(catchControl ? { catchControl } : {}), + to: toRef, + }, + scopeLineLoc, + ), + ); wires.push(...fallbackInternalWires); wires.push(...catchFallbackInternalWires); continue; @@ -4950,7 +5163,9 @@ function buildBridgeBody( ...(catchFallbackRef ? { catchFallbackRef } : {}), ...(catchControl ? { catchControl } : {}), }; - wires.push({ from: fromRef, to: toRef, ...wireAttrs }); + wires.push( + withLoc({ from: fromRef, to: toRef, ...wireAttrs }, scopeLineLoc), + ); wires.push(...fallbackInternalWires); wires.push(...catchFallbackInternalWires); } @@ -4969,6 +5184,7 @@ function buildBridgeBody( const nodeAliasNode = (c.bridgeNodeAlias as CstNode[] | undefined)?.[0]; if (nodeAliasNode) { const lineNum = line(findFirstToken(nodeAliasNode)); + const aliasLoc = locFromNode(nodeAliasNode); const alias = extractNameToken(sub(nodeAliasNode, "nodeAliasName")!); assertNotReserved(alias, lineNum, "node alias"); if (handleRes.has(alias)) { @@ -5034,8 +5250,13 @@ function buildBridgeBody( const stringExprOps = subs(nodeAliasNode, "aliasStringExprOp"); // Produce a NodeRef for the string value (concat fork or template desugar) const strRef: NodeRef = segs - ? desugarTemplateString(segs, lineNum) - : desugarTemplateString([{ kind: "text", value: raw }], lineNum); + ? desugarTemplateString(segs, lineNum, undefined, aliasLoc) + : desugarTemplateString( + [{ kind: "text", value: raw }], + lineNum, + undefined, + aliasLoc, + ); if (stringExprOps.length > 0) { const stringExprRights = subs(nodeAliasNode, "aliasStringExprRight"); sourceRef = desugarExprChain( @@ -5043,6 +5264,9 @@ function buildBridgeBody( stringExprOps, stringExprRights, lineNum, + undefined, + undefined, + aliasLoc, ); } else { sourceRef = strRef; @@ -5067,17 +5291,22 @@ function buildBridgeBody( type: "Shadow", field: alias, }); - wires.push({ - cond: sourceRef, - ...(thenBranch.kind === "ref" - ? { thenRef: thenBranch.ref } - : { thenValue: thenBranch.value }), - ...(elseBranch.kind === "ref" - ? { elseRef: elseBranch.ref } - : { elseValue: elseBranch.value }), - ...modifierAttrs, - to: ternaryToRef, - }); + wires.push( + withLoc( + { + cond: sourceRef, + ...(thenBranch.kind === "ref" + ? { thenRef: thenBranch.ref } + : { thenValue: thenBranch.value }), + ...(elseBranch.kind === "ref" + ? { elseRef: elseBranch.ref } + : { elseValue: elseBranch.value }), + ...modifierAttrs, + to: ternaryToRef, + }, + aliasLoc, + ), + ); wires.push(...aliasFallbackInternalWires); wires.push(...aliasCatchFallbackInternalWires); continue; @@ -5112,6 +5341,7 @@ function buildBridgeBody( lineNum, undefined, isSafe, + aliasLoc, ); } else { condRef = parenRef; @@ -5126,6 +5356,7 @@ function buildBridgeBody( lineNum, undefined, isSafe, + aliasLoc, ); } else { const result = buildSourceExprSafe(firstSourceNode!, lineNum); @@ -5137,7 +5368,7 @@ function buildBridgeBody( if ( (nodeAliasNode.children.aliasNotPrefix as IToken[] | undefined)?.[0] ) { - condRef = desugarNot(condRef, lineNum, isSafe); + condRef = desugarNot(condRef, lineNum, isSafe, aliasLoc); } // Ternary @@ -5160,17 +5391,22 @@ function buildBridgeBody( type: "Shadow", field: alias, }); - wires.push({ - cond: condRef, - ...(thenBranch.kind === "ref" - ? { thenRef: thenBranch.ref } - : { thenValue: thenBranch.value }), - ...(elseBranch.kind === "ref" - ? { elseRef: elseBranch.ref } - : { elseValue: elseBranch.value }), - ...modifierAttrs, - to: ternaryToRef, - }); + wires.push( + withLoc( + { + cond: condRef, + ...(thenBranch.kind === "ref" + ? { thenRef: thenBranch.ref } + : { thenValue: thenBranch.value }), + ...(elseBranch.kind === "ref" + ? { elseRef: elseBranch.ref } + : { elseValue: elseBranch.value }), + ...modifierAttrs, + to: ternaryToRef, + }, + aliasLoc, + ), + ); wires.push(...aliasFallbackInternalWires); wires.push(...aliasCatchFallbackInternalWires); continue; @@ -5199,7 +5435,9 @@ function buildBridgeBody( ...(aliasSafe ? { safe: true as const } : {}), ...modifierAttrs, }; - wires.push({ from: sourceRef, to: localToRef, ...aliasAttrs }); + wires.push( + withLoc({ from: sourceRef, to: localToRef, ...aliasAttrs }, aliasLoc), + ); wires.push(...aliasFallbackInternalWires); wires.push(...aliasCatchFallbackInternalWires); } @@ -5218,6 +5456,7 @@ function buildBridgeBody( const wc = wireNode.children; const lineNum = line(findFirstToken(wireNode)); + const wireLoc = locFromNode(wireNode); // Parse target const { root: targetRoot, segments: targetSegs } = extractAddressPath( @@ -5229,7 +5468,7 @@ function buildBridgeBody( // ── Constant wire: target = value ── if (wc.equalsOp) { const value = extractBareValue(sub(wireNode, "constValue")!); - wires.push({ value, to: toRef }); + wires.push(withLoc({ value, to: toRef }, wireLoc)); continue; } @@ -5263,11 +5502,16 @@ function buildBridgeBody( field: alias, path: [], }; - wires.push({ - from: sourceRef, - to: localToRef, - ...(aliasSafe ? { safe: true as const } : {}), - }); + wires.push( + withLoc( + { + from: sourceRef, + to: localToRef, + ...(aliasSafe ? { safe: true as const } : {}), + }, + locFromNode(aliasNode), + ), + ); } const scopeLines = subs(wireNode, "pathScopeLine"); // Process spread lines inside the scope block: ...sourceExpr @@ -5279,12 +5523,17 @@ function buildBridgeBody( sourceNode, spreadLineNum, ); - wires.push({ - from: fromRef, - to: toRef, - spread: true as const, - ...(spreadSafe ? { safe: true as const } : {}), - }); + wires.push( + withLoc( + { + from: fromRef, + to: toRef, + spread: true as const, + ...(spreadSafe ? { safe: true as const } : {}), + }, + locFromNode(spreadLine), + ), + ); } processScopeLines(scopeLines, targetRoot, targetSegs); continue; @@ -5344,11 +5593,21 @@ function buildBridgeBody( if (segs) { // Template string — desugar to synthetic internal.concat fork - const concatOutRef = desugarTemplateString(segs, lineNum); - wires.push({ from: concatOutRef, to: toRef, pipe: true, ...lastAttrs }); + const concatOutRef = desugarTemplateString( + segs, + lineNum, + undefined, + wireLoc, + ); + wires.push( + withLoc( + { from: concatOutRef, to: toRef, pipe: true, ...lastAttrs }, + wireLoc, + ), + ); } else { // Plain string without interpolation — emit constant wire - wires.push({ value: raw, to: toRef, ...lastAttrs }); + wires.push(withLoc({ value: raw, to: toRef, ...lastAttrs }, wireLoc)); } wires.push(...fallbackInternalWires); wires.push(...catchFallbackInternalWires); @@ -5361,7 +5620,13 @@ function buildBridgeBody( const firstSourceNode = sub(wireNode, "firstSource"); const firstParenNode = sub(wireNode, "firstParenExpr"); const srcRef = firstParenNode - ? resolveParenExpr(firstParenNode, lineNum) + ? resolveParenExpr( + firstParenNode, + lineNum, + undefined, + undefined, + wireLoc, + ) : buildSourceExpr(firstSourceNode!, lineNum); // Process coalesce modifiers on the array wire (same as plain pull wires) @@ -5408,7 +5673,9 @@ function buildBridgeBody( : {}), ...(arrayCatchControl ? { catchControl: arrayCatchControl } : {}), }; - wires.push({ from: srcRef, to: toRef, ...arrayWireAttrs }); + wires.push( + withLoc({ from: srcRef, to: toRef, ...arrayWireAttrs }, wireLoc), + ); wires.push(...arrayFallbackInternalWires); wires.push(...arrayCatchFallbackInternalWires); @@ -5472,6 +5739,7 @@ function buildBridgeBody( lineNum, undefined, isSafe, + wireLoc, ); if (exprOps.length > 0) { const exprRights = subs(wireNode, "exprRight"); @@ -5482,6 +5750,7 @@ function buildBridgeBody( lineNum, undefined, isSafe, + wireLoc, ); } else { condRef = parenRef; @@ -5498,6 +5767,7 @@ function buildBridgeBody( lineNum, undefined, isSafe, + wireLoc, ); condIsPipeFork = true; } else { @@ -5511,7 +5781,7 @@ function buildBridgeBody( // ── Apply `not` prefix if present ── if (wc.notPrefix) { - condRef = desugarNot(condRef, lineNum, isSafe); + condRef = desugarNot(condRef, lineNum, isSafe, wireLoc); condIsPipeFork = true; } @@ -5562,20 +5832,25 @@ function buildBridgeBody( } } - wires.push({ - cond: condRef, - ...(thenBranch.kind === "ref" - ? { thenRef: thenBranch.ref } - : { thenValue: thenBranch.value }), - ...(elseBranch.kind === "ref" - ? { elseRef: elseBranch.ref } - : { elseValue: elseBranch.value }), - ...(fallbacks.length > 0 ? { fallbacks } : {}), - ...(catchFallback !== undefined ? { catchFallback } : {}), - ...(catchFallbackRef !== undefined ? { catchFallbackRef } : {}), - ...(catchControl ? { catchControl } : {}), - to: toRef, - }); + wires.push( + withLoc( + { + cond: condRef, + ...(thenBranch.kind === "ref" + ? { thenRef: thenBranch.ref } + : { thenValue: thenBranch.value }), + ...(elseBranch.kind === "ref" + ? { elseRef: elseBranch.ref } + : { elseValue: elseBranch.value }), + ...(fallbacks.length > 0 ? { fallbacks } : {}), + ...(catchFallback !== undefined ? { catchFallback } : {}), + ...(catchFallbackRef !== undefined ? { catchFallbackRef } : {}), + ...(catchControl ? { catchControl } : {}), + to: toRef, + }, + wireLoc, + ), + ); wires.push(...fallbackInternalWires); wires.push(...catchFallbackInternalWires); continue; @@ -5634,7 +5909,7 @@ function buildBridgeBody( ...(catchFallbackRef ? { catchFallbackRef } : {}), ...(catchControl ? { catchControl } : {}), }; - wires.push({ from: fromRef, to: toRef, ...wireAttrs }); + wires.push(withLoc({ from: fromRef, to: toRef, ...wireAttrs }, wireLoc)); wires.push(...fallbackInternalWires); wires.push(...catchFallbackInternalWires); } diff --git a/packages/bridge-types/src/index.ts b/packages/bridge-types/src/index.ts index e3ba8d2a..c1db04e6 100644 --- a/packages/bridge-types/src/index.ts +++ b/packages/bridge-types/src/index.ts @@ -121,3 +121,10 @@ export type CacheStore = { get(key: string): Promise | any | undefined; set(key: string, value: any, ttlSeconds: number): Promise | void; }; + +export type SourceLocation = { + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; +}; diff --git a/packages/bridge/test/bridge-format.test.ts b/packages/bridge/test/bridge-format.test.ts index 3437680e..15cd9836 100644 --- a/packages/bridge/test/bridge-format.test.ts +++ b/packages/bridge/test/bridge-format.test.ts @@ -8,6 +8,7 @@ import { } from "../src/index.ts"; import type { Bridge, Instruction, ToolDef, Wire } from "../src/index.ts"; import { SELF_MODULE } from "../src/index.ts"; +import { assertDeepStrictEqualIgnoringLoc } from "./parse-test-utils.ts"; /** Pull wire — the Wire variant that has a `from` field */ type PullWire = Extract; @@ -16,15 +17,18 @@ type PullWire = Extract; describe("parsePath", () => { test("simple field", () => { - assert.deepStrictEqual(parsePath("name"), ["name"]); + assertDeepStrictEqualIgnoringLoc(parsePath("name"), ["name"]); }); test("dotted path", () => { - assert.deepStrictEqual(parsePath("position.lat"), ["position", "lat"]); + assertDeepStrictEqualIgnoringLoc(parsePath("position.lat"), [ + "position", + "lat", + ]); }); test("array index", () => { - assert.deepStrictEqual(parsePath("items[0].position.lat"), [ + assertDeepStrictEqualIgnoringLoc(parsePath("items[0].position.lat"), [ "items", "0", "position", @@ -33,14 +37,14 @@ describe("parsePath", () => { }); test("hyphenated key", () => { - assert.deepStrictEqual(parsePath("headers.x-message-id"), [ + assertDeepStrictEqualIgnoringLoc(parsePath("headers.x-message-id"), [ "headers", "x-message-id", ]); }); test("empty brackets stripped", () => { - assert.deepStrictEqual(parsePath("properties[]"), ["properties"]); + assertDeepStrictEqualIgnoringLoc(parsePath("properties[]"), ["properties"]); }); }); @@ -67,16 +71,22 @@ gc.q <- i.search assert.equal(bridge.type, "Query"); assert.equal(bridge.field, "geocode"); assert.equal(bridge.handles.length, 3); - assert.deepStrictEqual(bridge.handles[0], { + assertDeepStrictEqualIgnoringLoc(bridge.handles[0], { handle: "gc", kind: "tool", name: "hereapi.geocode", }); - assert.deepStrictEqual(bridge.handles[1], { handle: "i", kind: "input" }); - assert.deepStrictEqual(bridge.handles[2], { handle: "o", kind: "output" }); + assertDeepStrictEqualIgnoringLoc(bridge.handles[1], { + handle: "i", + kind: "input", + }); + assertDeepStrictEqualIgnoringLoc(bridge.handles[2], { + handle: "o", + kind: "output", + }); assert.equal(bridge.wires.length, 2); - assert.deepStrictEqual(bridge.wires[0], { + assertDeepStrictEqualIgnoringLoc(bridge.wires[0], { from: { module: SELF_MODULE, type: "Query", @@ -90,7 +100,7 @@ gc.q <- i.search path: ["search"], }, }); - assert.deepStrictEqual(bridge.wires[1], { + assertDeepStrictEqualIgnoringLoc(bridge.wires[1], { from: { module: SELF_MODULE, type: "Query", @@ -125,7 +135,7 @@ o.output <- ti.result (i): i is Bridge => i.kind === "bridge", )!; assert.equal(bridge.handles.length, 3); - assert.deepStrictEqual(bridge.wires[0], { + assertDeepStrictEqualIgnoringLoc(bridge.wires[0], { from: { module: "api", type: "Query", @@ -141,7 +151,7 @@ o.output <- ti.result path: ["value"], }, }); - assert.deepStrictEqual(bridge.wires[1], { + assertDeepStrictEqualIgnoringLoc(bridge.wires[1], { from: { module: SELF_MODULE, type: "Tools", @@ -172,26 +182,29 @@ o.topPick.city <- z.properties[0].location.city const bridge = result.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - assert.deepStrictEqual((bridge.wires[0] as PullWire).from, { + assertDeepStrictEqualIgnoringLoc((bridge.wires[0] as PullWire).from, { module: "zillow", type: "Query", field: "find", instance: 1, path: ["properties", "0", "streetAddress"], }); - assert.deepStrictEqual(bridge.wires[0].to, { + assertDeepStrictEqualIgnoringLoc(bridge.wires[0].to, { module: SELF_MODULE, type: "Query", field: "search", path: ["topPick", "address"], }); - assert.deepStrictEqual((bridge.wires[1] as PullWire).from.path, [ + assertDeepStrictEqualIgnoringLoc((bridge.wires[1] as PullWire).from.path, [ "properties", "0", "location", "city", ]); - assert.deepStrictEqual(bridge.wires[1].to.path, ["topPick", "city"]); + assertDeepStrictEqualIgnoringLoc(bridge.wires[1].to.path, [ + "topPick", + "city", + ]); }); test("array mapping with element wires", () => { @@ -211,7 +224,7 @@ o.results <- p.items[] as item { (i): i is Bridge => i.kind === "bridge", )!; assert.equal(bridge.wires.length, 3); - assert.deepStrictEqual(bridge.wires[0], { + assertDeepStrictEqualIgnoringLoc(bridge.wires[0], { from: { module: "provider", type: "Query", @@ -226,7 +239,7 @@ o.results <- p.items[] as item { path: ["results"], }, }); - assert.deepStrictEqual(bridge.wires[1], { + assertDeepStrictEqualIgnoringLoc(bridge.wires[1], { from: { module: SELF_MODULE, type: "Query", @@ -241,7 +254,7 @@ o.results <- p.items[] as item { path: ["results", "name"], }, }); - assert.deepStrictEqual(bridge.wires[2], { + assertDeepStrictEqualIgnoringLoc(bridge.wires[2], { from: { module: SELF_MODULE, type: "Query", @@ -274,14 +287,14 @@ o.messageId <- sg.headers.x-message-id (i): i is Bridge => i.kind === "bridge", )!; assert.equal(bridge.type, "Mutation"); - assert.deepStrictEqual(bridge.wires[0].to, { + assertDeepStrictEqualIgnoringLoc(bridge.wires[0].to, { module: "sendgrid", type: "Mutation", field: "send", instance: 1, path: ["content"], }); - assert.deepStrictEqual((bridge.wires[1] as PullWire).from.path, [ + assertDeepStrictEqualIgnoringLoc((bridge.wires[1] as PullWire).from.path, [ "headers", "x-message-id", ]); @@ -329,8 +342,11 @@ z.lat <- i.lat (i): i is Bridge => i.kind === "bridge", )!; assert.equal(bridge.handles.length, 3); - assert.deepStrictEqual(bridge.handles[2], { handle: "c", kind: "context" }); - assert.deepStrictEqual((bridge.wires[0] as PullWire).from, { + assertDeepStrictEqualIgnoringLoc(bridge.handles[2], { + handle: "c", + kind: "context", + }); + assertDeepStrictEqualIgnoringLoc((bridge.wires[0] as PullWire).from, { module: SELF_MODULE, type: "Context", field: "context", @@ -355,7 +371,7 @@ gc.q <- i.search }`; const instructions = parseBridge(input); const output = serializeBridge(instructions); - assert.deepStrictEqual(parseBridge(output), instructions); + assertDeepStrictEqualIgnoringLoc(parseBridge(output), instructions); }); test("tool bridge roundtrip", () => { @@ -375,7 +391,7 @@ o.lifeExpectancy <- ti.result }`; const instructions = parseBridge(input); - assert.deepStrictEqual( + assertDeepStrictEqualIgnoringLoc( parseBridge(serializeBridge(instructions)), instructions, ); @@ -395,7 +411,7 @@ o.results <- gc.items[] as item { }`; const instructions = parseBridge(input); - assert.deepStrictEqual( + assertDeepStrictEqualIgnoringLoc( parseBridge(serializeBridge(instructions)), instructions, ); @@ -415,7 +431,7 @@ o.messageId <- sg.headers.x-message-id }`; const instructions = parseBridge(input); - assert.deepStrictEqual( + assertDeepStrictEqualIgnoringLoc( parseBridge(serializeBridge(instructions)), instructions, ); @@ -464,7 +480,7 @@ o.propertyComments <- pt.result }`; const instructions = parseBridge(input); - assert.deepStrictEqual( + assertDeepStrictEqualIgnoringLoc( parseBridge(serializeBridge(instructions)), instructions, ); @@ -539,7 +555,7 @@ define myTransform { }`; const instructions = parseBridge(input); - assert.deepStrictEqual( + assertDeepStrictEqualIgnoringLoc( parseBridge(serializeBridge(instructions)), instructions, ); @@ -584,7 +600,7 @@ o.email <- d.email }`; const instructions = parseBridge(input); - assert.deepStrictEqual( + assertDeepStrictEqualIgnoringLoc( parseBridge(serializeBridge(instructions)), instructions, ); @@ -600,7 +616,7 @@ o.approved <- i.age > 18 and i.verified or i.admin }`; const instructions = parseBridge(input); - assert.deepStrictEqual( + assertDeepStrictEqualIgnoringLoc( parseBridge(serializeBridge(instructions)), instructions, ); @@ -639,8 +655,10 @@ gc.q <- i.search const root = tools.find((t) => t.name === "hereapi")!; assert.equal(root.fn, "httpCall"); assert.equal(root.extends, undefined); - assert.deepStrictEqual(root.deps, [{ kind: "context", handle: "context" }]); - assert.deepStrictEqual(root.wires, [ + assertDeepStrictEqualIgnoringLoc(root.deps, [ + { kind: "context", handle: "context" }, + ]); + assertDeepStrictEqualIgnoringLoc(root.wires, [ { target: "baseUrl", kind: "constant", @@ -656,7 +674,7 @@ gc.q <- i.search const child = tools.find((t) => t.name === "hereapi.geocode")!; assert.equal(child.fn, undefined); assert.equal(child.extends, "hereapi"); - assert.deepStrictEqual(child.wires, [ + assertDeepStrictEqualIgnoringLoc(child.wires, [ { target: "method", kind: "constant", value: "GET" }, { target: "path", kind: "constant", value: "/geocode" }, ]); @@ -687,7 +705,7 @@ sg.content <- i.body const root = result.instructions.find( (i): i is ToolDef => i.kind === "tool" && i.name === "sendgrid", )!; - assert.deepStrictEqual(root.wires, [ + assertDeepStrictEqualIgnoringLoc(root.wires, [ { target: "baseUrl", kind: "constant", @@ -705,7 +723,7 @@ sg.content <- i.body (i): i is ToolDef => i.kind === "tool" && i.name === "sendgrid.send", )!; assert.equal(child.extends, "sendgrid"); - assert.deepStrictEqual(child.wires, [ + assertDeepStrictEqualIgnoringLoc(child.wires, [ { target: "method", kind: "constant", value: "POST" }, { target: "path", kind: "constant", value: "/mail/send" }, ]); @@ -739,11 +757,11 @@ sb.q <- i.query const serviceB = result.instructions.find( (i): i is ToolDef => i.kind === "tool" && i.name === "serviceB", )!; - assert.deepStrictEqual(serviceB.deps, [ + assertDeepStrictEqualIgnoringLoc(serviceB.deps, [ { kind: "context", handle: "context" }, { kind: "tool", handle: "auth", tool: "authService" }, ]); - assert.deepStrictEqual(serviceB.wires[1], { + assertDeepStrictEqualIgnoringLoc(serviceB.wires[1], { target: "headers.Authorization", kind: "pull", source: "auth.access_token", @@ -778,7 +796,7 @@ gc.limit <- i.limit }`; const instructions = parseBridge(input); - assert.deepStrictEqual( + assertDeepStrictEqualIgnoringLoc( parseBridge(serializeBridge(instructions)), instructions, ); @@ -808,7 +826,7 @@ o.messageId <- sg.id }`; const instructions = parseBridge(input); - assert.deepStrictEqual( + assertDeepStrictEqualIgnoringLoc( parseBridge(serializeBridge(instructions)), instructions, ); @@ -1054,7 +1072,7 @@ bridge Query.demo { o.result <- api.value }`); - assert.deepStrictEqual(brace, indent); + assertDeepStrictEqualIgnoringLoc(brace, indent); }); test("tool block with braces parses identically to indented style", () => { @@ -1071,7 +1089,7 @@ tool myApi from httpCall { .method = GET }`); - assert.deepStrictEqual(brace, indent); + assertDeepStrictEqualIgnoringLoc(brace, indent); }); test("indented } inside on error value is not stripped", () => { @@ -1089,7 +1107,10 @@ tool myApi from httpCall { const onError = tool.wires.find((w) => w.kind === "onError"); assert.ok(onError && "value" in onError); if ("value" in onError!) { - assert.deepStrictEqual(JSON.parse(onError.value), { lat: 0, lon: 0 }); + assertDeepStrictEqualIgnoringLoc(JSON.parse(onError.value), { + lat: 0, + lon: 0, + }); } }); @@ -1286,7 +1307,9 @@ bridge Query.test { 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§"); + 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); diff --git a/packages/bridge/test/coalesce-cost.test.ts b/packages/bridge/test/coalesce-cost.test.ts index ee3cd5e7..212fcaff 100644 --- a/packages/bridge/test/coalesce-cost.test.ts +++ b/packages/bridge/test/coalesce-cost.test.ts @@ -3,6 +3,7 @@ import { parse } from "graphql"; import assert from "node:assert/strict"; import { describe, test } from "node:test"; import { parseBridgeFormat as parseBridge } from "../src/index.ts"; +import { assertDeepStrictEqualIgnoringLoc } from "./parse-test-utils.ts"; import { createGateway } from "./_gateway.ts"; // ═══════════════════════════════════════════════════════════════════════════ @@ -57,7 +58,7 @@ o.label <- p.label || b.label document: parse(`{ lookup(q: "x") { label } }`), }); assert.equal(result.data.lookup.label, "P"); - assert.deepStrictEqual( + assertDeepStrictEqualIgnoringLoc( callLog, ["primary"], "backup should never be called", @@ -96,7 +97,7 @@ o.label <- p.label || b.label document: parse(`{ lookup(q: "x") { label } }`), }); assert.equal(result.data.lookup.label, "B"); - assert.deepStrictEqual( + assertDeepStrictEqualIgnoringLoc( callLog, ["primary", "backup"], "backup called after primary returned null", @@ -149,7 +150,11 @@ o.label <- a.label || b.label || c.label document: parse(`{ lookup(q: "x") { label } }`), }); assert.equal(result.data.lookup.label, "from-B"); - assert.deepStrictEqual(callLog, ["A", "B"], "C should never be called"); + assertDeepStrictEqualIgnoringLoc( + callLog, + ["A", "B"], + "C should never be called", + ); }); test("|| with literal fallback: both null → literal, no extra calls", async () => { @@ -184,7 +189,7 @@ o.label <- p.label || b.label || "default" document: parse(`{ lookup(q: "x") { label } }`), }); assert.equal(result.data.lookup.label, "default"); - assert.deepStrictEqual( + assertDeepStrictEqualIgnoringLoc( callLog, ["primary", "backup"], "both called, then literal fires", @@ -224,7 +229,7 @@ o.label <- p.label || b.label }); // strict source throws → error exits || chain → no catch → GraphQL error assert.ok(result.errors?.length, "strict throw → GraphQL error"); - assert.deepStrictEqual( + assertDeepStrictEqualIgnoringLoc( callLog, ["primary"], "backup never called — strict throw exits chain", @@ -263,7 +268,7 @@ o.label <- p.label || b.label || "null-default" catch "error-default" document: parse(`{ lookup(q: "x") { label } }`), }); assert.equal(result.data.lookup.label, "error-default"); - assert.deepStrictEqual( + assertDeepStrictEqualIgnoringLoc( callLog, ["primary"], "strict throw exits || — catch fires immediately", @@ -302,7 +307,7 @@ o.label <- i.hint }); // Authored order: api.label is first → wins assert.equal(result.data.lookup.label, "expensive"); - assert.deepStrictEqual( + assertDeepStrictEqualIgnoringLoc( callLog, ["expensiveApi"], "first wire evaluated first", @@ -337,7 +342,7 @@ o.label <- i.hint document: parse(`{ lookup(q: "x") { label } }`), }); assert.equal(result.data.lookup.label, "from-api"); - assert.deepStrictEqual( + assertDeepStrictEqualIgnoringLoc( callLog, ["expensiveApi"], "API called when input is null", @@ -373,7 +378,7 @@ o.label <- i.hint document: parse(`{ lookup(q: "x", hint: "from-input") { label } }`), }); assert.equal(result.data.lookup.label, "expensive"); - assert.deepStrictEqual( + assertDeepStrictEqualIgnoringLoc( callLog, ["expensiveApi"], "first wire wins — authored order matters", @@ -412,7 +417,7 @@ o.label <- ctx.defaultLabel }); // api.label is first wire → evaluated first → wins assert.equal(result.data.lookup.label, "expensive"); - assert.deepStrictEqual( + assertDeepStrictEqualIgnoringLoc( callLog, ["api"], "authored order: api first, context second", @@ -453,7 +458,7 @@ o.label <- b.label }); // Authored order: A is first in the bridge → wins. assert.equal(result.data.lookup.label, "from-A"); - assert.deepStrictEqual( + assertDeepStrictEqualIgnoringLoc( callLog, ["A"], "B never called — A is first, short-circuits", @@ -758,8 +763,8 @@ bridge Query.lookup { "Query.lookup", { q: "test" }, { - "primary": async () => ({ label: null }), - "backup": async () => ({ label: "" }), + primary: async () => ({ label: null }), + backup: async () => ({ label: "" }), }, ); // p.label is null → ?? gate opens → b.label is "" (non-nullish, gate closes) @@ -783,8 +788,8 @@ bridge Query.lookup { "Query.lookup", { q: "test" }, { - "primary": async () => ({ label: "" }), - "backup": async () => ({ label: null }), + primary: async () => ({ label: "" }), + backup: async () => ({ label: null }), }, ); // p.label is "" → || gate opens → b.label is null (still falsy) @@ -810,9 +815,9 @@ bridge Query.lookup { "Query.lookup", { q: "test" }, { - "a": async () => ({ label: null }), - "b": async () => ({ label: 0 }), - "c": async () => ({ label: null }), + a: async () => ({ label: null }), + b: async () => ({ label: 0 }), + c: async () => ({ label: null }), }, ); // a.label null → ?? opens → b.label is 0 (non-nullish, ?? closes) @@ -837,8 +842,8 @@ bridge Query.lookup { "Query.lookup", { q: "test" }, { - "a": async () => ({ label: null }), - "b": async () => ({ label: "found" }), + a: async () => ({ label: null }), + b: async () => ({ label: "found" }), }, ); // a.label null → ?? opens → b.label is "found" (truthy) @@ -863,7 +868,7 @@ bridge Query.lookup { const doc = parseBridge(src); const serialized = serializeBridge(doc); const reparsed = parseBridge(serialized); - assert.deepStrictEqual(reparsed, doc); + assertDeepStrictEqualIgnoringLoc(reparsed, doc); }); test("?? then || with literals round-trips", () => { @@ -879,7 +884,7 @@ bridge Query.lookup { const doc = parseBridge(src); const serialized = serializeBridge(doc); const reparsed = parseBridge(serialized); - assert.deepStrictEqual(reparsed, doc); + assertDeepStrictEqualIgnoringLoc(reparsed, doc); }); test("parser produces correct fallbacks array for mixed chain", () => { diff --git a/packages/bridge/test/control-flow.test.ts b/packages/bridge/test/control-flow.test.ts index 3084535e..0cdfcb38 100644 --- a/packages/bridge/test/control-flow.test.ts +++ b/packages/bridge/test/control-flow.test.ts @@ -26,10 +26,12 @@ bridge Query.test { "from" in w && w.to.path.join(".") === "name", ); assert.ok(pullWire); - assert.deepStrictEqual(pullWire.fallbacks, [{ - type: "falsy", - control: { kind: "throw", message: "name is required" }, - }]); + assert.deepStrictEqual(pullWire.fallbacks, [ + { + type: "falsy", + control: { kind: "throw", message: "name is required" }, + }, + ]); }); test("panic on ?? gate", () => { @@ -45,10 +47,12 @@ bridge Query.test { "from" in w && w.to.path.join(".") === "name", ); assert.ok(pullWire); - assert.deepStrictEqual(pullWire.fallbacks, [{ - type: "nullish", - control: { kind: "panic", message: "fatal: name cannot be null" }, - }]); + assert.deepStrictEqual(pullWire.fallbacks, [ + { + type: "nullish", + control: { kind: "panic", message: "fatal: name cannot be null" }, + }, + ]); }); test("continue on ?? gate", () => { @@ -69,7 +73,9 @@ bridge Query.test { w.to.path.join(".") === "items.name", ); assert.ok(elemWire); - assert.deepStrictEqual(elemWire.fallbacks, [{ type: "nullish", control: { kind: "continue" } }]); + assert.deepStrictEqual(elemWire.fallbacks, [ + { type: "nullish", control: { kind: "continue" } }, + ]); }); test("break on ?? gate", () => { @@ -90,7 +96,9 @@ bridge Query.test { w.to.path.join(".") === "items.name", ); assert.ok(elemWire); - assert.deepStrictEqual(elemWire.fallbacks, [{ type: "nullish", control: { kind: "break" } }]); + assert.deepStrictEqual(elemWire.fallbacks, [ + { type: "nullish", control: { kind: "break" } }, + ]); }); test("break/continue with levels on ?? gate", () => { @@ -195,10 +203,12 @@ bridge Query.test { "from" in w && w.to.path.join(".") === "name", ); assert.ok(pullWire); - assert.deepStrictEqual(pullWire.fallbacks, [{ - type: "falsy", - control: { kind: "throw", message: "name is required" }, - }]); + assert.deepStrictEqual(pullWire.fallbacks, [ + { + type: "falsy", + control: { kind: "throw", message: "name is required" }, + }, + ]); }); test("panic on ?? gate round-trips", () => { @@ -221,10 +231,12 @@ bridge Query.test { "from" in w && w.to.path.join(".") === "name", ); assert.ok(pullWire); - assert.deepStrictEqual(pullWire.fallbacks, [{ - type: "nullish", - control: { kind: "panic", message: "fatal" }, - }]); + assert.deepStrictEqual(pullWire.fallbacks, [ + { + type: "nullish", + control: { kind: "panic", message: "fatal" }, + }, + ]); }); test("continue on ?? gate round-trips", () => { @@ -252,7 +264,9 @@ bridge Query.test { w.to.path.join(".") === "items.name", ); assert.ok(elemWire); - assert.deepStrictEqual(elemWire.fallbacks, [{ type: "nullish", control: { kind: "continue" } }]); + assert.deepStrictEqual(elemWire.fallbacks, [ + { type: "nullish", control: { kind: "continue" } }, + ]); }); test("break on catch gate round-trips", () => { @@ -563,7 +577,13 @@ bridge Query.test { const tools = { api: async () => ({ orders: [ - { id: 1, items: [{ sku: "A", price: 10 }, { sku: null, price: 99 }] }, + { + id: 1, + items: [ + { sku: "A", price: 10 }, + { sku: null, price: 99 }, + ], + }, { id: 2, items: [{ sku: "B", price: 20 }] }, ], }), @@ -571,7 +591,9 @@ bridge Query.test { const { data } = (await run(src, "Query.test", {}, tools)) as { data: any[]; }; - assert.deepStrictEqual(data, [{ id: 2, items: [{ sku: "B", price: 20 }] }]); + assert.deepStrictEqual(data, [ + { id: 2, items: [{ sku: "B", price: 20 }] }, + ]); }); test("break 2 breaks out of parent loop", async () => { @@ -591,7 +613,13 @@ bridge Query.test { api: async () => ({ orders: [ { id: 1, items: [{ sku: "A", price: 10 }] }, - { id: 2, items: [{ sku: "B", price: null }, { sku: "C", price: 30 }] }, + { + id: 2, + items: [ + { sku: "B", price: null }, + { sku: "C", price: 30 }, + ], + }, { id: 3, items: [{ sku: "D", price: 40 }] }, ], }), @@ -599,7 +627,9 @@ bridge Query.test { const { data } = (await run(src, "Query.test", {}, tools)) as { data: any[]; }; - assert.deepStrictEqual(data, [{ id: 1, items: [{ sku: "A", price: 10 }] }]); + assert.deepStrictEqual(data, [ + { id: 1, items: [{ sku: "A", price: 10 }] }, + ]); }); }); diff --git a/packages/bridge/test/engine-hardening.test.ts b/packages/bridge/test/engine-hardening.test.ts index 3d7d902e..68f54967 100644 --- a/packages/bridge/test/engine-hardening.test.ts +++ b/packages/bridge/test/engine-hardening.test.ts @@ -217,7 +217,11 @@ bridge Query.test { return true; }, ); - assert.equal(secondCalled, false, "second tool should not have been called"); + assert.equal( + secondCalled, + false, + "second tool should not have been called", + ); }); test("pre-aborted signal throws immediately", async () => { diff --git a/packages/bridge/test/force-wire.test.ts b/packages/bridge/test/force-wire.test.ts index 12c965cd..c7bacd48 100644 --- a/packages/bridge/test/force-wire.test.ts +++ b/packages/bridge/test/force-wire.test.ts @@ -8,6 +8,7 @@ import { } from "../src/index.ts"; import type { Bridge } from "../src/index.ts"; import { SELF_MODULE } from "../src/index.ts"; +import { assertDeepStrictEqualIgnoringLoc } from "./parse-test-utils.ts"; import { createGateway } from "./_gateway.ts"; // ── Parser: `force ` creates forces entries ───────────────────────── @@ -224,7 +225,7 @@ force lg const instructions = parseBridge(input); const serialized = serializeBridge(instructions); const reparsed = parseBridge(serialized); - assert.deepStrictEqual(reparsed, instructions); + assertDeepStrictEqualIgnoringLoc(reparsed, instructions); }); test("mixed force and regular wires roundtrip", () => { @@ -244,7 +245,7 @@ o.result <- m.data const instructions = parseBridge(input); const serialized = serializeBridge(instructions); const reparsed = parseBridge(serialized); - assert.deepStrictEqual(reparsed, instructions); + assertDeepStrictEqualIgnoringLoc(reparsed, instructions); }); test("serialized output contains force syntax", () => { @@ -285,7 +286,7 @@ force ping catch null "should contain catch null", ); const reparsed = parseBridge(serialized); - assert.deepStrictEqual(reparsed, instructions); + assertDeepStrictEqualIgnoringLoc(reparsed, instructions); }); test("mixed critical and fire-and-forget roundtrip", () => { @@ -304,7 +305,7 @@ force mt catch null const instructions = parseBridge(input); const serialized = serializeBridge(instructions); const reparsed = parseBridge(serialized); - assert.deepStrictEqual(reparsed, instructions); + assertDeepStrictEqualIgnoringLoc(reparsed, instructions); }); test("multiple force statements roundtrip", () => { @@ -323,7 +324,7 @@ force mt const instructions = parseBridge(input); const serialized = serializeBridge(instructions); const reparsed = parseBridge(serialized); - assert.deepStrictEqual(reparsed, instructions); + assertDeepStrictEqualIgnoringLoc(reparsed, instructions); }); }); @@ -381,7 +382,7 @@ o.title <- m.title auditCalled, "audit tool must be called even though output is not queried", ); - assert.deepStrictEqual(auditInput, { action: "test" }); + assertDeepStrictEqualIgnoringLoc(auditInput, { action: "test" }); }); test("forced tool receives correct input from multiple wires", async () => { diff --git a/packages/bridge/test/parse-test-utils.ts b/packages/bridge/test/parse-test-utils.ts new file mode 100644 index 00000000..3da104e2 --- /dev/null +++ b/packages/bridge/test/parse-test-utils.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; + +function omitLoc(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((entry) => omitLoc(entry)); + } + + if (value && typeof value === "object") { + const result: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (key === "loc") { + continue; + } + result[key] = omitLoc(entry); + } + return result; + } + + return value; +} + +export function assertDeepStrictEqualIgnoringLoc( + actual: unknown, + expected: unknown, + message?: string, +): void { + assert.deepStrictEqual(omitLoc(actual), omitLoc(expected), message); +} diff --git a/packages/bridge/test/path-scoping.test.ts b/packages/bridge/test/path-scoping.test.ts index 4b317b04..4c9acb08 100644 --- a/packages/bridge/test/path-scoping.test.ts +++ b/packages/bridge/test/path-scoping.test.ts @@ -5,6 +5,7 @@ import { serializeBridge, } from "../src/index.ts"; import type { Bridge, Wire } from "../src/index.ts"; +import { assertDeepStrictEqualIgnoringLoc } from "./parse-test-utils.ts"; import { forEachEngine } from "./_dual-run.ts"; // ── Parser tests ──────────────────────────────────────────────────────────── @@ -65,9 +66,9 @@ bridge Query.test { (w) => w.to.path.join(".") === "user.email", ); assert.ok(nameWire); - assert.deepStrictEqual(nameWire.from.path, ["name"]); + assertDeepStrictEqualIgnoringLoc(nameWire.from.path, ["name"]); assert.ok(emailWire); - assert.deepStrictEqual(emailWire.from.path, ["email"]); + assertDeepStrictEqualIgnoringLoc(emailWire.from.path, ["email"]); }); test("nested scope blocks", () => { @@ -105,8 +106,8 @@ bridge Query.test { ); assert.ok(idWire, "id wire should exist"); assert.ok(nameWire, "name wire should exist"); - assert.deepStrictEqual(idWire.from.path, ["id"]); - assert.deepStrictEqual(nameWire.from.path, ["name"]); + assertDeepStrictEqualIgnoringLoc(idWire.from.path, ["id"]); + assertDeepStrictEqualIgnoringLoc(nameWire.from.path, ["name"]); // Constant wires const constWires = wires.filter( @@ -163,7 +164,9 @@ bridge Query.test { ); const nameWire = pullWires.find((w) => w.to.path.join(".") === "data.name"); assert.ok(nameWire); - assert.deepStrictEqual(nameWire.fallbacks, [{ type: "falsy", value: '"anonymous"' }]); + assertDeepStrictEqualIgnoringLoc(nameWire.fallbacks, [ + { type: "falsy", value: '"anonymous"' }, + ]); const valueWire = pullWires.find( (w) => w.to.path.join(".") === "data.value", @@ -322,7 +325,7 @@ bridge Query.test { (i): i is Bridge => i.kind === "bridge", )!; - assert.deepStrictEqual(scopedBridge.wires, flatBridge.wires); + assertDeepStrictEqualIgnoringLoc(scopedBridge.wires, flatBridge.wires); }); }); @@ -351,7 +354,7 @@ bridge Query.test { const bridge2 = reparsed.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - assert.deepStrictEqual(bridge1.wires, bridge2.wires); + assertDeepStrictEqualIgnoringLoc(bridge1.wires, bridge2.wires); }); test("deeply nested scope round-trips correctly", () => { @@ -381,7 +384,7 @@ bridge Query.test { const bridge2 = reparsed.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; - assert.deepStrictEqual(bridge1.wires, bridge2.wires); + assertDeepStrictEqualIgnoringLoc(bridge1.wires, bridge2.wires); }); }); @@ -401,7 +404,10 @@ bridge Query.config { } }`; const result = await run(bridge, "Query.config", {}); - assert.deepStrictEqual(result.data, { theme: "dark", lang: "en" }); + assertDeepStrictEqualIgnoringLoc(result.data, { + theme: "dark", + lang: "en", + }); }); test("scope block pull wires resolve at runtime", async () => { @@ -420,7 +426,7 @@ bridge Query.user { name: "Alice", email: "alice@test.com", }); - assert.deepStrictEqual(result.data, { + assertDeepStrictEqualIgnoringLoc(result.data, { name: "Alice", email: "alice@test.com", }); @@ -469,7 +475,7 @@ bridge Query.profile { theme: "dark", }); - assert.deepStrictEqual(scopedResult.data, flatResult.data); + assertDeepStrictEqualIgnoringLoc(scopedResult.data, flatResult.data); }); test("scope block on tool input wires to tool correctly", () => { @@ -571,7 +577,7 @@ bridge Query.test { assert.equal(constWires.length, 1); const wire = constWires[0]; assert.equal(wire.value, "1"); - assert.deepStrictEqual(wire.to.path, ["obj", "etc"]); + assertDeepStrictEqualIgnoringLoc(wire.to.path, ["obj", "etc"]); assert.equal(wire.to.element, true); }); @@ -597,7 +603,7 @@ bridge Query.test { const nameWire = pullWires.find((w) => w.to.path.join(".") === "obj.name"); assert.ok(nameWire, "wire to obj.name should exist"); assert.equal(nameWire!.from.element, true); - assert.deepStrictEqual(nameWire!.from.path, ["title"]); + assertDeepStrictEqualIgnoringLoc(nameWire!.from.path, ["title"]); }); test("nested scope blocks inside array mapper flatten to correct paths", () => { @@ -622,7 +628,7 @@ bridge Query.test { (w): w is Extract => "value" in w, ); assert.equal(constWires.length, 1); - assert.deepStrictEqual(constWires[0].to.path, ["a", "b", "c"]); + assertDeepStrictEqualIgnoringLoc(constWires[0].to.path, ["a", "b", "c"]); assert.equal(constWires[0].to.element, true); }); @@ -683,7 +689,7 @@ bridge Query.test { const result = await run(bridge, "Query.test", { items: [{ title: "Hello" }, { title: "World" }], }); - assert.deepStrictEqual(result.data, [ + assertDeepStrictEqualIgnoringLoc(result.data, [ { obj: { name: "Hello", code: 42 } }, { obj: { name: "World", code: 42 } }, ]); @@ -708,7 +714,7 @@ bridge Query.test { const result = await run(bridge, "Query.test", { items: [{ title: "Alice" }, { title: "Bob" }], }); - assert.deepStrictEqual(result.data, [ + assertDeepStrictEqualIgnoringLoc(result.data, [ { level1: { level2: { name: "Alice", fixed: "ok" } } }, { level1: { level2: { name: "Bob", fixed: "ok" } } }, ]); @@ -740,7 +746,7 @@ bridge Query.test { ); const spreadWire = pullWires.find((w) => w.to.path.length === 0); assert.ok(spreadWire, "spread wire targeting tool root should exist"); - assert.deepStrictEqual(spreadWire.from.path, []); + assertDeepStrictEqualIgnoringLoc(spreadWire.from.path, []); }); test("spread combined with constant wires in scope block", () => { @@ -799,7 +805,7 @@ bridge Query.test { ); const spreadWire = pullWires.find((w) => w.to.path.length === 0); assert.ok(spreadWire, "spread wire should exist"); - assert.deepStrictEqual(spreadWire.from.path, ["profile"]); + assertDeepStrictEqualIgnoringLoc(spreadWire.from.path, ["profile"]); }); test("spread in nested scope block produces wire to nested path", () => { @@ -848,7 +854,7 @@ bridge Query.test { ); const spreadWire = pullWires.find((w) => w.to.path.join(".") === "nested"); assert.ok(spreadWire, "spread wire to tool.nested should exist"); - assert.deepStrictEqual(spreadWire.from.path, []); + assertDeepStrictEqualIgnoringLoc(spreadWire.from.path, []); }); }); @@ -875,7 +881,7 @@ bridge Query.test { myTool: async (input: any) => ({ received: input }), }, ); - assert.deepStrictEqual(result.data, { + assertDeepStrictEqualIgnoringLoc(result.data, { result: { received: { name: "Alice", age: 30 } }, }); }); @@ -903,7 +909,7 @@ bridge Query.test { myTool: async (input: any) => ({ received: input }), }, ); - assert.deepStrictEqual(result.data, { + assertDeepStrictEqualIgnoringLoc(result.data, { result: { received: { name: "Alice", age: 30, extra: "added" } }, }); }); @@ -930,7 +936,7 @@ bridge Query.test { myTool: async (input: any) => ({ received: input }), }, ); - assert.deepStrictEqual(result.data, { + assertDeepStrictEqualIgnoringLoc(result.data, { result: { received: { name: "Bob", email: "bob@test.com" } }, }); }); @@ -951,7 +957,7 @@ bridge Query.greet { } }`; const result = await run(bridge, "Query.greet", { name: "Hello Bridge" }); - assert.deepStrictEqual(result.data, { name: "Hello Bridge" }); + assertDeepStrictEqualIgnoringLoc(result.data, { name: "Hello Bridge" }); }); test("spread with explicit field overrides", async () => { @@ -967,7 +973,7 @@ bridge Query.greet { } }`; const result = await run(bridge, "Query.greet", { name: "Hello Bridge" }); - assert.deepStrictEqual(result.data, { + assertDeepStrictEqualIgnoringLoc(result.data, { name: "Hello Bridge", message: "Hello Bridge", }); @@ -990,7 +996,7 @@ bridge Query.test { second: { b: 3, c: 4 }, }); // second should override b from first - assert.deepStrictEqual(result.data, { a: 1, b: 3, c: 4 }); + assertDeepStrictEqualIgnoringLoc(result.data, { a: 1, b: 3, c: 4 }); }); test("spread with explicit override taking precedence", async () => { @@ -1010,7 +1016,10 @@ bridge Query.test { age: 30, }); // explicit .name should override spread - assert.deepStrictEqual(result.data, { name: "overridden", age: 30 }); + assertDeepStrictEqualIgnoringLoc(result.data, { + name: "overridden", + age: 30, + }); }); test("spread with deep path source", async () => { @@ -1027,7 +1036,7 @@ bridge Query.test { const result = await run(bridge, "Query.test", { user: { profile: { email: "test@test.com", verified: true } }, }); - assert.deepStrictEqual(result.data, { + assertDeepStrictEqualIgnoringLoc(result.data, { email: "test@test.com", verified: true, }); @@ -1049,7 +1058,7 @@ bridge Query.greet { } }`; const result = await run(bridge, "Query.greet", { name: "Hello Bridge" }); - assert.deepStrictEqual(result.data, { + assertDeepStrictEqualIgnoringLoc(result.data, { name: "Hello Bridge", upper: "HELLO BRIDGE", lower: "hello bridge", @@ -1071,7 +1080,7 @@ bridge Query.test { const result = await run(bridge, "Query.test", { data: { x: 1, y: 2 }, }); - assert.deepStrictEqual(result.data, { + assertDeepStrictEqualIgnoringLoc(result.data, { result: { x: 1, y: 2, extra: "added" }, }); }); diff --git a/packages/bridge/test/resilience.test.ts b/packages/bridge/test/resilience.test.ts index 310124f7..29021174 100644 --- a/packages/bridge/test/resilience.test.ts +++ b/packages/bridge/test/resilience.test.ts @@ -7,6 +7,7 @@ import { serializeBridge, } from "../src/index.ts"; import type { Bridge, ConstDef, NodeRef, ToolDef, Wire } from "../src/index.ts"; +import { assertDeepStrictEqualIgnoringLoc } from "./parse-test-utils.ts"; import { createGateway } from "./_gateway.ts"; // ══════════════════════════════════════════════════════════════════════════════ @@ -21,7 +22,7 @@ const fallbackGeo = { "lat": 0, "lon": 0 }`); const c = doc.instructions.find((i): i is ConstDef => i.kind === "const")!; assert.equal(c.kind, "const"); assert.equal(c.name, "fallbackGeo"); - assert.deepStrictEqual(JSON.parse(c.value), { lat: 0, lon: 0 }); + assertDeepStrictEqualIgnoringLoc(JSON.parse(c.value), { lat: 0, lon: 0 }); }); test("single const with string value", () => { @@ -70,7 +71,7 @@ const geo = { "lat": 0, "lon": 0 }`).instructions.find((i): i is ConstDef => i.kind === "const")!; - assert.deepStrictEqual(JSON.parse(c.value), { lat: 0, lon: 0 }); + assertDeepStrictEqualIgnoringLoc(JSON.parse(c.value), { lat: 0, lon: 0 }); }); test("multi-line JSON array", () => { @@ -80,7 +81,7 @@ const items = [ "b", "c" ]`).instructions.find((i): i is ConstDef => i.kind === "const")!; - assert.deepStrictEqual(JSON.parse(c.value), ["a", "b", "c"]); + assertDeepStrictEqualIgnoringLoc(JSON.parse(c.value), ["a", "b", "c"]); }); test("const coexists with tool and bridge blocks", () => { @@ -137,7 +138,7 @@ o.result <- i.q const doc = parseBridge(input); const serialized = serializeBridge(doc); const reparsed = parseBridge(serialized); - assert.deepStrictEqual(reparsed, doc); + assertDeepStrictEqualIgnoringLoc(reparsed, doc); }); }); @@ -200,7 +201,10 @@ tool myApi from httpCall { assert.ok(onError, "should have an onError wire"); assert.ok("value" in onError!, "should have a value"); if ("value" in onError!) { - assert.deepStrictEqual(JSON.parse(onError.value), { lat: 0, lon: 0 }); + assertDeepStrictEqualIgnoringLoc(JSON.parse(onError.value), { + lat: 0, + lon: 0, + }); } }); @@ -235,7 +239,10 @@ tool myApi from httpCall { const onError = tool.wires.find((w) => w.kind === "onError"); assert.ok(onError && "value" in onError); if ("value" in onError!) { - assert.deepStrictEqual(JSON.parse(onError.value), { lat: 0, lon: 0 }); + assertDeepStrictEqualIgnoringLoc(JSON.parse(onError.value), { + lat: 0, + lon: 0, + }); } }); @@ -267,7 +274,7 @@ tool myApi from httpCall { }`; const doc = parseBridge(input); - assert.deepStrictEqual(parseBridge(serializeBridge(doc)), doc); + assertDeepStrictEqualIgnoringLoc(parseBridge(serializeBridge(doc)), doc); }); test("on error <- source roundtrips", () => { @@ -278,7 +285,7 @@ tool myApi from httpCall { }`; const doc = parseBridge(input); - assert.deepStrictEqual(parseBridge(serializeBridge(doc)), doc); + assertDeepStrictEqualIgnoringLoc(parseBridge(serializeBridge(doc)), doc); }); }); @@ -595,7 +602,7 @@ o.lat <- a.lat catch 0 }`; const doc = parseBridge(input); - assert.deepStrictEqual(parseBridge(serializeBridge(doc)), doc); + assertDeepStrictEqualIgnoringLoc(parseBridge(serializeBridge(doc)), doc); }); test("catch on pipe chain roundtrips", () => { @@ -609,7 +616,7 @@ o.result <- t:i.text catch "fallback" }`; const doc = parseBridge(input); - assert.deepStrictEqual(parseBridge(serializeBridge(doc)), doc); + assertDeepStrictEqualIgnoringLoc(parseBridge(serializeBridge(doc)), doc); }); test("serialized output contains catch", () => { @@ -821,7 +828,9 @@ o.name <- i.name || "World" (i): i is Bridge => i.kind === "bridge", )!; const wire = bridge.wires[0] as Extract; - assert.deepStrictEqual(wire.fallbacks, [{ type: "falsy", value: '"World"' }]); + assertDeepStrictEqualIgnoringLoc(wire.fallbacks, [ + { type: "falsy", value: '"World"' }, + ]); assert.equal(wire.catchFallback, undefined); }); @@ -839,7 +848,9 @@ o.name <- i.name || "World" catch "Error" (i): i is Bridge => i.kind === "bridge", )!; const wire = bridge.wires[0] as Extract; - assert.deepStrictEqual(wire.fallbacks, [{ type: "falsy", value: '"World"' }]); + assertDeepStrictEqualIgnoringLoc(wire.fallbacks, [ + { type: "falsy", value: '"World"' }, + ]); assert.equal(wire.catchFallback, '"Error"'); }); @@ -861,7 +872,9 @@ o.result <- a.data || {"lat":0,"lon":0} const wire = bridge.wires.find( (w) => "from" in w && (w as any).from.path[0] === "data", ) as Extract; - assert.deepStrictEqual(wire.fallbacks, [{ type: "falsy", value: '{"lat":0,"lon":0}' }]); + assertDeepStrictEqualIgnoringLoc(wire.fallbacks, [ + { type: "falsy", value: '{"lat":0,"lon":0}' }, + ]); }); test("wire without || has no fallbacks", () => { @@ -900,7 +913,9 @@ o.result <- up:i.text || "N/A" (w) => "from" in w && (w as any).pipe && (w as any).from.path.length === 0, ) as Extract; - assert.deepStrictEqual(terminalWire?.fallbacks, [{ type: "falsy", value: '"N/A"' }]); + assertDeepStrictEqualIgnoringLoc(terminalWire?.fallbacks, [ + { type: "falsy", value: '"N/A"' }, + ]); }); }); @@ -916,7 +931,7 @@ o.name <- i.name || "World" }`; const reparsed = parseBridge(serializeBridge(parseBridge(input))); const original = parseBridge(input); - assert.deepStrictEqual(reparsed, original); + assertDeepStrictEqualIgnoringLoc(reparsed, original); }); test("|| and catch together roundtrip", () => { @@ -932,7 +947,7 @@ o.name <- a.name || "World" catch "Error" }`; const reparsed = parseBridge(serializeBridge(parseBridge(input))); const original = parseBridge(input); - assert.deepStrictEqual(reparsed, original); + assertDeepStrictEqualIgnoringLoc(reparsed, original); }); test("pipe wire with || roundtrips", () => { @@ -947,7 +962,7 @@ o.result <- up:i.text || "N/A" }`; const reparsed = parseBridge(serializeBridge(parseBridge(input))); const original = parseBridge(input); - assert.deepStrictEqual(reparsed, original); + assertDeepStrictEqualIgnoringLoc(reparsed, original); }); }); @@ -1332,7 +1347,7 @@ o.label <- api.label catch i.fallbackLabel }`; const reparsed = parseBridge(serializeBridge(parseBridge(input))); - assert.deepStrictEqual(reparsed, parseBridge(input)); + assertDeepStrictEqualIgnoringLoc(reparsed, parseBridge(input)); }); test("catch pipe:source roundtrips", () => { @@ -1348,7 +1363,7 @@ o.label <- api.label catch up:i.errorDefault }`; const reparsed = parseBridge(serializeBridge(parseBridge(input))); - assert.deepStrictEqual(reparsed, parseBridge(input)); + assertDeepStrictEqualIgnoringLoc(reparsed, parseBridge(input)); }); test("|| source || source roundtrips (desugars to multi-wire)", () => { @@ -1367,7 +1382,7 @@ o.label <- p.label || b.label || "default" }`; const reparsed = parseBridge(serializeBridge(parseBridge(input))); - assert.deepStrictEqual(reparsed, parseBridge(input)); + assertDeepStrictEqualIgnoringLoc(reparsed, parseBridge(input)); }); test("full chain: || source || literal catch pipe roundtrips", () => { @@ -1385,7 +1400,7 @@ o.label <- api.label || b.label || "default" catch up:i.errorDefault }`; const reparsed = parseBridge(serializeBridge(parseBridge(input))); - assert.deepStrictEqual(reparsed, parseBridge(input)); + assertDeepStrictEqualIgnoringLoc(reparsed, parseBridge(input)); }); }); diff --git a/packages/bridge/test/source-locations.test.ts b/packages/bridge/test/source-locations.test.ts new file mode 100644 index 00000000..e7e9f4db --- /dev/null +++ b/packages/bridge/test/source-locations.test.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + parseBridgeChevrotain as parseBridge, + type Bridge, + type Wire, +} from "../src/index.ts"; + +function getBridge(text: string): Bridge { + const document = parseBridge(text); + const bridge = document.instructions.find( + (instruction): instruction is Bridge => instruction.kind === "bridge", + ); + assert.ok(bridge, "expected a bridge instruction"); + return bridge; +} + +function assertLoc(wire: Wire | undefined, line: number, column: number): void { + assert.ok(wire, "expected wire to exist"); + assert.ok(wire.loc, "expected wire to carry a source location"); + assert.equal(wire.loc.startLine, line); + assert.equal(wire.loc.startColumn, column); + assert.ok(wire.loc.endColumn >= wire.loc.startColumn); +} + +describe("parser source locations", () => { + it("pull wire loc is populated", () => { + const bridge = getBridge(`version 1.5 +bridge Query.test { + with input as i + with output as o + o.name <- i.user.name +}`); + + assertLoc(bridge.wires[0], 5, 3); + }); + + it("constant wire loc is populated", () => { + const bridge = getBridge(`version 1.5 +bridge Query.test { + with output as o + o.name = "Ada" +}`); + + assertLoc(bridge.wires[0], 4, 3); + }); + + it("ternary wire loc is populated", () => { + const bridge = getBridge(`version 1.5 +bridge Query.test { + with input as i + with output as o + o.name <- i.user ? i.user.name : "Anonymous" +}`); + + const ternaryWire = bridge.wires.find((wire) => "cond" in wire); + assertLoc(ternaryWire, 5, 3); + }); + + it("desugared template wires inherit the originating source loc", () => { + const bridge = getBridge(`version 1.5 +bridge Query.test { + with input as i + with output as o + o.label <- "Hello {i.name}" +}`); + + const concatPartWire = bridge.wires.find( + (wire) => wire.to.field === "concat", + ); + assertLoc(concatPartWire, 5, 3); + }); +}); From 66f97c85ba60b1fef6c4937752d78401ccd92597 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Sat, 7 Mar 2026 15:58:30 +0100 Subject: [PATCH 2/8] Playground errors --- .changeset/document-source-metadata.md | 15 ++ .changeset/runtime-error-tool-formatting.md | 12 + packages/bridge-compiler/src/codegen.ts | 77 +++++- .../bridge-compiler/src/execute-bridge.ts | 3 + packages/bridge-core/src/ExecutionTree.ts | 32 ++- packages/bridge-core/src/execute-bridge.ts | 15 +- packages/bridge-core/src/formatBridgeError.ts | 87 +++++++ packages/bridge-core/src/index.ts | 3 + packages/bridge-core/src/resolveWires.ts | 54 ++-- packages/bridge-core/src/scheduleTools.ts | 35 ++- packages/bridge-core/src/tree-types.ts | 104 ++++++-- packages/bridge-core/src/types.ts | 10 + .../bridge-core/test/execution-tree.test.ts | 14 +- .../bridge-graphql/src/bridge-transform.ts | 81 ++++-- packages/bridge-parser/src/bridge-format.ts | 12 +- packages/bridge-parser/src/index.ts | 6 +- packages/bridge-parser/src/parser/index.ts | 6 +- packages/bridge-parser/src/parser/parser.ts | 231 ++++++++++++------ packages/bridge/test/control-flow.test.ts | 23 +- packages/bridge/test/parse-test-utils.ts | 7 +- .../bridge/test/runtime-error-format.test.ts | 191 +++++++++++++++ packages/bridge/test/source-locations.test.ts | 30 +++ packages/bridge/test/ternary.test.ts | 5 +- .../playground/src/components/ResultView.tsx | 7 +- packages/playground/src/engine.ts | 32 ++- 25 files changed, 907 insertions(+), 185 deletions(-) create mode 100644 .changeset/document-source-metadata.md create mode 100644 .changeset/runtime-error-tool-formatting.md create mode 100644 packages/bridge-core/src/formatBridgeError.ts create mode 100644 packages/bridge/test/runtime-error-format.test.ts diff --git a/.changeset/document-source-metadata.md b/.changeset/document-source-metadata.md new file mode 100644 index 00000000..33f921e5 --- /dev/null +++ b/.changeset/document-source-metadata.md @@ -0,0 +1,15 @@ +--- +"@stackables/bridge": patch +"@stackables/bridge-core": patch +"@stackables/bridge-compiler": patch +"@stackables/bridge-graphql": patch +"@stackables/bridge-parser": patch +--- + +Move Bridge source metadata onto BridgeDocument. + +Parsed documents now retain their original source text automatically, and can +optionally carry a filename from parse time. Runtime execution, compiler +fallbacks, GraphQL execution, and playground formatting now read that metadata +from the document instead of requiring callers to thread source and filename +through execute options. diff --git a/.changeset/runtime-error-tool-formatting.md b/.changeset/runtime-error-tool-formatting.md new file mode 100644 index 00000000..74bd01b6 --- /dev/null +++ b/.changeset/runtime-error-tool-formatting.md @@ -0,0 +1,12 @@ +--- +"@stackables/bridge": patch +"@stackables/bridge-core": patch +--- + +Improve formatted runtime errors for missing tools and source underlines. + +`No tool found for "..."` and missing registered tool-function errors now carry +Bridge source locations when they originate from authored bridge wires, so +formatted errors include the filename, line, and highlighted source span. +Caret underlines now render the full inclusive source span instead of stopping +one character short. diff --git a/packages/bridge-compiler/src/codegen.ts b/packages/bridge-compiler/src/codegen.ts index 38fb7bdc..e6d52049 100644 --- a/packages/bridge-compiler/src/codegen.ts +++ b/packages/bridge-compiler/src/codegen.ts @@ -30,6 +30,7 @@ import type { NodeRef, ToolDef, } from "@stackables/bridge-core"; +import type { SourceLocation } from "@stackables/bridge-types"; import { assertBridgeCompilerCompatible } from "./bridge-asserts.ts"; const SELF_MODULE = "_"; @@ -694,12 +695,42 @@ class CodegenContext { lines.push( ` const __BridgeTimeoutError = __opts?.__BridgeTimeoutError ?? class extends Error { constructor(n, ms) { super('Tool "' + n + '" timed out after ' + ms + 'ms'); this.name = "BridgeTimeoutError"; } };`, ); + lines.push( + ` const __BridgeRuntimeError = __opts?.__BridgeRuntimeError ?? class extends Error { constructor(message, options) { super(message, options && "cause" in options ? { cause: options.cause } : undefined); this.name = "BridgeRuntimeError"; this.bridgeLoc = options?.bridgeLoc; this.bridgeSource = options?.bridgeSource; this.bridgeFilename = options?.bridgeFilename; } };`, + ); lines.push(` const __signal = __opts?.signal;`); lines.push(` const __timeoutMs = __opts?.toolTimeoutMs ?? 0;`); + lines.push(` const __bridgeSource = __opts?.source;`); + lines.push(` const __bridgeFilename = __opts?.filename;`); lines.push( ` const __ctx = { logger: __opts?.logger ?? {}, signal: __signal };`, ); lines.push(` const __trace = __opts?.__trace;`); + lines.push(` function __rethrowBridgeError(err, loc) {`); + lines.push( + ` if (err?.name === "BridgePanicError" || err?.name === "BridgeAbortError") throw err;`, + ); + lines.push( + ` if (err?.name === "BridgeRuntimeError" && err.bridgeLoc !== undefined) throw err;`, + ); + lines.push( + ` throw new __BridgeRuntimeError(err instanceof Error ? err.message : String(err), { cause: err, bridgeLoc: loc, bridgeSource: __bridgeSource, bridgeFilename: __bridgeFilename });`, + ); + lines.push(` }`); + lines.push(` function __wrapBridgeError(fn, loc) {`); + lines.push(` try {`); + lines.push(` return fn();`); + lines.push(` } catch (err) {`); + lines.push(` __rethrowBridgeError(err, loc);`); + lines.push(` }`); + lines.push(` }`); + lines.push(` async function __wrapBridgeErrorAsync(fn, loc) {`); + lines.push(` try {`); + lines.push(` return await fn();`); + lines.push(` } catch (err) {`); + lines.push(` __rethrowBridgeError(err, loc);`); + lines.push(` }`); + lines.push(` }`); // Sync tool caller — no await, no timeout, enforces no-promise return. lines.push(` function __callSync(fn, input, toolName) {`); lines.push(` if (__signal?.aborted) throw new __BridgeAbortError();`); @@ -2222,9 +2253,9 @@ class CodegenContext { // Pull wire if ("from" in w) { - let expr = this.refToExpr(w.from); + let expr = this.wrapExprWithLoc(this.refToExpr(w.from), w.fromLoc); expr = this.applyFallbacks(w, expr); - return expr; + return this.wrapWireExpr(w, expr); } // Conditional wire (ternary) @@ -2244,7 +2275,7 @@ class CodegenContext { : "undefined"; let expr = `(${condExpr} ? ${thenExpr} : ${elseExpr})`; expr = this.applyFallbacks(w, expr); - return expr; + return this.wrapWireExpr(w, expr); } // Logical AND @@ -2258,7 +2289,7 @@ class CodegenContext { expr = `(Boolean(${left}) && Boolean(${emitCoerced(rightValue)}))`; else expr = `Boolean(${left})`; expr = this.applyFallbacks(w, expr); - return expr; + return this.wrapWireExpr(w, expr); } // Logical OR @@ -2272,7 +2303,7 @@ class CodegenContext { expr = `(Boolean(${left}) || Boolean(${emitCoerced(rightValue)}))`; else expr = `Boolean(${left})`; expr = this.applyFallbacks(w, expr); - return expr; + return this.wrapWireExpr(w, expr); } return "undefined"; @@ -2284,13 +2315,34 @@ class CodegenContext { this.elementVarStack.push(elVar); this.currentElVar = elVar; try { - return this._elementWireToExprInner(w, elVar); + return this.wrapWireExpr(w, this._elementWireToExprInner(w, elVar)); } finally { this.elementVarStack.pop(); this.currentElVar = prevElVar; } } + private wrapWireExpr(w: Wire, expr: string): string { + const loc = this.serializeLoc(w.loc); + if (expr.includes("await ")) { + return `await __wrapBridgeErrorAsync(async () => (${expr}), ${loc})`; + } + return `__wrapBridgeError(() => (${expr}), ${loc})`; + } + + private serializeLoc(loc?: SourceLocation): string { + return JSON.stringify(loc ?? null); + } + + private wrapExprWithLoc(expr: string, loc?: SourceLocation): string { + if (!loc) return expr; + const serializedLoc = this.serializeLoc(loc); + if (expr.includes("await ")) { + return `await __wrapBridgeErrorAsync(async () => (${expr}), ${serializedLoc})`; + } + return `__wrapBridgeError(() => (${expr}), ${serializedLoc})`; + } + private refToElementExpr(ref: NodeRef): string { const depth = ref.elementDepth ?? 0; const stackIndex = this.elementVarStack.length - 1 - depth; @@ -2362,17 +2414,19 @@ class CodegenContext { `(${expr})` + w.from.path.map((p) => `?.[${JSON.stringify(p)}]`).join(""); } + expr = this.wrapExprWithLoc(expr, w.fromLoc); expr = this.applyFallbacks(w, expr); return expr; } // Non-element ref inside array mapping — use normal refToExpr - let expr = this.refToExpr(w.from); + let expr = this.wrapExprWithLoc(this.refToExpr(w.from), w.fromLoc); expr = this.applyFallbacks(w, expr); return expr; } // Element refs: from.element === true, path = ["srcField"] let expr = elVar + w.from.path.map((p) => `?.[${JSON.stringify(p)}]`).join(""); + expr = this.wrapExprWithLoc(expr, w.fromLoc); expr = this.applyFallbacks(w, expr); return expr; } @@ -2888,7 +2942,7 @@ class CodegenContext { for (const fb of w.fallbacks) { if (fb.type === "falsy") { if (fb.ref) { - expr = `(${expr} || ${this.refToExpr(fb.ref)})`; // lgtm [js/code-injection] + expr = `(${expr} || ${this.wrapExprWithLoc(this.refToExpr(fb.ref), fb.loc)})`; // lgtm [js/code-injection] } else if (fb.value != null) { expr = `(${expr} || ${emitCoerced(fb.value)})`; // lgtm [js/code-injection] } else if (fb.control) { @@ -2902,7 +2956,7 @@ class CodegenContext { } else { // nullish if (fb.ref) { - expr = `((__v) => (__v == null ? undefined : __v))((${expr} ?? ${this.refToExpr(fb.ref)}))`; // lgtm [js/code-injection] + expr = `((__v) => (__v == null ? undefined : __v))((${expr} ?? ${this.wrapExprWithLoc(this.refToExpr(fb.ref), fb.loc)}))`; // lgtm [js/code-injection] } else if (fb.value != null) { expr = `((__v) => (__v == null ? undefined : __v))((${expr} ?? ${emitCoerced(fb.value)}))`; // lgtm [js/code-injection] } else if (fb.control) { @@ -2923,7 +2977,10 @@ class CodegenContext { if (hasCatchFallback(w)) { let catchExpr: string; if ("catchFallbackRef" in w && w.catchFallbackRef) { - catchExpr = this.refToExpr(w.catchFallbackRef); + catchExpr = this.wrapExprWithLoc( + this.refToExpr(w.catchFallbackRef), + "catchLoc" in w ? w.catchLoc : undefined, + ); } else if ("catchFallback" in w && w.catchFallback != null) { catchExpr = emitCoerced(w.catchFallback); } else { diff --git a/packages/bridge-compiler/src/execute-bridge.ts b/packages/bridge-compiler/src/execute-bridge.ts index 464294b5..5d53d4fb 100644 --- a/packages/bridge-compiler/src/execute-bridge.ts +++ b/packages/bridge-compiler/src/execute-bridge.ts @@ -17,6 +17,7 @@ import { TraceCollector, BridgePanicError, BridgeAbortError, + BridgeRuntimeError, BridgeTimeoutError, executeBridge as executeCoreBridge, } from "@stackables/bridge-core"; @@ -104,6 +105,7 @@ type BridgeFn = ( __BridgePanicError?: new (...args: any[]) => Error; __BridgeAbortError?: new (...args: any[]) => Error; __BridgeTimeoutError?: new (...args: any[]) => Error; + __BridgeRuntimeError?: new (...args: any[]) => Error; }, ) => Promise; @@ -298,6 +300,7 @@ export async function executeBridge( __BridgePanicError: BridgePanicError, __BridgeAbortError: BridgeAbortError, __BridgeTimeoutError: BridgeTimeoutError, + __BridgeRuntimeError: BridgeRuntimeError, __trace: tracer ? ( toolName: string, diff --git a/packages/bridge-core/src/ExecutionTree.ts b/packages/bridge-core/src/ExecutionTree.ts index fbe161db..aff4f797 100644 --- a/packages/bridge-core/src/ExecutionTree.ts +++ b/packages/bridge-core/src/ExecutionTree.ts @@ -31,6 +31,7 @@ import { BREAK_SYM, BridgeAbortError, BridgePanicError, + wrapBridgeRuntimeError, CONTINUE_SYM, decrementLoopControl, isLoopControlSignal, @@ -91,6 +92,8 @@ function stableMemoizeKey(value: unknown): string { export class ExecutionTree implements TreeContext { state: Record = {}; bridge: Bridge | undefined; + source?: string; + filename?: string; /** * Cache for resolved tool dependency promises. * Public to satisfy `ToolLookupContext` — used by `toolLookup.ts`. @@ -542,7 +545,7 @@ export class ExecutionTree implements TreeContext { * Traverse `ref.path` on an already-resolved value, respecting null guards. * Extracted from `pullSingle` so the sync and async paths can share logic. */ - private applyPath(resolved: any, ref: NodeRef): any { + private applyPath(resolved: any, ref: NodeRef, bridgeLoc?: Wire["loc"]): any { if (!ref.path.length) return resolved; let result: any = resolved; @@ -550,8 +553,15 @@ export class ExecutionTree implements TreeContext { // Root-level null check if (result == null) { if (ref.rootSafe || ref.element) return undefined; - throw new TypeError( - `Cannot read properties of ${result} (reading '${ref.path[0]}')`, + throw wrapBridgeRuntimeError( + new TypeError( + `Cannot read properties of ${result} (reading '${ref.path[0]}')`, + ), + { + bridgeLoc, + bridgeSource: this.source, + bridgeFilename: this.filename, + }, ); } @@ -568,8 +578,15 @@ export class ExecutionTree implements TreeContext { if (result == null && i < ref.path.length - 1) { const nextSafe = (ref.pathSafe?.[i + 1] ?? false) || !!ref.element; if (nextSafe) return undefined; - throw new TypeError( - `Cannot read properties of ${result} (reading '${ref.path[i + 1]}')`, + throw wrapBridgeRuntimeError( + new TypeError( + `Cannot read properties of ${result} (reading '${ref.path[i + 1]}')`, + ), + { + bridgeLoc, + bridgeSource: this.source, + bridgeFilename: this.filename, + }, ); } } @@ -587,6 +604,7 @@ export class ExecutionTree implements TreeContext { pullSingle( ref: NodeRef, pullChain: Set = new Set(), + bridgeLoc?: Wire["loc"], ): MaybePromise { // Cache trunkKey on the NodeRef via a Symbol key to avoid repeated // string allocation. Symbol keys don't affect V8 hidden classes, @@ -649,12 +667,12 @@ export class ExecutionTree implements TreeContext { // Sync fast path: value is already resolved (not a pending Promise). if (!isPromise(value)) { - return this.applyPath(value, ref); + return this.applyPath(value, ref, bridgeLoc); } // Async: chain path traversal onto the pending promise. return (value as Promise).then((resolved: any) => - this.applyPath(resolved, ref), + this.applyPath(resolved, ref, bridgeLoc), ); } diff --git a/packages/bridge-core/src/execute-bridge.ts b/packages/bridge-core/src/execute-bridge.ts index 89bcc253..f819687d 100644 --- a/packages/bridge-core/src/execute-bridge.ts +++ b/packages/bridge-core/src/execute-bridge.ts @@ -133,12 +133,23 @@ export async function executeBridge( const tree = new ExecutionTree(trunk, doc, allTools, context); + tree.source = doc.source; + tree.filename = doc.filename; + if (options.logger) tree.logger = options.logger; if (options.signal) tree.signal = options.signal; - if (options.toolTimeoutMs !== undefined && Number.isFinite(options.toolTimeoutMs) && options.toolTimeoutMs >= 0) { + if ( + options.toolTimeoutMs !== undefined && + Number.isFinite(options.toolTimeoutMs) && + options.toolTimeoutMs >= 0 + ) { tree.toolTimeoutMs = Math.floor(options.toolTimeoutMs); } - if (options.maxDepth !== undefined && Number.isFinite(options.maxDepth) && options.maxDepth >= 0) { + if ( + options.maxDepth !== undefined && + Number.isFinite(options.maxDepth) && + options.maxDepth >= 0 + ) { tree.maxDepth = Math.floor(options.maxDepth); } diff --git a/packages/bridge-core/src/formatBridgeError.ts b/packages/bridge-core/src/formatBridgeError.ts new file mode 100644 index 00000000..a3e5b0b2 --- /dev/null +++ b/packages/bridge-core/src/formatBridgeError.ts @@ -0,0 +1,87 @@ +import { BridgeRuntimeError } from "./tree-types.ts"; +import type { SourceLocation } from "./types.ts"; + +export type FormatBridgeErrorOptions = { + source?: string; + filename?: string; +}; + +function getMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +function getBridgeLoc(err: unknown): SourceLocation | undefined { + return err instanceof BridgeRuntimeError ? err.bridgeLoc : undefined; +} + +function getBridgeSource( + err: unknown, + options: FormatBridgeErrorOptions, +): string | undefined { + return ( + options.source ?? + (err instanceof BridgeRuntimeError ? err.bridgeSource : undefined) + ); +} + +function getBridgeFilename( + err: unknown, + options: FormatBridgeErrorOptions, +): string { + return ( + options.filename ?? + (err instanceof BridgeRuntimeError ? err.bridgeFilename : undefined) ?? + "" + ); +} + +function renderSnippet(source: string, loc: SourceLocation): string { + const lines = source.replace(/\r\n?/g, "\n").split("\n"); + const targetLine = lines[loc.startLine - 1] ?? ""; + const previousLine = loc.startLine > 1 ? lines[loc.startLine - 2] : undefined; + const nextLine = lines[loc.startLine] ?? undefined; + const width = String(loc.startLine + (nextLine !== undefined ? 1 : 0)).length; + const gutter = " ".repeat(width); + const markerStart = Math.max(0, loc.startColumn - 1); + const markerWidth = Math.max( + 1, + loc.endLine === loc.startLine + ? loc.endColumn - loc.startColumn + 1 + : targetLine.length - loc.startColumn + 1, + ); + const marker = `${" ".repeat(markerStart)}${"^".repeat(markerWidth)}`; + + const snippet: string[] = [`${gutter} |`]; + if (previousLine !== undefined) { + snippet.push( + `${String(loc.startLine - 1).padStart(width)} | ${previousLine}`, + ); + } + snippet.push(`${String(loc.startLine).padStart(width)} | ${targetLine}`); + snippet.push(`${gutter} | ${marker}`); + if (nextLine !== undefined) { + snippet.push(`${String(loc.startLine + 1).padStart(width)} | ${nextLine}`); + } + return snippet.join("\n"); +} + +export function formatBridgeError( + err: unknown, + options: FormatBridgeErrorOptions = {}, +): string { + const message = getMessage(err); + const loc = getBridgeLoc(err); + if (!loc) { + return `Bridge Execution Error: ${message}`; + } + + const filename = getBridgeFilename(err, options); + const source = getBridgeSource(err, options); + const header = `Bridge Execution Error: ${message}\n --> ${filename}:${loc.startLine}:${loc.startColumn}`; + + if (!source) { + return header; + } + + return `${header}\n${renderSnippet(source, loc)}`; +} diff --git a/packages/bridge-core/src/index.ts b/packages/bridge-core/src/index.ts index 6f0c1793..e4c8b372 100644 --- a/packages/bridge-core/src/index.ts +++ b/packages/bridge-core/src/index.ts @@ -35,9 +35,12 @@ export { mergeBridgeDocuments } from "./merge-documents.ts"; export { ExecutionTree } from "./ExecutionTree.ts"; export { TraceCollector, boundedClone } from "./tracing.ts"; export type { ToolTrace, TraceLevel } from "./tracing.ts"; +export { formatBridgeError } from "./formatBridgeError.ts"; +export type { FormatBridgeErrorOptions } from "./formatBridgeError.ts"; export { BridgeAbortError, BridgePanicError, + BridgeRuntimeError, BridgeTimeoutError, MAX_EXECUTION_DEPTH, } from "./tree-types.ts"; diff --git a/packages/bridge-core/src/resolveWires.ts b/packages/bridge-core/src/resolveWires.ts index 55d2aff9..c0521fb9 100644 --- a/packages/bridge-core/src/resolveWires.ts +++ b/packages/bridge-core/src/resolveWires.ts @@ -16,6 +16,7 @@ import { isPromise, applyControlFlow, BridgeAbortError, + wrapBridgeRuntimeError, } from "./tree-types.ts"; import { coerceConstant, getSimplePullRef } from "./tree-utils.ts"; @@ -71,7 +72,13 @@ export function resolveWires( const w = wires[0]!; if ("value" in w) return coerceConstant(w.value); const ref = getSimplePullRef(w); - if (ref) return ctx.pullSingle(ref, pullChain); + if (ref) { + return ctx.pullSingle( + ref, + pullChain, + "from" in w ? (w.fromLoc ?? w.loc) : w.loc, + ); + } } return resolveWiresAsync(ctx, wires, pullChain); } @@ -108,7 +115,11 @@ async function resolveWiresAsync( const recoveredValue = await applyCatchGate(ctx, w, pullChain); if (recoveredValue !== undefined) return recoveredValue; - lastError = err; + lastError = wrapBridgeRuntimeError(err, { + bridgeLoc: w.loc, + bridgeSource: ctx.source, + bridgeFilename: ctx.filename, + }); } } @@ -142,7 +153,11 @@ export async function applyFallbackGates( if (isFalsyGateOpen || isNullishGateOpen) { if (fallback.control) return applyControlFlow(fallback.control); if (fallback.ref) { - value = await ctx.pullSingle(fallback.ref, pullChain); + value = await ctx.pullSingle( + fallback.ref, + pullChain, + fallback.loc ?? w.loc, + ); } else if (fallback.value !== undefined) { value = coerceConstant(fallback.value); } @@ -168,7 +183,9 @@ export async function applyCatchGate( pullChain?: Set, ): Promise { if (w.catchControl) return applyControlFlow(w.catchControl); - if (w.catchFallbackRef) return ctx.pullSingle(w.catchFallbackRef, pullChain); + if (w.catchFallbackRef) { + return ctx.pullSingle(w.catchFallbackRef, pullChain, w.catchLoc ?? w.loc); + } if (w.catchFallback != null) return coerceConstant(w.catchFallback); return undefined; } @@ -190,10 +207,14 @@ async function evaluateWireSource( if ("cond" in w) { const condValue = await ctx.pullSingle(w.cond, pullChain); if (condValue) { - if (w.thenRef !== undefined) return ctx.pullSingle(w.thenRef, pullChain); + if (w.thenRef !== undefined) { + return ctx.pullSingle(w.thenRef, pullChain, w.loc); + } if (w.thenValue !== undefined) return coerceConstant(w.thenValue); } else { - if (w.elseRef !== undefined) return ctx.pullSingle(w.elseRef, pullChain); + if (w.elseRef !== undefined) { + return ctx.pullSingle(w.elseRef, pullChain, w.loc); + } if (w.elseValue !== undefined) return coerceConstant(w.elseValue); } return undefined; @@ -201,20 +222,24 @@ async function evaluateWireSource( if ("condAnd" in w) { const { leftRef, rightRef, rightValue, safe, rightSafe } = w.condAnd; - const leftVal = await pullSafe(ctx, leftRef, safe, pullChain); + const leftVal = await pullSafe(ctx, leftRef, safe, pullChain, w.loc); if (!leftVal) return false; if (rightRef !== undefined) - return Boolean(await pullSafe(ctx, rightRef, rightSafe, pullChain)); + return Boolean( + await pullSafe(ctx, rightRef, rightSafe, pullChain, w.loc), + ); if (rightValue !== undefined) return Boolean(coerceConstant(rightValue)); return Boolean(leftVal); } if ("condOr" in w) { const { leftRef, rightRef, rightValue, safe, rightSafe } = w.condOr; - const leftVal = await pullSafe(ctx, leftRef, safe, pullChain); + const leftVal = await pullSafe(ctx, leftRef, safe, pullChain, w.loc); if (leftVal) return true; if (rightRef !== undefined) - return Boolean(await pullSafe(ctx, rightRef, rightSafe, pullChain)); + return Boolean( + await pullSafe(ctx, rightRef, rightSafe, pullChain, w.loc), + ); if (rightValue !== undefined) return Boolean(coerceConstant(rightValue)); return Boolean(leftVal); } @@ -222,13 +247,13 @@ async function evaluateWireSource( if ("from" in w) { if (w.safe) { try { - return await ctx.pullSingle(w.from, pullChain); + return await ctx.pullSingle(w.from, pullChain, w.fromLoc ?? w.loc); } catch (err: any) { if (isFatalError(err)) throw err; return undefined; } } - return ctx.pullSingle(w.from, pullChain); + return ctx.pullSingle(w.from, pullChain, w.fromLoc ?? w.loc); } return undefined; @@ -246,16 +271,17 @@ function pullSafe( ref: NodeRef, safe: boolean | undefined, pullChain?: Set, + bridgeLoc?: Wire["loc"], ): MaybePromise { // FAST PATH: Unsafe wires bypass the try/catch overhead entirely if (!safe) { - return ctx.pullSingle(ref, pullChain); + return ctx.pullSingle(ref, pullChain, bridgeLoc); } // SAFE PATH: We must catch synchronous throws during the invocation let pull: any; try { - pull = ctx.pullSingle(ref, pullChain); + pull = ctx.pullSingle(ref, pullChain, bridgeLoc); } catch (e: any) { // Caught a synchronous error! if (isFatalError(e)) throw e; diff --git a/packages/bridge-core/src/scheduleTools.ts b/packages/bridge-core/src/scheduleTools.ts index 31aba702..8b66f742 100644 --- a/packages/bridge-core/src/scheduleTools.ts +++ b/packages/bridge-core/src/scheduleTools.ts @@ -10,7 +10,7 @@ import type { Bridge, NodeRef, ToolDef, Wire } from "./types.ts"; import { SELF_MODULE } from "./types.ts"; -import { isPromise } from "./tree-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 { @@ -44,6 +44,10 @@ export interface SchedulerContext extends ToolLookupContext { readonly handleVersionMap: ReadonlyMap; /** Tool trunks marked with `memoize`. */ readonly memoizedToolKeys: ReadonlySet; + /** Optional original bridge source used for formatted runtime errors. */ + readonly source?: string; + /** Optional bridge filename used for formatted runtime errors. */ + readonly filename?: string; // ── Methods ──────────────────────────────────────────────────────────── /** Recursive entry point — parent delegation calls this. */ @@ -52,6 +56,15 @@ export interface SchedulerContext extends ToolLookupContext { resolveWires(wires: Wire[], pullChain?: Set): MaybePromise; } +function getBridgeLocFromGroups(groupEntries: [string, Wire[]][]): Wire["loc"] { + for (const [, wires] of groupEntries) { + for (const wire of wires) { + if (wire.loc) return wire.loc; + } + } + return undefined; +} + // ── Helpers ───────────────────────────────────────────────────────────────── /** Derive tool name from a trunk. */ @@ -270,6 +283,7 @@ export function scheduleFinish( ): MaybePromise { const input: Record = {}; const resolved: [string[], any][] = []; + const bridgeLoc = getBridgeLocFromGroups(groupEntries); for (let i = 0; i < groupEntries.length; i++) { const path = groupEntries[i]![1][0]!.to.path; const value = resolvedValues[i]; @@ -323,7 +337,11 @@ export function scheduleFinish( return input; } - throw new Error(`No tool found for "${toolName}"`); + throw wrapBridgeRuntimeError(new Error(`No tool found for "${toolName}"`), { + bridgeLoc, + bridgeSource: ctx.source, + bridgeFilename: ctx.filename, + }); } // ── Schedule ToolDef ──────────────────────────────────────────────────────── @@ -361,9 +379,20 @@ export async function scheduleToolDef( } } + const bridgeLoc = getBridgeLocFromGroups(groupEntries); + // Call ToolDef-backed tool function const fn = lookupToolFn(ctx, toolDef.fn!); - if (!fn) throw new Error(`Tool function "${toolDef.fn}" not registered`); + if (!fn) { + throw wrapBridgeRuntimeError( + new Error(`Tool function "${toolDef.fn}" not registered`), + { + bridgeLoc, + bridgeSource: ctx.source, + bridgeFilename: ctx.filename, + }, + ); + } // on error: wrap the tool call with fallback from onError wire const onErrorWire = toolDef.wires.find((w) => w.kind === "onError"); diff --git a/packages/bridge-core/src/tree-types.ts b/packages/bridge-core/src/tree-types.ts index 36412723..df4e7b8c 100644 --- a/packages/bridge-core/src/tree-types.ts +++ b/packages/bridge-core/src/tree-types.ts @@ -6,7 +6,11 @@ * See docs/execution-tree-refactor.md */ -import type { ControlFlowInstruction, NodeRef } from "./types.ts"; +import type { + ControlFlowInstruction, + NodeRef, + SourceLocation, +} from "./types.ts"; // ── Error classes ─────────────────────────────────────────────────────────── @@ -29,13 +33,34 @@ export class BridgeAbortError extends Error { /** Timeout error — raised when a tool call exceeds the configured timeout. */ export class BridgeTimeoutError extends Error { constructor(toolName: string, timeoutMs: number) { - super( - `Tool "${toolName}" timed out after ${timeoutMs}ms`, - ); + super(`Tool "${toolName}" timed out after ${timeoutMs}ms`); this.name = "BridgeTimeoutError"; } } +/** Runtime error enriched with the originating Bridge wire location. */ +export class BridgeRuntimeError extends Error { + bridgeLoc?: SourceLocation; + bridgeSource?: string; + bridgeFilename?: string; + + constructor( + message: string, + options: { + bridgeLoc?: SourceLocation; + bridgeSource?: string; + bridgeFilename?: string; + cause?: unknown; + } = {}, + ) { + super(message, "cause" in options ? { cause: options.cause } : undefined); + this.name = "BridgeRuntimeError"; + this.bridgeLoc = options.bridgeLoc; + this.bridgeSource = options.bridgeSource; + this.bridgeFilename = options.bridgeFilename; + } +} + // ── Sentinels ─────────────────────────────────────────────────────────────── /** Sentinel for `continue` — skip the current array element */ @@ -43,10 +68,13 @@ export const CONTINUE_SYM = Symbol.for("BRIDGE_CONTINUE"); /** Sentinel for `break` — halt array iteration */ export const BREAK_SYM = Symbol.for("BRIDGE_BREAK"); /** Multi-level loop control signal used for break/continue N (N > 1). */ -export type LoopControlSignal = { - __bridgeControl: "break" | "continue"; - levels: number; -} | typeof BREAK_SYM | typeof CONTINUE_SYM; +export type LoopControlSignal = + | { + __bridgeControl: "break" | "continue"; + levels: number; + } + | typeof BREAK_SYM + | typeof CONTINUE_SYM; // ── Constants ─────────────────────────────────────────────────────────────── @@ -100,9 +128,17 @@ export interface Path { */ export interface TreeContext { /** Resolve a single NodeRef, returning sync when already in state. */ - pullSingle(ref: NodeRef, pullChain?: Set): MaybePromise; + pullSingle( + ref: NodeRef, + pullChain?: Set, + bridgeLoc?: SourceLocation, + ): MaybePromise; /** External abort signal — cancels execution when triggered. */ signal?: AbortSignal; + /** Optional original bridge source used for formatted runtime errors. */ + source?: string; + /** Optional bridge filename used for formatted runtime errors. */ + filename?: string; } /** Returns `true` when `value` is a thenable (Promise or Promise-like). */ @@ -120,13 +156,55 @@ export function isFatalError(err: any): boolean { ); } -function controlLevels(ctrl: Extract): number { +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +export function wrapBridgeRuntimeError( + err: unknown, + options: { + bridgeLoc?: SourceLocation; + bridgeSource?: string; + bridgeFilename?: string; + } = {}, +): BridgeRuntimeError { + if (err instanceof BridgeRuntimeError) { + if ( + err.bridgeLoc || + err.bridgeSource || + err.bridgeFilename || + (!options.bridgeLoc && !options.bridgeSource && !options.bridgeFilename) + ) { + return err; + } + + return new BridgeRuntimeError(err.message, { + bridgeLoc: options.bridgeLoc, + bridgeSource: options.bridgeSource, + bridgeFilename: options.bridgeFilename, + cause: err.cause ?? err, + }); + } + + return new BridgeRuntimeError(errorMessage(err), { + bridgeLoc: options.bridgeLoc, + bridgeSource: options.bridgeSource, + bridgeFilename: options.bridgeFilename, + cause: err, + }); +} + +function controlLevels( + ctrl: Extract, +): number { const n = ctrl.levels; return Number.isInteger(n) && (n as number) > 0 ? (n as number) : 1; } /** True when `value` is a loop control signal (single- or multi-level). */ -export function isLoopControlSignal(value: unknown): value is typeof BREAK_SYM | typeof CONTINUE_SYM | LoopControlSignal { +export function isLoopControlSignal( + value: unknown, +): value is typeof BREAK_SYM | typeof CONTINUE_SYM | LoopControlSignal { if (value === BREAK_SYM || value === CONTINUE_SYM) return true; if (typeof value !== "object" || value == null) return false; const candidate = value as { __bridgeControl?: unknown; levels?: unknown }; @@ -160,9 +238,7 @@ export function applyControlFlow( if (ctrl.kind === "panic") throw new BridgePanicError(ctrl.message); if (ctrl.kind === "continue") { const levels = controlLevels(ctrl); - return levels <= 1 - ? CONTINUE_SYM - : { __bridgeControl: "continue", levels }; + return levels <= 1 ? CONTINUE_SYM : { __bridgeControl: "continue", levels }; } /* ctrl.kind === "break" */ const levels = controlLevels(ctrl); diff --git a/packages/bridge-core/src/types.ts b/packages/bridge-core/src/types.ts index 803589a9..c54fd297 100644 --- a/packages/bridge-core/src/types.ts +++ b/packages/bridge-core/src/types.ts @@ -43,6 +43,7 @@ export interface WireFallback { ref?: NodeRef; value?: string; control?: ControlFlowInstruction; + loc?: SourceLocation; } /** @@ -61,11 +62,13 @@ export type Wire = from: NodeRef; to: NodeRef; loc?: SourceLocation; + fromLoc?: SourceLocation; pipe?: true; /** When true, this wire merges source properties into target (from `...source` syntax). */ spread?: true; safe?: true; fallbacks?: WireFallback[]; + catchLoc?: SourceLocation; catchFallback?: string; catchFallbackRef?: NodeRef; catchControl?: ControlFlowInstruction; @@ -80,6 +83,7 @@ export type Wire = to: NodeRef; loc?: SourceLocation; fallbacks?: WireFallback[]; + catchLoc?: SourceLocation; catchFallback?: string; catchFallbackRef?: NodeRef; catchControl?: ControlFlowInstruction; @@ -96,6 +100,7 @@ export type Wire = to: NodeRef; loc?: SourceLocation; fallbacks?: WireFallback[]; + catchLoc?: SourceLocation; catchFallback?: string; catchFallbackRef?: NodeRef; catchControl?: ControlFlowInstruction; @@ -112,6 +117,7 @@ export type Wire = to: NodeRef; loc?: SourceLocation; fallbacks?: WireFallback[]; + catchLoc?: SourceLocation; catchFallback?: string; catchFallbackRef?: NodeRef; catchControl?: ControlFlowInstruction; @@ -315,6 +321,10 @@ export type Instruction = Bridge | ToolDef | ConstDef | DefineDef; export interface BridgeDocument { /** Declared language version (from `version X.Y` header). */ version?: string; + /** Original Bridge source text that produced this document. */ + source?: string; + /** Optional logical filename associated with the source text. */ + filename?: string; /** All instructions: bridge, tool, const, and define blocks. */ instructions: Instruction[]; } diff --git a/packages/bridge-core/test/execution-tree.test.ts b/packages/bridge-core/test/execution-tree.test.ts index 943e43bb..4388b159 100644 --- a/packages/bridge-core/test/execution-tree.test.ts +++ b/packages/bridge-core/test/execution-tree.test.ts @@ -3,6 +3,7 @@ import { describe, test } from "node:test"; import { BridgeAbortError, BridgePanicError, + BridgeRuntimeError, ExecutionTree, type BridgeDocument, type NodeRef, @@ -39,7 +40,18 @@ describe("ExecutionTree edge cases", () => { 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"])), TypeError); + 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", () => { diff --git a/packages/bridge-graphql/src/bridge-transform.ts b/packages/bridge-graphql/src/bridge-transform.ts index dc183a13..ba3ae831 100644 --- a/packages/bridge-graphql/src/bridge-transform.ts +++ b/packages/bridge-graphql/src/bridge-transform.ts @@ -14,6 +14,7 @@ import { ExecutionTree, TraceCollector, executeBridge as executeBridgeDefault, + formatBridgeError, resolveStd, checkHandleVersions, type Logger, @@ -250,26 +251,30 @@ export function bridgeTransform( info: GraphQLResolveInfo, ): Promise { const requestedFields = collectRequestedFields(info); - const { data, traces } = await executeBridgeFn({ - document: activeDoc, - operation: `${typeName}.${fieldName}`, - input: args, - context: bridgeContext, - tools: userTools, - ...(traceLevel !== "off" ? { trace: traceLevel } : {}), - logger, - ...(options?.toolTimeoutMs !== undefined - ? { toolTimeoutMs: options.toolTimeoutMs } - : {}), - ...(options?.maxDepth !== undefined - ? { maxDepth: options.maxDepth } - : {}), - ...(requestedFields.length > 0 ? { requestedFields } : {}), - }); - if (traceLevel !== "off") { - context.__bridgeTracer = { traces }; + try { + const { data, traces } = await executeBridgeFn({ + document: activeDoc, + operation: `${typeName}.${fieldName}`, + input: args, + context: bridgeContext, + tools: userTools, + ...(traceLevel !== "off" ? { trace: traceLevel } : {}), + logger, + ...(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), { cause: err }); } - return data; } const standalonePrecomputed = precomputeStandalone(); @@ -349,6 +354,8 @@ export function bridgeTransform( ); source.logger = logger; + source.source = activeDoc.source; + source.filename = activeDoc.filename; if ( options?.toolTimeoutMs !== undefined && Number.isFinite(options.toolTimeoutMs) && @@ -395,19 +402,34 @@ export function bridgeTransform( } if (source instanceof ExecutionTree) { - const result = await source.response(info.path, array); + let result; + try { + result = await source.response(info.path, array); + } catch (err) { + throw new Error(formatBridgeError(err), { cause: err }); + } // 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) { - return result.collectOutput(); + try { + return result.collectOutput(); + } catch (err) { + throw new Error(formatBridgeError(err), { cause: err }); + } } if (Array.isArray(result) && result[0] instanceof ExecutionTree) { - return Promise.all( - result.map((shadow: ExecutionTree) => shadow.collectOutput()), - ); + try { + return await Promise.all( + result.map((shadow: ExecutionTree) => + shadow.collectOutput(), + ), + ); + } catch (err) { + throw new Error(formatBridgeError(err), { cause: err }); + } } } @@ -415,9 +437,14 @@ export function bridgeTransform( // force promises so errors propagate into GraphQL `errors[]` // while still allowing parallel execution. if (info.path.prev && source.getForcedExecution()) { - return Promise.all([result, source.getForcedExecution()]).then( - ([data]) => data, - ); + try { + return await Promise.all([ + result, + source.getForcedExecution(), + ]).then(([data]) => data); + } catch (err) { + throw new Error(formatBridgeError(err), { cause: err }); + } } return result; } diff --git a/packages/bridge-parser/src/bridge-format.ts b/packages/bridge-parser/src/bridge-format.ts index 1c018838..17a254f6 100644 --- a/packages/bridge-parser/src/bridge-format.ts +++ b/packages/bridge-parser/src/bridge-format.ts @@ -9,14 +9,20 @@ import type { Wire, } from "@stackables/bridge-core"; import { SELF_MODULE } from "@stackables/bridge-core"; -import { parseBridgeChevrotain } from "./parser/index.ts"; +import { + parseBridgeChevrotain, + type ParseBridgeOptions, +} from "./parser/index.ts"; export { parsePath } from "@stackables/bridge-core"; /** * Parse .bridge text — delegates to the Chevrotain parser. */ -export function parseBridge(text: string): BridgeDocument { - return parseBridgeChevrotain(text); +export function parseBridge( + text: string, + options: ParseBridgeOptions = {}, +): BridgeDocument { + return parseBridgeChevrotain(text, options); } const BRIDGE_VERSION = "1.5"; diff --git a/packages/bridge-parser/src/index.ts b/packages/bridge-parser/src/index.ts index bf5fe06a..1e2ae3a6 100644 --- a/packages/bridge-parser/src/index.ts +++ b/packages/bridge-parser/src/index.ts @@ -14,7 +14,11 @@ export { parseBridgeDiagnostics, PARSER_VERSION, } from "./parser/index.ts"; -export type { BridgeDiagnostic, BridgeParseResult } from "./parser/index.ts"; +export type { + BridgeDiagnostic, + BridgeParseResult, + ParseBridgeOptions, +} from "./parser/index.ts"; export { BridgeLexer, allTokens } from "./parser/index.ts"; // ── Serializer ────────────────────────────────────────────────────────────── diff --git a/packages/bridge-parser/src/parser/index.ts b/packages/bridge-parser/src/parser/index.ts index 7ab36947..c92f9e38 100644 --- a/packages/bridge-parser/src/parser/index.ts +++ b/packages/bridge-parser/src/parser/index.ts @@ -9,5 +9,9 @@ export { parseBridgeDiagnostics, PARSER_VERSION, } from "./parser.ts"; -export type { BridgeDiagnostic, BridgeParseResult } from "./parser.ts"; +export type { + BridgeDiagnostic, + BridgeParseResult, + ParseBridgeOptions, +} from "./parser.ts"; export { BridgeLexer, allTokens } from "./lexer.ts"; diff --git a/packages/bridge-parser/src/parser/parser.ts b/packages/bridge-parser/src/parser/parser.ts index b00b6cc7..eeee5273 100644 --- a/packages/bridge-parser/src/parser/parser.ts +++ b/packages/bridge-parser/src/parser/parser.ts @@ -1072,15 +1072,7 @@ class BridgeParser extends CstParser { // consumption in multi-line contexts like element blocks. // LA(0) gives the last consumed token. const prev = this.LA(0); - if ( - prev && - la.startLine != null && - prev.endLine != null && - la.startLine > prev.endLine - ) { - return false; - } - return true; + return (prev.endLine ?? prev.startLine ?? 0) === (la.startLine ?? 0); } if (la.tokenType === LSquare) { const la2 = this.LA(2); @@ -1338,12 +1330,20 @@ export const PARSER_VERSION = { maxMajor: BRIDGE_MAX_MAJOR, } as const; +export type ParseBridgeOptions = { + /** Optional logical filename associated with the parsed source. */ + filename?: string; +}; + // ═══════════════════════════════════════════════════════════════════════════ // Public API // ═══════════════════════════════════════════════════════════════════════════ -export function parseBridgeChevrotain(text: string): BridgeDocument { - return internalParse(text); +export function parseBridgeChevrotain( + text: string, + options: ParseBridgeOptions = {}, +): BridgeDocument { + return internalParse(text, undefined, options); } export function parseBridgeCst(text: string): CstNode { @@ -1386,7 +1386,10 @@ export type BridgeParseResult = { * Uses Chevrotain's error recovery — always returns a (possibly partial) AST * even when the file has errors. Designed for LSP/IDE use. */ -export function parseBridgeDiagnostics(text: string): BridgeParseResult { +export function parseBridgeDiagnostics( + text: string, + options: ParseBridgeOptions = {}, +): BridgeParseResult { const diagnostics: BridgeDiagnostic[] = []; // 1. Lex @@ -1427,11 +1430,16 @@ export function parseBridgeDiagnostics(text: string): BridgeParseResult { } // 3. Visit → AST (semantic errors thrown as "Line N: ..." messages) - let document: BridgeDocument = { instructions: [] }; + let document: BridgeDocument = { source: text, instructions: [] }; let startLines = new Map(); try { const result = toBridgeAst(cst, []); - document = { version: result.version, instructions: result.instructions }; + document = { + version: result.version, + source: text, + ...(options.filename ? { filename: options.filename } : {}), + instructions: result.instructions, + }; startLines = result.startLines; } catch (err) { const msg = String((err as Error)?.message ?? err); @@ -1453,6 +1461,7 @@ export function parseBridgeDiagnostics(text: string): BridgeParseResult { function internalParse( text: string, previousInstructions?: Instruction[], + options: ParseBridgeOptions = {}, ): BridgeDocument { // 1. Lex const lexResult = BridgeLexer.tokenize(text); @@ -1471,7 +1480,12 @@ function internalParse( // 3. Visit → AST const result = toBridgeAst(cst, previousInstructions); - return { version: result.version, instructions: result.instructions }; + return { + version: result.version, + source: text, + ...(options.filename ? { filename: options.filename } : {}), + instructions: result.instructions, + }; } // ═══════════════════════════════════════════════════════════════════════════ @@ -1516,6 +1530,54 @@ function makeLoc( }; } +type CoalesceAltResult = + | { literal: string } + | { sourceRef: NodeRef } + | { control: ControlFlowInstruction }; + +function buildWireFallback( + type: "falsy" | "nullish", + altNode: CstNode, + altResult: CoalesceAltResult, +): WireFallback { + const loc = locFromNode(altNode); + if ("literal" in altResult) { + return { type, value: altResult.literal, ...(loc ? { loc } : {}) }; + } + if ("control" in altResult) { + return { type, control: altResult.control, ...(loc ? { loc } : {}) }; + } + return { type, ref: altResult.sourceRef, ...(loc ? { loc } : {}) }; +} + +function buildCatchAttrs( + catchAlt: CstNode, + altResult: CoalesceAltResult, +): { + catchLoc?: SourceLocation; + catchFallback?: string; + catchFallbackRef?: NodeRef; + catchControl?: ControlFlowInstruction; +} { + const catchLoc = locFromNode(catchAlt); + if ("literal" in altResult) { + return { + ...(catchLoc ? { catchLoc } : {}), + catchFallback: altResult.literal, + }; + } + if ("control" in altResult) { + return { + ...(catchLoc ? { catchLoc } : {}), + catchControl: altResult.control, + }; + } + return { + ...(catchLoc ? { catchLoc } : {}), + catchFallbackRef: altResult.sourceRef, + }; +} + /* ── extractNameToken: get string from nameToken CST node ── */ function extractNameToken(node: CstNode): string { const c = node.children; @@ -1643,6 +1705,17 @@ function locFromNode(node: CstNode | undefined): SourceLocation | undefined { return makeLoc(findFirstToken(node), findLastToken(node)); } +function locFromNodeRange( + startNode: CstNode | undefined, + endNode: CstNode | undefined = startNode, +): SourceLocation | undefined { + if (!startNode) return undefined; + return makeLoc( + findFirstToken(startNode), + findLastToken(endNode ?? startNode), + ); +} + function withLoc(wire: T, loc: SourceLocation | undefined): T { if (!loc) return wire; return { ...wire, loc } as T; @@ -5201,15 +5274,12 @@ function buildBridgeBody( const altNode = sub(item, "altValue")!; const preLen = wires.length; const altResult = extractCoalesceAlt(altNode, lineNum); - if ("literal" in altResult) { - aliasFallbacks.push({ type, value: altResult.literal }); - } else if ("control" in altResult) { - aliasFallbacks.push({ type, control: altResult.control }); - } else { - aliasFallbacks.push({ type, ref: altResult.sourceRef }); + aliasFallbacks.push(buildWireFallback(type, altNode, altResult)); + if ("sourceRef" in altResult) { aliasFallbackInternalWires.push(...wires.splice(preLen)); } } + let aliasCatchLoc: SourceLocation | undefined; let aliasCatchFallback: string | undefined; let aliasCatchControl: ControlFlowInstruction | undefined; let aliasCatchFallbackRef: NodeRef | undefined; @@ -5218,17 +5288,18 @@ function buildBridgeBody( if (aliasCatchAlt) { const preLen = wires.length; const altResult = extractCoalesceAlt(aliasCatchAlt, lineNum); - if ("literal" in altResult) { - aliasCatchFallback = altResult.literal; - } else if ("control" in altResult) { - aliasCatchControl = altResult.control; - } else { - aliasCatchFallbackRef = altResult.sourceRef; + const catchAttrs = buildCatchAttrs(aliasCatchAlt, altResult); + aliasCatchLoc = catchAttrs.catchLoc; + aliasCatchFallback = catchAttrs.catchFallback; + aliasCatchControl = catchAttrs.catchControl; + aliasCatchFallbackRef = catchAttrs.catchFallbackRef; + if ("sourceRef" in altResult) { aliasCatchFallbackInternalWires = wires.splice(preLen); } } const modifierAttrs = { ...(aliasFallbacks.length > 0 ? { fallbacks: aliasFallbacks } : {}), + ...(aliasCatchLoc ? { catchLoc: aliasCatchLoc } : {}), ...(aliasCatchFallback ? { catchFallback: aliasCatchFallback } : {}), ...(aliasCatchFallbackRef ? { catchFallbackRef: aliasCatchFallbackRef } @@ -5238,6 +5309,7 @@ function buildBridgeBody( // ── Compute the source ref ── let sourceRef: NodeRef; + let sourceLoc: SourceLocation | undefined; let aliasSafe: boolean | undefined; const aliasStringToken = ( @@ -5271,6 +5343,7 @@ function buildBridgeBody( } else { sourceRef = strRef; } + sourceLoc = aliasLoc; // Ternary after string source (e.g. alias "a" == "b" ? x : y as name) const strTernaryOp = ( nodeAliasNode.children.aliasStringTernaryOp as IToken[] | undefined @@ -5323,6 +5396,13 @@ function buildBridgeBody( ? !!extractAddressPath(headNode).rootSafe : false; const exprOps = subs(nodeAliasNode, "aliasExprOp"); + const exprRights = subs(nodeAliasNode, "aliasExprRight"); + sourceLoc = locFromNodeRange( + firstParenNode ?? firstSourceNode, + exprRights[exprRights.length - 1] ?? + firstParenNode ?? + firstSourceNode, + ); let condRef: NodeRef; if (firstParenNode) { @@ -5333,7 +5413,6 @@ function buildBridgeBody( isSafe, ); if (exprOps.length > 0) { - const exprRights = subs(nodeAliasNode, "aliasExprRight"); condRef = desugarExprChain( parenRef, exprOps, @@ -5347,7 +5426,6 @@ function buildBridgeBody( condRef = parenRef; } } else if (exprOps.length > 0) { - const exprRights = subs(nodeAliasNode, "aliasExprRight"); const leftRef = buildSourceExpr(firstSourceNode!, lineNum); condRef = desugarExprChain( leftRef, @@ -5433,6 +5511,7 @@ function buildBridgeBody( }; const aliasAttrs = { ...(aliasSafe ? { safe: true as const } : {}), + ...(sourceLoc ? { fromLoc: sourceLoc } : {}), ...modifierAttrs, }; wires.push( @@ -5557,15 +5636,12 @@ function buildBridgeBody( const altNode = sub(item, "altValue")!; const preLen = wires.length; const altResult = extractCoalesceAlt(altNode, lineNum); - if ("literal" in altResult) { - fallbacks.push({ type, value: altResult.literal }); - } else if ("control" in altResult) { - fallbacks.push({ type, control: altResult.control }); - } else { - fallbacks.push({ type, ref: altResult.sourceRef }); + fallbacks.push(buildWireFallback(type, altNode, altResult)); + if ("sourceRef" in altResult) { fallbackInternalWires.push(...wires.splice(preLen)); } } + let catchLoc: SourceLocation | undefined; let catchFallback: string | undefined; let catchControl: ControlFlowInstruction | undefined; let catchFallbackRef: NodeRef | undefined; @@ -5574,18 +5650,19 @@ function buildBridgeBody( if (catchAlt) { const preLen = wires.length; const altResult = extractCoalesceAlt(catchAlt, lineNum); - if ("literal" in altResult) { - catchFallback = altResult.literal; - } else if ("control" in altResult) { - catchControl = altResult.control; - } else { - catchFallbackRef = altResult.sourceRef; + const catchAttrs = buildCatchAttrs(catchAlt, altResult); + catchLoc = catchAttrs.catchLoc; + catchFallback = catchAttrs.catchFallback; + catchControl = catchAttrs.catchControl; + catchFallbackRef = catchAttrs.catchFallbackRef; + if ("sourceRef" in altResult) { catchFallbackInternalWires = wires.splice(preLen); } } const lastAttrs = { ...(fallbacks.length > 0 ? { fallbacks } : {}), + ...(catchLoc ? { catchLoc } : {}), ...(catchFallback ? { catchFallback } : {}), ...(catchFallbackRef ? { catchFallbackRef } : {}), ...(catchControl ? { catchControl } : {}), @@ -5639,15 +5716,12 @@ function buildBridgeBody( const altNode = sub(item, "altValue")!; const preLen = wires.length; const altResult = extractCoalesceAlt(altNode, lineNum); - if ("literal" in altResult) { - arrayFallbacks.push({ type, value: altResult.literal }); - } else if ("control" in altResult) { - arrayFallbacks.push({ type, control: altResult.control }); - } else { - arrayFallbacks.push({ type, ref: altResult.sourceRef }); + arrayFallbacks.push(buildWireFallback(type, altNode, altResult)); + if ("sourceRef" in altResult) { arrayFallbackInternalWires.push(...wires.splice(preLen)); } } + let arrayCatchLoc: SourceLocation | undefined; let arrayCatchFallback: string | undefined; let arrayCatchControl: ControlFlowInstruction | undefined; let arrayCatchFallbackRef: NodeRef | undefined; @@ -5656,17 +5730,18 @@ function buildBridgeBody( if (arrayCatchAlt) { const preLen = wires.length; const altResult = extractCoalesceAlt(arrayCatchAlt, lineNum); - if ("literal" in altResult) { - arrayCatchFallback = altResult.literal; - } else if ("control" in altResult) { - arrayCatchControl = altResult.control; - } else { - arrayCatchFallbackRef = altResult.sourceRef; + const catchAttrs = buildCatchAttrs(arrayCatchAlt, altResult); + arrayCatchLoc = catchAttrs.catchLoc; + arrayCatchFallback = catchAttrs.catchFallback; + arrayCatchControl = catchAttrs.catchControl; + arrayCatchFallbackRef = catchAttrs.catchFallbackRef; + if ("sourceRef" in altResult) { arrayCatchFallbackInternalWires = wires.splice(preLen); } } const arrayWireAttrs = { ...(arrayFallbacks.length > 0 ? { fallbacks: arrayFallbacks } : {}), + ...(arrayCatchLoc ? { catchLoc: arrayCatchLoc } : {}), ...(arrayCatchFallback ? { catchFallback: arrayCatchFallback } : {}), ...(arrayCatchFallbackRef ? { catchFallbackRef: arrayCatchFallbackRef } @@ -5728,6 +5803,11 @@ function buildBridgeBody( const isSafe = headNode ? !!extractAddressPath(headNode).rootSafe : false; const exprOps = subs(wireNode, "exprOp"); + const exprRights = subs(wireNode, "exprRight"); + const sourceLoc = locFromNodeRange( + firstParenNode ?? firstSourceNode, + exprRights[exprRights.length - 1] ?? firstParenNode ?? firstSourceNode, + ); // Compute condition ref (expression chain result or plain source) let condRef: NodeRef; @@ -5742,7 +5822,6 @@ function buildBridgeBody( wireLoc, ); if (exprOps.length > 0) { - const exprRights = subs(wireNode, "exprRight"); condRef = desugarExprChain( parenRef, exprOps, @@ -5758,7 +5837,6 @@ function buildBridgeBody( condIsPipeFork = true; } else if (exprOps.length > 0) { // It's a math/comparison expression — desugar it. - const exprRights = subs(wireNode, "exprRight"); const leftRef = buildSourceExpr(firstSourceNode!, lineNum); condRef = desugarExprChain( leftRef, @@ -5803,17 +5881,14 @@ function buildBridgeBody( const altNode = sub(item, "altValue")!; const preLen = wires.length; const altResult = extractCoalesceAlt(altNode, lineNum); - if ("literal" in altResult) { - fallbacks.push({ type, value: altResult.literal }); - } else if ("control" in altResult) { - fallbacks.push({ type, control: altResult.control }); - } else { - fallbacks.push({ type, ref: altResult.sourceRef }); + fallbacks.push(buildWireFallback(type, altNode, altResult)); + if ("sourceRef" in altResult) { fallbackInternalWires.push(...wires.splice(preLen)); } } // Process catch error fallback. + let catchLoc: SourceLocation | undefined; let catchFallback: string | undefined; let catchControl: ControlFlowInstruction | undefined; let catchFallbackRef: NodeRef | undefined; @@ -5822,12 +5897,12 @@ function buildBridgeBody( if (catchAlt) { const preLen = wires.length; const altResult = extractCoalesceAlt(catchAlt, lineNum); - if ("literal" in altResult) { - catchFallback = altResult.literal; - } else if ("control" in altResult) { - catchControl = altResult.control; - } else { - catchFallbackRef = altResult.sourceRef; + const catchAttrs = buildCatchAttrs(catchAlt, altResult); + catchLoc = catchAttrs.catchLoc; + catchFallback = catchAttrs.catchFallback; + catchControl = catchAttrs.catchControl; + catchFallbackRef = catchAttrs.catchFallbackRef; + if ("sourceRef" in altResult) { catchFallbackInternalWires = wires.splice(preLen); } } @@ -5843,6 +5918,7 @@ function buildBridgeBody( ? { elseRef: elseBranch.ref } : { elseValue: elseBranch.value }), ...(fallbacks.length > 0 ? { fallbacks } : {}), + ...(catchLoc ? { catchLoc } : {}), ...(catchFallback !== undefined ? { catchFallback } : {}), ...(catchFallbackRef !== undefined ? { catchFallbackRef } : {}), ...(catchControl ? { catchControl } : {}), @@ -5870,18 +5946,19 @@ function buildBridgeBody( const preLen = wires.length; const altResult = extractCoalesceAlt(altNode, lineNum); if ("literal" in altResult) { - fallbacks.push({ type, value: altResult.literal }); + fallbacks.push(buildWireFallback(type, altNode, altResult)); if (type === "falsy") { hasTruthyLiteralFallback = Boolean(JSON.parse(altResult.literal)); } } else if ("control" in altResult) { - fallbacks.push({ type, control: altResult.control }); + fallbacks.push(buildWireFallback(type, altNode, altResult)); } else { - fallbacks.push({ type, ref: altResult.sourceRef }); + fallbacks.push(buildWireFallback(type, altNode, altResult)); fallbackInternalWires.push(...wires.splice(preLen)); } } + let catchLoc: SourceLocation | undefined; let catchFallback: string | undefined; let catchControl: ControlFlowInstruction | undefined; let catchFallbackRef: NodeRef | undefined; @@ -5890,12 +5967,12 @@ function buildBridgeBody( if (catchAlt) { const preLen = wires.length; const altResult = extractCoalesceAlt(catchAlt, lineNum); - if ("literal" in altResult) { - catchFallback = altResult.literal; - } else if ("control" in altResult) { - catchControl = altResult.control; - } else { - catchFallbackRef = altResult.sourceRef; + const catchAttrs = buildCatchAttrs(catchAlt, altResult); + catchLoc = catchAttrs.catchLoc; + catchFallback = catchAttrs.catchFallback; + catchControl = catchAttrs.catchControl; + catchFallbackRef = catchAttrs.catchFallbackRef; + if ("sourceRef" in altResult) { catchFallbackInternalWires = wires.splice(preLen); } } @@ -5903,8 +5980,10 @@ function buildBridgeBody( const { ref: fromRef, isPipeFork: isPipe } = sourceParts[0]; const wireAttrs = { ...(isSafe ? { safe: true as const } : {}), + ...(sourceLoc ? { fromLoc: sourceLoc } : {}), ...(isPipe ? { pipe: true as const } : {}), ...(fallbacks.length > 0 ? { fallbacks } : {}), + ...(catchLoc ? { catchLoc } : {}), ...(catchFallback ? { catchFallback } : {}), ...(catchFallbackRef ? { catchFallbackRef } : {}), ...(catchControl ? { catchControl } : {}), diff --git a/packages/bridge/test/control-flow.test.ts b/packages/bridge/test/control-flow.test.ts index 0cdfcb38..d927ac97 100644 --- a/packages/bridge/test/control-flow.test.ts +++ b/packages/bridge/test/control-flow.test.ts @@ -7,6 +7,7 @@ import { import { BridgeAbortError, BridgePanicError } from "../src/index.ts"; import type { Bridge, Wire } from "../src/index.ts"; import { forEachEngine } from "./_dual-run.ts"; +import { assertDeepStrictEqualIgnoringLoc } from "./parse-test-utils.ts"; // ══════════════════════════════════════════════════════════════════════════════ // 1. Parser: control flow keywords @@ -26,7 +27,7 @@ bridge Query.test { "from" in w && w.to.path.join(".") === "name", ); assert.ok(pullWire); - assert.deepStrictEqual(pullWire.fallbacks, [ + assertDeepStrictEqualIgnoringLoc(pullWire.fallbacks, [ { type: "falsy", control: { kind: "throw", message: "name is required" }, @@ -47,7 +48,7 @@ bridge Query.test { "from" in w && w.to.path.join(".") === "name", ); assert.ok(pullWire); - assert.deepStrictEqual(pullWire.fallbacks, [ + assertDeepStrictEqualIgnoringLoc(pullWire.fallbacks, [ { type: "nullish", control: { kind: "panic", message: "fatal: name cannot be null" }, @@ -73,7 +74,7 @@ bridge Query.test { w.to.path.join(".") === "items.name", ); assert.ok(elemWire); - assert.deepStrictEqual(elemWire.fallbacks, [ + assertDeepStrictEqualIgnoringLoc(elemWire.fallbacks, [ { type: "nullish", control: { kind: "continue" } }, ]); }); @@ -96,7 +97,7 @@ bridge Query.test { w.to.path.join(".") === "items.name", ); assert.ok(elemWire); - assert.deepStrictEqual(elemWire.fallbacks, [ + assertDeepStrictEqualIgnoringLoc(elemWire.fallbacks, [ { type: "nullish", control: { kind: "break" } }, ]); }); @@ -128,10 +129,10 @@ bridge Query.test { ); assert.ok(skuWire); assert.ok(priceWire); - assert.deepStrictEqual(skuWire.fallbacks, [ + assertDeepStrictEqualIgnoringLoc(skuWire.fallbacks, [ { type: "nullish", control: { kind: "continue", levels: 2 } }, ]); - assert.deepStrictEqual(priceWire.fallbacks, [ + assertDeepStrictEqualIgnoringLoc(priceWire.fallbacks, [ { type: "nullish", control: { kind: "break", levels: 2 } }, ]); }); @@ -203,7 +204,7 @@ bridge Query.test { "from" in w && w.to.path.join(".") === "name", ); assert.ok(pullWire); - assert.deepStrictEqual(pullWire.fallbacks, [ + assertDeepStrictEqualIgnoringLoc(pullWire.fallbacks, [ { type: "falsy", control: { kind: "throw", message: "name is required" }, @@ -231,7 +232,7 @@ bridge Query.test { "from" in w && w.to.path.join(".") === "name", ); assert.ok(pullWire); - assert.deepStrictEqual(pullWire.fallbacks, [ + assertDeepStrictEqualIgnoringLoc(pullWire.fallbacks, [ { type: "nullish", control: { kind: "panic", message: "fatal" }, @@ -264,7 +265,7 @@ bridge Query.test { w.to.path.join(".") === "items.name", ); assert.ok(elemWire); - assert.deepStrictEqual(elemWire.fallbacks, [ + assertDeepStrictEqualIgnoringLoc(elemWire.fallbacks, [ { type: "nullish", control: { kind: "continue" } }, ]); }); @@ -328,10 +329,10 @@ bridge Query.test { ); assert.ok(skuWire); assert.ok(priceWire); - assert.deepStrictEqual(skuWire.fallbacks, [ + assertDeepStrictEqualIgnoringLoc(skuWire.fallbacks, [ { type: "nullish", control: { kind: "continue", levels: 2 } }, ]); - assert.deepStrictEqual(priceWire.fallbacks, [ + assertDeepStrictEqualIgnoringLoc(priceWire.fallbacks, [ { type: "nullish", control: { kind: "break", levels: 2 } }, ]); }); diff --git a/packages/bridge/test/parse-test-utils.ts b/packages/bridge/test/parse-test-utils.ts index 3da104e2..118c68a0 100644 --- a/packages/bridge/test/parse-test-utils.ts +++ b/packages/bridge/test/parse-test-utils.ts @@ -8,7 +8,12 @@ function omitLoc(value: unknown): unknown { if (value && typeof value === "object") { const result: Record = {}; for (const [key, entry] of Object.entries(value)) { - if (key === "loc") { + if ( + key === "loc" || + key.endsWith("Loc") || + key === "source" || + key === "filename" + ) { continue; } result[key] = omitLoc(entry); diff --git a/packages/bridge/test/runtime-error-format.test.ts b/packages/bridge/test/runtime-error-format.test.ts new file mode 100644 index 00000000..c0e84922 --- /dev/null +++ b/packages/bridge/test/runtime-error-format.test.ts @@ -0,0 +1,191 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { buildSchema, execute, parse } from "graphql"; +import { + BridgeRuntimeError, + bridgeTransform, + executeBridge, + formatBridgeError, + parseBridgeChevrotain as parseBridge, +} from "../src/index.ts"; + +const bridgeText = `version 1.5 + +bridge Query.greet { + with std.str.toUpperCase as uc memoize + with std.str.toLowerCase as lc + with input as i + with output as o + + o.message <- i.empty.array.error + o.upper <- uc:i.name + o.lower <- lc:i.name +}`; + +const bridgeCoalesceText = `version 1.5 + +bridge Query.greet { + with std.str.toUpperCase as uc memoize + with std.str.toLowerCase as lc + with input as i + with output as o + + alias i.empty.array.error catch i.empty.array.error as clean + + o.message <- i.empty.array?.error ?? i.empty.array.error + o.upper <- uc:i.name + o.lower <- lc:i.name +}`; + +const bridgeMissingToolText = `version 1.5 + +bridge Query.greet { + with xxx as missing + with input as i + with output as o + + o.message <- missing:i.name +}`; + +function maxCaretCount(formatted: string): number { + return Math.max( + 0, + ...formatted.split("\n").map((line) => (line.match(/\^/g) ?? []).length), + ); +} + +describe("runtime error formatting", () => { + test("formatBridgeError underlines the full inclusive source span", () => { + const sourceLine = "o.message <- i.empty.array.error"; + const formatted = formatBridgeError( + new BridgeRuntimeError("boom", { + bridgeSource: sourceLine, + bridgeFilename: "playground.bridge", + bridgeLoc: { + startLine: 1, + startColumn: 14, + endLine: 1, + endColumn: 32, + }, + }), + ); + + assert.equal(maxCaretCount(formatted), "i.empty.array.error".length); + }); + + test("executeBridge formats runtime errors with bridge source location", async () => { + const document = parseBridge(bridgeText, { + filename: "playground.bridge", + }); + + await assert.rejects( + () => + executeBridge({ + document, + operation: "Query.greet", + input: { name: "Ada" }, + }), + (err: unknown) => { + const formatted = formatBridgeError(err); + assert.match( + formatted, + /Bridge Execution Error: Cannot read properties of undefined \(reading '(array|error)'\)/, + ); + assert.match(formatted, /playground\.bridge:9:16/); + assert.match(formatted, /o\.message <- i\.empty\.array\.error/); + assert.equal(maxCaretCount(formatted), "i.empty.array.error".length); + return true; + }, + ); + }); + + test("executeBridge formats missing tool errors with bridge source location", async () => { + const document = parseBridge(bridgeMissingToolText, { + filename: "playground.bridge", + }); + + await assert.rejects( + () => + executeBridge({ + document, + operation: "Query.greet", + input: { name: "Ada" }, + }), + (err: unknown) => { + const formatted = formatBridgeError(err); + assert.match( + formatted, + /Bridge Execution Error: No tool found for "xxx"/, + ); + assert.match(formatted, /playground\.bridge:8:16/); + assert.match(formatted, /o\.message <- missing:i\.name/); + assert.equal(maxCaretCount(formatted), "missing:i.name".length); + return true; + }, + ); + }); + + test("bridgeTransform surfaces formatted runtime errors through GraphQL", async () => { + const schema = buildSchema(/* GraphQL */ ` + type Query { + greet(name: String!): Greeting + } + + type Greeting { + message: String + upper: String + lower: String + } + `); + + const transformed = bridgeTransform( + schema, + parseBridge(bridgeText, { + filename: "playground.bridge", + }), + ); + + const result = await execute({ + schema: transformed, + document: parse(`{ greet(name: "Ada") { message upper lower } }`), + contextValue: {}, + }); + + assert.ok(result.errors?.length, "expected GraphQL errors"); + const message = result.errors?.[0]?.message ?? ""; + assert.match( + message, + /Bridge Execution Error: Cannot read properties of undefined \(reading '(array|error)'\)/, + ); + assert.match(message, /playground\.bridge:9:16/); + assert.match(message, /o\.message <- i\.empty\.array\.error/); + }); + + test("coalesce fallback errors highlight the failing fallback branch", async () => { + const document = parseBridge(bridgeCoalesceText, { + filename: "playground.bridge", + }); + + await assert.rejects( + () => + executeBridge({ + document, + operation: "Query.greet", + input: { name: "Ada" }, + }), + (err: unknown) => { + const formatted = formatBridgeError(err); + assert.match( + formatted, + /Bridge Execution Error: Cannot read properties of undefined \(reading 'array'\)/, + ); + assert.match(formatted, /playground\.bridge:11:16/); + assert.match( + formatted, + /o\.message <- i\.empty\.array\?\.error \?\? i\.empty\.array\.error/, + ); + return true; + }, + ); + }); +}); diff --git a/packages/bridge/test/source-locations.test.ts b/packages/bridge/test/source-locations.test.ts index e7e9f4db..9d719d45 100644 --- a/packages/bridge/test/source-locations.test.ts +++ b/packages/bridge/test/source-locations.test.ts @@ -70,4 +70,34 @@ bridge Query.test { ); assertLoc(concatPartWire, 5, 3); }); + + it("fallback and catch refs carry granular locations", () => { + const bridge = getBridge(`version 1.5 +bridge Query.test { + with input as i + with output as o + alias i.empty.array.error catch i.empty.array.error as clean + o.message <- i.empty.array?.error ?? i.empty.array.error catch clean +}`); + + const aliasWire = bridge.wires.find( + (wire) => "to" in wire && wire.to.field === "clean", + ); + assert.ok(aliasWire && "catchLoc" in aliasWire); + assert.equal(aliasWire.catchLoc?.startLine, 5); + assert.equal(aliasWire.catchLoc?.startColumn, 35); + + const messageWire = bridge.wires.find( + (wire) => "to" in wire && wire.to.path.join(".") === "message", + ); + assert.ok( + messageWire && "from" in messageWire && "fallbacks" in messageWire, + ); + assert.equal(messageWire.fromLoc?.startLine, 6); + assert.equal(messageWire.fromLoc?.startColumn, 16); + assert.equal(messageWire.fallbacks?.[0]?.loc?.startLine, 6); + assert.equal(messageWire.fallbacks?.[0]?.loc?.startColumn, 40); + assert.equal(messageWire.catchLoc?.startLine, 6); + assert.equal(messageWire.catchLoc?.startColumn, 66); + }); }); diff --git a/packages/bridge/test/ternary.test.ts b/packages/bridge/test/ternary.test.ts index 94c15ad3..34d38d55 100644 --- a/packages/bridge/test/ternary.test.ts +++ b/packages/bridge/test/ternary.test.ts @@ -6,6 +6,7 @@ import { } from "../src/index.ts"; import { BridgePanicError } from "../src/index.ts"; import { forEachEngine } from "./_dual-run.ts"; +import { assertDeepStrictEqualIgnoringLoc } from "./parse-test-utils.ts"; // ── Parser / desugaring tests ───────────────────────────────────────────── @@ -121,7 +122,9 @@ bridge Query.pricing { const bridge = doc.instructions.find((inst) => inst.kind === "bridge")!; const condWire = bridge.wires.find((w) => "cond" in w); assert.ok(condWire && "cond" in condWire); - assert.deepStrictEqual(condWire.fallbacks, [{ type: "falsy", value: "0" }]); + assertDeepStrictEqualIgnoringLoc(condWire.fallbacks, [ + { type: "falsy", value: "0" }, + ]); }); test("catch literal fallback stored on conditional wire", () => { diff --git a/packages/playground/src/components/ResultView.tsx b/packages/playground/src/components/ResultView.tsx index 22bc48eb..0362293d 100644 --- a/packages/playground/src/components/ResultView.tsx +++ b/packages/playground/src/components/ResultView.tsx @@ -62,9 +62,12 @@ export function ResultView({ Errors

{errors.map((err, i) => ( -

+

                 {err}
-              

+
))} )} diff --git a/packages/playground/src/engine.ts b/packages/playground/src/engine.ts index 022c2e0c..5edc2943 100644 --- a/packages/playground/src/engine.ts +++ b/packages/playground/src/engine.ts @@ -8,6 +8,7 @@ import { parseBridgeChevrotain, parseBridgeDiagnostics, executeBridge, + formatBridgeError, prettyPrintToSource, } from "@stackables/bridge"; export { prettyPrintToSource }; @@ -86,7 +87,9 @@ export type DiagnosticResult = { * Parse bridge DSL and return diagnostics (errors / warnings). */ export function getDiagnostics(bridgeText: string): DiagnosticResult { - const result = parseBridgeDiagnostics(bridgeText); + const result = parseBridgeDiagnostics(bridgeText, { + filename: "playground.bridge", + }); return { diagnostics: result.diagnostics }; } @@ -241,6 +244,9 @@ export async function runBridge( }); const errors = result.errors?.map((e) => { + if (e.message.includes("\n")) { + return e.message; + } const path = e.path ? ` (path: ${e.path.join(".")})` : ""; return `${e.message}${path}`; }); @@ -254,9 +260,7 @@ export async function runBridge( }; } catch (err: unknown) { return { - errors: [ - `Execution error: ${err instanceof Error ? err.message : String(err)}`, - ], + errors: [formatBridgeError(err)], }; } finally { _onCacheHit = null; @@ -273,7 +277,9 @@ export type BridgeOperation = { type: string; field: string; label: string }; */ export function extractBridgeOperations(bridgeText: string): BridgeOperation[] { try { - const { document } = parseBridgeDiagnostics(bridgeText); + const { document } = parseBridgeDiagnostics(bridgeText, { + filename: "playground.bridge", + }); return document.instructions .filter((i): i is Bridge => i.kind === "bridge") .map((b) => ({ @@ -311,7 +317,9 @@ export function extractOutputFields( operation: string, ): OutputFieldNode[] { try { - const { document } = parseBridgeDiagnostics(bridgeText); + const { document } = parseBridgeDiagnostics(bridgeText, { + filename: "playground.bridge", + }); const [type, field] = operation.split("."); if (!type || !field) return []; @@ -380,7 +388,9 @@ export function extractInputSkeleton( operation: string, ): string { try { - const { document } = parseBridgeDiagnostics(bridgeText); + const { document } = parseBridgeDiagnostics(bridgeText, { + filename: "playground.bridge", + }); const [type, field] = operation.split("."); if (!type || !field) return "{}"; @@ -528,7 +538,9 @@ export async function runBridgeStandalone( // 1. Parse Bridge DSL let document; try { - const result = parseBridgeDiagnostics(bridgeText); + const result = parseBridgeDiagnostics(bridgeText, { + filename: "playground.bridge", + }); document = result.document; } catch (err: unknown) { return { @@ -646,9 +658,7 @@ export async function runBridgeStandalone( }; } catch (err: unknown) { return { - errors: [ - `Execution error: ${err instanceof Error ? err.message : String(err)}`, - ], + errors: [formatBridgeError(err)], }; } finally { _onCacheHit = null; From 5e35255ccc9fc62909c18973b7d56e343f68c4a3 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Sat, 7 Mar 2026 16:17:03 +0100 Subject: [PATCH 3/8] Enhance error handling and formatting for runtime and control flow errors - Improve formatted runtime errors for missing tools and source underlines. - Fix segment-local `?.` traversal to ensure proper error handling. - Introduce metadata attachment for Bridge errors to preserve source context. - Update tests to validate new error formatting behavior for throw and panic fallbacks. --- .changeset/runtime-error-tool-formatting.md | 2 + .changeset/safe-path-panic-formatting.md | 7 ++ packages/bridge-compiler/src/codegen.ts | 83 ++++++++++++------- packages/bridge-core/src/ExecutionTree.ts | 45 ++++------ packages/bridge-core/src/formatBridgeError.ts | 28 +++++-- packages/bridge-core/src/resolveWires.ts | 40 ++++++++- packages/bridge-core/src/tree-types.ts | 31 +++++++ packages/bridge/test/path-scoping.test.ts | 15 ++++ .../bridge/test/runtime-error-format.test.ts | 73 ++++++++++++++++ 9 files changed, 257 insertions(+), 67 deletions(-) create mode 100644 .changeset/safe-path-panic-formatting.md diff --git a/.changeset/runtime-error-tool-formatting.md b/.changeset/runtime-error-tool-formatting.md index 74bd01b6..3a428233 100644 --- a/.changeset/runtime-error-tool-formatting.md +++ b/.changeset/runtime-error-tool-formatting.md @@ -8,5 +8,7 @@ Improve formatted runtime errors for missing tools and source underlines. `No tool found for "..."` and missing registered tool-function errors now carry Bridge source locations when they originate from authored bridge wires, so formatted errors include the filename, line, and highlighted source span. +Control-flow throw fallbacks now preserve their own source span, so +`?? throw "..."` highlights only the throw clause instead of the whole wire. Caret underlines now render the full inclusive source span instead of stopping one character short. diff --git a/.changeset/safe-path-panic-formatting.md b/.changeset/safe-path-panic-formatting.md new file mode 100644 index 00000000..8c35fd3f --- /dev/null +++ b/.changeset/safe-path-panic-formatting.md @@ -0,0 +1,7 @@ +--- +"@stackables/bridge": patch +"@stackables/bridge-core": patch +"@stackables/bridge-compiler": patch +--- + +Fix segment-local `?.` traversal so later strict path segments still fail after a guarded null hop, and preserve source formatting for `panic` control-flow errors. diff --git a/packages/bridge-compiler/src/codegen.ts b/packages/bridge-compiler/src/codegen.ts index e6d52049..b909af51 100644 --- a/packages/bridge-compiler/src/codegen.ts +++ b/packages/bridge-compiler/src/codegen.ts @@ -708,8 +708,9 @@ class CodegenContext { lines.push(` const __trace = __opts?.__trace;`); lines.push(` function __rethrowBridgeError(err, loc) {`); lines.push( - ` if (err?.name === "BridgePanicError" || err?.name === "BridgeAbortError") throw err;`, + ` if (err?.name === "BridgePanicError") throw __attachBridgeMeta(err, loc);`, ); + lines.push(` if (err?.name === "BridgeAbortError") throw err;`); lines.push( ` if (err?.name === "BridgeRuntimeError" && err.bridgeLoc !== undefined) throw err;`, ); @@ -731,6 +732,38 @@ class CodegenContext { lines.push(` __rethrowBridgeError(err, loc);`); lines.push(` }`); lines.push(` }`); + lines.push(` function __attachBridgeMeta(err, loc) {`); + lines.push( + ` if (err && (typeof err === "object" || typeof err === "function")) {`, + ); + lines.push(` if (err.bridgeLoc === undefined) err.bridgeLoc = loc;`); + lines.push( + ` if (err.bridgeSource === undefined) err.bridgeSource = __bridgeSource;`, + ); + lines.push( + ` if (err.bridgeFilename === undefined) err.bridgeFilename = __bridgeFilename;`, + ); + lines.push(` }`); + lines.push(` return err;`); + lines.push(` }`); + lines.push(` function __path(base, path, safe, allowMissingBase) {`); + lines.push(` let result = base;`); + lines.push(` for (let i = 0; i < path.length; i++) {`); + lines.push(` const segment = path[i];`); + lines.push(` const accessSafe = safe?.[i] ?? false;`); + lines.push(` if (result == null) {`); + lines.push(` if ((i === 0 && allowMissingBase) || accessSafe) {`); + lines.push(` result = undefined;`); + lines.push(` continue;`); + lines.push(` }`); + lines.push( + ` throw new TypeError("Cannot read properties of " + result + " (reading '" + segment + "')");`, + ); + lines.push(` }`); + lines.push(` result = result[segment];`); + lines.push(` }`); + lines.push(` return result;`); + lines.push(` }`); // Sync tool caller — no await, no timeout, enforces no-promise return. lines.push(` function __callSync(fn, input, toolName) {`); lines.push(` if (__signal?.aborted) throw new __BridgeAbortError();`); @@ -2352,7 +2385,7 @@ class CodegenContext { throw new Error(`Missing element variable for ${JSON.stringify(ref)}`); } if (ref.path.length === 0) return elVar; - return elVar + ref.path.map((p) => `?.[${JSON.stringify(p)}]`).join(""); + return this.appendPathExpr(elVar, ref, true); } private _elementWireToExprInner(w: Wire, elVar: string): string { @@ -3084,19 +3117,7 @@ class CodegenContext { !ref.element ) { if (ref.path.length === 0) return "input"; - // Respect rootSafe / pathSafe flags, same as tool-result refs. - // A bare `.` access (no `?.`) on a null intermediate throws TypeError, - // matching the runtime's applyPath strict-null behaviour. - return ( - "input" + - ref.path - .map((p, i) => { - const safe = - ref.pathSafe?.[i] ?? (i === 0 ? (ref.rootSafe ?? false) : false); - return safe ? `?.[${JSON.stringify(p)}]` : `[${JSON.stringify(p)}]`; - }) - .join("") - ); + return this.appendPathExpr("input", ref); } // Tool result reference @@ -3106,9 +3127,7 @@ class CodegenContext { if (this.elementScopedTools.has(key) && this.currentElVar) { let expr = this.buildInlineToolExpr(key, this.currentElVar); if (ref.path.length > 0) { - expr = - `(${expr})` + - ref.path.map((p) => `?.[${JSON.stringify(p)}]`).join(""); + expr = this.appendPathExpr(`(${expr})`, ref); } return expr; } @@ -3122,17 +3141,25 @@ class CodegenContext { if (!varName) throw new Error(`Unknown reference: ${key} (${JSON.stringify(ref)})`); if (ref.path.length === 0) return varName; - // Use pathSafe flags to decide ?. vs . for each segment - return ( - varName + - ref.path - .map((p, i) => { - const safe = - ref.pathSafe?.[i] ?? (i === 0 ? (ref.rootSafe ?? false) : false); - return safe ? `?.[${JSON.stringify(p)}]` : `[${JSON.stringify(p)}]`; - }) - .join("") + return this.appendPathExpr(varName, ref); + } + + private appendPathExpr( + baseExpr: string, + ref: NodeRef, + allowMissingBase = false, + ): string { + if (ref.path.length === 0) return baseExpr; + + const safeFlags = ref.path.map( + (_, i) => + ref.pathSafe?.[i] ?? (i === 0 ? (ref.rootSafe ?? false) : false), ); + if (allowMissingBase || safeFlags.some(Boolean)) { + return `__path(${baseExpr}, ${JSON.stringify(ref.path)}, ${JSON.stringify(safeFlags)}, ${allowMissingBase ? "true" : "false"})`; + } + + return baseExpr + ref.path.map((p) => `[${JSON.stringify(p)}]`).join(""); } /** diff --git a/packages/bridge-core/src/ExecutionTree.ts b/packages/bridge-core/src/ExecutionTree.ts index aff4f797..51e6bfc7 100644 --- a/packages/bridge-core/src/ExecutionTree.ts +++ b/packages/bridge-core/src/ExecutionTree.ts @@ -550,37 +550,19 @@ export class ExecutionTree implements TreeContext { let result: any = resolved; - // Root-level null check - if (result == null) { - if (ref.rootSafe || ref.element) return undefined; - throw wrapBridgeRuntimeError( - new TypeError( - `Cannot read properties of ${result} (reading '${ref.path[0]}')`, - ), - { - bridgeLoc, - bridgeSource: this.source, - bridgeFilename: this.filename, - }, - ); - } - for (let i = 0; i < ref.path.length; i++) { const segment = ref.path[i]!; - if (UNSAFE_KEYS.has(segment)) - throw new Error(`Unsafe property traversal: ${segment}`); - if (Array.isArray(result) && !/^\d+$/.test(segment)) { - this.logger?.warn?.( - `[bridge] Accessing ".${segment}" on an array (${result.length} items) — did you mean to use pickFirst or array mapping? Source: ${trunkKey(ref)}.${ref.path.join(".")}`, - ); - } - result = result[segment]; - if (result == null && i < ref.path.length - 1) { - const nextSafe = (ref.pathSafe?.[i + 1] ?? false) || !!ref.element; - if (nextSafe) return undefined; + const accessSafe = + ref.pathSafe?.[i] ?? (i === 0 ? (ref.rootSafe ?? false) : false); + + if (result == null) { + if ((i === 0 && ref.element) || accessSafe) { + result = undefined; + continue; + } throw wrapBridgeRuntimeError( new TypeError( - `Cannot read properties of ${result} (reading '${ref.path[i + 1]}')`, + `Cannot read properties of ${result} (reading '${segment}')`, ), { bridgeLoc, @@ -589,6 +571,15 @@ export class ExecutionTree implements TreeContext { }, ); } + + if (UNSAFE_KEYS.has(segment)) + throw new Error(`Unsafe property traversal: ${segment}`); + if (Array.isArray(result) && !/^\d+$/.test(segment)) { + this.logger?.warn?.( + `[bridge] Accessing ".${segment}" on an array (${result.length} items) — did you mean to use pickFirst or array mapping? Source: ${trunkKey(ref)}.${ref.path.join(".")}`, + ); + } + result = result[segment]; } return result; } diff --git a/packages/bridge-core/src/formatBridgeError.ts b/packages/bridge-core/src/formatBridgeError.ts index a3e5b0b2..f9d45838 100644 --- a/packages/bridge-core/src/formatBridgeError.ts +++ b/packages/bridge-core/src/formatBridgeError.ts @@ -1,4 +1,3 @@ -import { BridgeRuntimeError } from "./tree-types.ts"; import type { SourceLocation } from "./types.ts"; export type FormatBridgeErrorOptions = { @@ -10,18 +9,31 @@ function getMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } +function getBridgeMetadata(err: unknown): { + bridgeLoc?: SourceLocation; + bridgeSource?: string; + bridgeFilename?: string; +} | null { + if (!err || (typeof err !== "object" && typeof err !== "function")) { + return null; + } + + return err as { + bridgeLoc?: SourceLocation; + bridgeSource?: string; + bridgeFilename?: string; + }; +} + function getBridgeLoc(err: unknown): SourceLocation | undefined { - return err instanceof BridgeRuntimeError ? err.bridgeLoc : undefined; + return getBridgeMetadata(err)?.bridgeLoc; } function getBridgeSource( err: unknown, options: FormatBridgeErrorOptions, ): string | undefined { - return ( - options.source ?? - (err instanceof BridgeRuntimeError ? err.bridgeSource : undefined) - ); + return options.source ?? getBridgeMetadata(err)?.bridgeSource; } function getBridgeFilename( @@ -29,9 +41,7 @@ function getBridgeFilename( options: FormatBridgeErrorOptions, ): string { return ( - options.filename ?? - (err instanceof BridgeRuntimeError ? err.bridgeFilename : undefined) ?? - "" + options.filename ?? getBridgeMetadata(err)?.bridgeFilename ?? "" ); } diff --git a/packages/bridge-core/src/resolveWires.ts b/packages/bridge-core/src/resolveWires.ts index c0521fb9..9b70029e 100644 --- a/packages/bridge-core/src/resolveWires.ts +++ b/packages/bridge-core/src/resolveWires.ts @@ -9,13 +9,15 @@ * the full `ExecutionTree` class. */ -import type { NodeRef, Wire } from "./types.ts"; +import type { ControlFlowInstruction, NodeRef, Wire } from "./types.ts"; import type { MaybePromise, TreeContext } from "./tree-types.ts"; import { + attachBridgeErrorMetadata, isFatalError, isPromise, applyControlFlow, BridgeAbortError, + BridgePanicError, wrapBridgeRuntimeError, } from "./tree-types.ts"; import { coerceConstant, getSimplePullRef } from "./tree-utils.ts"; @@ -151,7 +153,13 @@ export async function applyFallbackGates( const isNullishGateOpen = fallback.type === "nullish" && value == null; if (isFalsyGateOpen || isNullishGateOpen) { - if (fallback.control) return applyControlFlow(fallback.control); + if (fallback.control) { + return applyControlFlowWithLoc( + ctx, + fallback.control, + fallback.loc ?? w.loc, + ); + } if (fallback.ref) { value = await ctx.pullSingle( fallback.ref, @@ -182,7 +190,9 @@ export async function applyCatchGate( w: WireWithGates, pullChain?: Set, ): Promise { - if (w.catchControl) return applyControlFlow(w.catchControl); + if (w.catchControl) { + return applyControlFlowWithLoc(ctx, w.catchControl, w.catchLoc ?? w.loc); + } if (w.catchFallbackRef) { return ctx.pullSingle(w.catchFallbackRef, pullChain, w.catchLoc ?? w.loc); } @@ -190,6 +200,30 @@ export async function applyCatchGate( return undefined; } +function applyControlFlowWithLoc( + ctx: TreeContext, + control: ControlFlowInstruction, + bridgeLoc: Wire["loc"], +): symbol | import("./tree-types.ts").LoopControlSignal { + try { + return applyControlFlow(control); + } catch (err) { + if (err instanceof BridgePanicError) { + throw attachBridgeErrorMetadata(err, { + bridgeLoc, + bridgeSource: ctx.source, + bridgeFilename: ctx.filename, + }); + } + if (isFatalError(err)) throw err; + throw wrapBridgeRuntimeError(err, { + bridgeLoc, + bridgeSource: ctx.source, + bridgeFilename: ctx.filename, + }); + } +} + // ── Layer 1: Wire source evaluation ───────────────────────────────────────── /** diff --git a/packages/bridge-core/src/tree-types.ts b/packages/bridge-core/src/tree-types.ts index df4e7b8c..03ec4520 100644 --- a/packages/bridge-core/src/tree-types.ts +++ b/packages/bridge-core/src/tree-types.ts @@ -61,6 +61,12 @@ export class BridgeRuntimeError extends Error { } } +type BridgeErrorMetadataCarrier = { + bridgeLoc?: SourceLocation; + bridgeSource?: string; + bridgeFilename?: string; +}; + // ── Sentinels ─────────────────────────────────────────────────────────────── /** Sentinel for `continue` — skip the current array element */ @@ -194,6 +200,31 @@ export function wrapBridgeRuntimeError( }); } +export function attachBridgeErrorMetadata( + err: T, + options: { + bridgeLoc?: SourceLocation; + bridgeSource?: string; + bridgeFilename?: string; + } = {}, +): T { + if (!err || (typeof err !== "object" && typeof err !== "function")) { + return err; + } + + const carrier = err as BridgeErrorMetadataCarrier; + if (carrier.bridgeLoc === undefined) { + carrier.bridgeLoc = options.bridgeLoc; + } + if (carrier.bridgeSource === undefined) { + carrier.bridgeSource = options.bridgeSource; + } + if (carrier.bridgeFilename === undefined) { + carrier.bridgeFilename = options.bridgeFilename; + } + return err; +} + function controlLevels( ctrl: Extract, ): number { diff --git a/packages/bridge/test/path-scoping.test.ts b/packages/bridge/test/path-scoping.test.ts index 4c9acb08..3e7aa8e0 100644 --- a/packages/bridge/test/path-scoping.test.ts +++ b/packages/bridge/test/path-scoping.test.ts @@ -1111,4 +1111,19 @@ o.result <- t.user.profile.name /Cannot read properties of null \(reading 'name'\)/, ); }); + + test("?. only guards the segment it prefixes", async () => { + const bridgeText = `version 1.5 +bridge Query.test { + with input as i + with output as o + + o.result <- i.does?.not.crash.hard ?? throw "Errore" +}`; + + await assert.rejects( + () => run(bridgeText, "Query.test", { does: null }), + /Cannot read properties of undefined \(reading 'crash'\)/, + ); + }); }); diff --git a/packages/bridge/test/runtime-error-format.test.ts b/packages/bridge/test/runtime-error-format.test.ts index c0e84922..60ccb2fb 100644 --- a/packages/bridge/test/runtime-error-format.test.ts +++ b/packages/bridge/test/runtime-error-format.test.ts @@ -47,6 +47,30 @@ bridge Query.greet { o.message <- missing:i.name }`; +const bridgeThrowFallbackText = `version 1.5 + +bridge Query.greet { + with std.str.toUpperCase as uc + with std.str.toLowerCase as lc + with kala as k + with input as i + with output as o + + o.message <- i.does?.not?.crash ?? throw "Errore" + + o.upper <- uc:i.name + o.lower <- lc:i.name +}`; + +const bridgePanicFallbackText = `version 1.5 + +bridge Query.greet { + with input as i + with output as o + + o.message <- i.name ?? panic "Fatale" +}`; + function maxCaretCount(formatted: string): number { return Math.max( 0, @@ -125,6 +149,55 @@ describe("runtime error formatting", () => { ); }); + test("throw fallbacks underline only the throw clause", async () => { + const document = parseBridge(bridgeThrowFallbackText, { + filename: "playground.bridge", + }); + + await assert.rejects( + () => + executeBridge({ + document, + operation: "Query.greet", + input: { name: "Ada" }, + }), + (err: unknown) => { + const formatted = formatBridgeError(err); + assert.match(formatted, /Bridge Execution Error: Errore/); + assert.match(formatted, /playground\.bridge:10:38/); + assert.match( + formatted, + /o\.message <- i\.does\?\.not\?\.crash \?\? throw "Errore"/, + ); + assert.equal(maxCaretCount(formatted), 'throw "Errore"'.length); + return true; + }, + ); + }); + + test("panic fallbacks underline only the panic clause", async () => { + const document = parseBridge(bridgePanicFallbackText, { + filename: "playground.bridge", + }); + + await assert.rejects( + () => + executeBridge({ + document, + operation: "Query.greet", + input: {}, + }), + (err: unknown) => { + const formatted = formatBridgeError(err); + assert.match(formatted, /Bridge Execution Error: Fatale/); + assert.match(formatted, /playground\.bridge:7:26/); + assert.match(formatted, /o\.message <- i\.name \?\? panic "Fatale"/); + assert.equal(maxCaretCount(formatted), 'panic "Fatale"'.length); + return true; + }, + ); + }); + test("bridgeTransform surfaces formatted runtime errors through GraphQL", async () => { const schema = buildSchema(/* GraphQL */ ` type Query { From 8f74634585e1311592019cbdc9bbd418bb56c6ef Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Sat, 7 Mar 2026 16:22:41 +0100 Subject: [PATCH 4/8] Compiler parity --- packages/bridge-compiler/src/codegen.ts | 25 ++++------- packages/bridge-core/src/toolLookup.ts | 2 +- packages/bridge-parser/src/parser/parser.ts | 19 ++++++-- packages/bridge/test/shared-parity.test.ts | 50 +++++++++++++++++++++ 4 files changed, 75 insertions(+), 21 deletions(-) diff --git a/packages/bridge-compiler/src/codegen.ts b/packages/bridge-compiler/src/codegen.ts index b909af51..689bddaa 100644 --- a/packages/bridge-compiler/src/codegen.ts +++ b/packages/bridge-compiler/src/codegen.ts @@ -1471,7 +1471,7 @@ class CodegenContext { if (restPath.length === 1) return base; const tail = restPath .slice(1) - .map((p) => `?.[${JSON.stringify(p)}]`) + .map((p) => `[${JSON.stringify(p)}]`) .join(""); return `(${base})${tail}`; } @@ -1496,7 +1496,7 @@ class CodegenContext { } if (restPath.length === 0) return baseExpr; - return baseExpr + restPath.map((p) => `?.[${JSON.stringify(p)}]`).join(""); + return baseExpr + restPath.map((p) => `[${JSON.stringify(p)}]`).join(""); } /** Find a tool info by tool name. */ @@ -2402,9 +2402,7 @@ class CodegenContext { if (this.elementScopedTools.has(condKey)) { condExpr = this.buildInlineToolExpr(condKey, elVar); if (condRef.path.length > 0) { - condExpr = - `(${condExpr})` + - condRef.path.map((p) => `?.[${JSON.stringify(p)}]`).join(""); + condExpr = this.appendPathExpr(`(${condExpr})`, condRef); } } else { condExpr = this.refToExpr(condRef); @@ -2419,10 +2417,7 @@ class CodegenContext { const branchKey = refTrunkKey(ref); if (this.elementScopedTools.has(branchKey)) { let e = this.buildInlineToolExpr(branchKey, elVar); - if (ref.path.length > 0) - e = - `(${e})` + - ref.path.map((p) => `?.[${JSON.stringify(p)}]`).join(""); + if (ref.path.length > 0) e = this.appendPathExpr(`(${e})`, ref); return e; } return this.refToExpr(ref); @@ -2443,9 +2438,7 @@ class CodegenContext { if (this.elementScopedTools.has(srcKey)) { let expr = this.buildInlineToolExpr(srcKey, elVar); if (w.from.path.length > 0) { - expr = - `(${expr})` + - w.from.path.map((p) => `?.[${JSON.stringify(p)}]`).join(""); + expr = this.appendPathExpr(`(${expr})`, w.from); } expr = this.wrapExprWithLoc(expr, w.fromLoc); expr = this.applyFallbacks(w, expr); @@ -2457,8 +2450,7 @@ class CodegenContext { return expr; } // Element refs: from.element === true, path = ["srcField"] - let expr = - elVar + w.from.path.map((p) => `?.[${JSON.stringify(p)}]`).join(""); + let expr = this.appendPathExpr(elVar, w.from, true); expr = this.wrapExprWithLoc(expr, w.fromLoc); expr = this.applyFallbacks(w, expr); return expr; @@ -3103,7 +3095,7 @@ class CodegenContext { if (ref.path.length === 1) return base; const tail = ref.path .slice(1) - .map((p) => `?.[${JSON.stringify(p)}]`) + .map((p) => `[${JSON.stringify(p)}]`) .join(""); return `(${base})${tail}`; } @@ -3218,8 +3210,7 @@ class CodegenContext { ? `(await __callMemoized(tools[${JSON.stringify(fnName)}], ${inputObj}, ${JSON.stringify(fnName)}, ${JSON.stringify(key)}))` : `(await __call(tools[${JSON.stringify(fnName)}], ${inputObj}, ${JSON.stringify(fnName)}))`; if (ref.path.length > 0) { - expr = - expr + ref.path.map((p) => `?.[${JSON.stringify(p)}]`).join(""); + expr = this.appendPathExpr(expr, ref); } return expr; } diff --git a/packages/bridge-core/src/toolLookup.ts b/packages/bridge-core/src/toolLookup.ts index 1f88d5ec..e7d7c2db 100644 --- a/packages/bridge-core/src/toolLookup.ts +++ b/packages/bridge-core/src/toolLookup.ts @@ -258,7 +258,7 @@ export async function resolveToolSource( } for (const segment of restPath) { - value = value?.[segment]; + value = value[segment]; } return value; } diff --git a/packages/bridge-parser/src/parser/parser.ts b/packages/bridge-parser/src/parser/parser.ts index eeee5273..98727de8 100644 --- a/packages/bridge-parser/src/parser/parser.ts +++ b/packages/bridge-parser/src/parser/parser.ts @@ -4382,15 +4382,28 @@ function buildBridgeBody( if (c.nullLit) return { kind: "literal", value: "null" }; if (c.sourceRef) { const addrNode = (c.sourceRef as CstNode[])[0]; - const { root, segments } = extractAddressPath(addrNode); + const { root, segments, rootSafe, segmentSafe } = + extractAddressPath(addrNode); const iterRef = resolveIterRef(root, segments, iterScope); if (iterRef) { return { kind: "ref", - ref: iterRef, + ref: { + ...iterRef, + ...(rootSafe ? { rootSafe: true } : {}), + ...(segmentSafe ? { pathSafe: segmentSafe } : {}), + }, }; } - return { kind: "ref", ref: resolveAddress(root, segments, lineNum) }; + const ref = resolveAddress(root, segments, lineNum); + return { + kind: "ref", + ref: { + ...ref, + ...(rootSafe ? { rootSafe: true } : {}), + ...(segmentSafe ? { pathSafe: segmentSafe } : {}), + }, + }; } throw new Error(`Line ${lineNum}: Invalid ternary branch`); } diff --git a/packages/bridge/test/shared-parity.test.ts b/packages/bridge/test/shared-parity.test.ts index 5c4b6c94..ac22a563 100644 --- a/packages/bridge/test/shared-parity.test.ts +++ b/packages/bridge/test/shared-parity.test.ts @@ -512,6 +512,21 @@ bridge Query.pricing { tools: { api: () => ({ proPrice: 99, basicPrice: 49 }) }, expected: { price: 99 }, }, + { + name: "ternary branch preserves segment-local ?. semantics", + bridgeText: `version 1.5 +bridge Query.pricing { + with api as a + with input as i + with output as o + + o.price <- i.isPro ? a.user?.profile.name : "basic" +}`, + operation: "Query.pricing", + input: { isPro: true }, + tools: { api: () => ({ user: null }) }, + expectedError: /Cannot read properties of undefined \(reading 'name'\)/, + }, ]; runSharedSuite("Shared: ternary / conditional wires", ternaryCases); @@ -792,6 +807,27 @@ bridge Query.users { }, expected: { users: [] }, }, + { + name: "ToolDef source paths stay strict after null intermediate", + bridgeText: `version 1.5 +tool restApi from myHttp { + with context + .headers.Authorization <- context.auth.profile.token +} + +bridge Query.data { + with restApi as api + with output as o + + o.result <- api.body +}`, + operation: "Query.data", + tools: { + myHttp: async (_: any) => ({ body: { ok: true } }), + }, + context: { auth: { profile: null } }, + expectedError: /Cannot read properties of null \(reading 'token'\)/, + }, ]; runSharedSuite("Shared: ToolDef support", toolDefCases); @@ -849,6 +885,20 @@ bridge Query.locate { tools: { geoApi: () => ({ lat: null, lon: null }) }, expected: { lat: 0, lon: 0 }, }, + { + name: "const path traversal stays strict after null intermediate", + bridgeText: `version 1.5 +const defaults = { "user": null } + +bridge Query.consts { + with const as c + with output as o + + o.name <- c.defaults.user.profile.name +}`, + operation: "Query.consts", + expectedError: /Cannot read properties of null \(reading 'profile'\)/, + }, ]; runSharedSuite("Shared: const blocks", constCases); From ea3b51b14f3d7fc8170341ba823bffcb1ff01e94 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Sat, 7 Mar 2026 16:50:50 +0100 Subject: [PATCH 5/8] Enhance error handling and source location tracking in code generation and execution --- .../ternary-condition-source-mapping.md | 11 + packages/bridge-compiler/src/codegen.ts | 40 ++-- packages/bridge-core/src/ExecutionTree.ts | 21 +- packages/bridge-core/src/resolveWires.ts | 10 +- packages/bridge-core/src/types.ts | 3 + packages/bridge-parser/src/parser/parser.ts | 212 +++++++++++------- .../bridge/test/runtime-error-format.test.ts | 123 ++++++++++ packages/bridge/test/source-locations.test.ts | 6 + 8 files changed, 324 insertions(+), 102 deletions(-) create mode 100644 .changeset/ternary-condition-source-mapping.md diff --git a/.changeset/ternary-condition-source-mapping.md b/.changeset/ternary-condition-source-mapping.md new file mode 100644 index 00000000..ce53d70d --- /dev/null +++ b/.changeset/ternary-condition-source-mapping.md @@ -0,0 +1,11 @@ +--- +"@stackables/bridge": patch +"@stackables/bridge-core": patch +"@stackables/bridge-compiler": patch +"@stackables/bridge-parser": patch +--- + +Improve runtime error source mapping for ternary conditions and strict path traversal. + +Runtime and compiled execution now preserve clause-level source spans for ternary conditions and branches, so formatted errors can highlight only the failing condition or selected branch instead of the whole wire. +Strict path traversal also now fails consistently on primitive property access in both runtime and AOT execution, keeping error messages and behavior aligned. diff --git a/packages/bridge-compiler/src/codegen.ts b/packages/bridge-compiler/src/codegen.ts index 689bddaa..73a49813 100644 --- a/packages/bridge-compiler/src/codegen.ts +++ b/packages/bridge-compiler/src/codegen.ts @@ -760,7 +760,16 @@ class CodegenContext { ` throw new TypeError("Cannot read properties of " + result + " (reading '" + segment + "')");`, ); lines.push(` }`); - lines.push(` result = result[segment];`); + lines.push(` const next = result[segment];`); + lines.push( + ` const isPrimitiveBase = result !== null && typeof result !== "object" && typeof result !== "function";`, + ); + lines.push(` if (isPrimitiveBase && next === undefined) {`); + lines.push( + ` throw new TypeError("Cannot read properties of " + result + " (reading '" + segment + "')");`, + ); + lines.push(` }`); + lines.push(` result = next;`); lines.push(` }`); lines.push(` return result;`); lines.push(` }`); @@ -2293,16 +2302,19 @@ class CodegenContext { // Conditional wire (ternary) if ("cond" in w) { - const condExpr = this.refToExpr(w.cond); + const condExpr = this.wrapExprWithLoc( + this.refToExpr(w.cond), + w.condLoc ?? w.loc, + ); const thenExpr = w.thenRef !== undefined - ? this.lazyRefToExpr(w.thenRef) + ? this.wrapExprWithLoc(this.lazyRefToExpr(w.thenRef), w.thenLoc) : w.thenValue !== undefined ? emitCoerced(w.thenValue) : "undefined"; const elseExpr = w.elseRef !== undefined - ? this.lazyRefToExpr(w.elseRef) + ? this.wrapExprWithLoc(this.lazyRefToExpr(w.elseRef), w.elseLoc) : w.elseValue !== undefined ? emitCoerced(w.elseValue) : "undefined"; @@ -2408,24 +2420,28 @@ class CodegenContext { condExpr = this.refToExpr(condRef); } } + condExpr = this.wrapExprWithLoc(condExpr, w.condLoc ?? w.loc); const resolveBranch = ( ref: NodeRef | undefined, val: string | undefined, + loc: SourceLocation | undefined, ): string => { if (ref !== undefined) { - if (ref.element) return this.refToElementExpr(ref); + if (ref.element) { + return this.wrapExprWithLoc(this.refToElementExpr(ref), loc); + } const branchKey = refTrunkKey(ref); if (this.elementScopedTools.has(branchKey)) { let e = this.buildInlineToolExpr(branchKey, elVar); if (ref.path.length > 0) e = this.appendPathExpr(`(${e})`, ref); - return e; + return this.wrapExprWithLoc(e, loc); } - return this.refToExpr(ref); + return this.wrapExprWithLoc(this.refToExpr(ref), loc); } return val !== undefined ? emitCoerced(val) : "undefined"; }; - const thenExpr = resolveBranch(w.thenRef, w.thenValue); - const elseExpr = resolveBranch(w.elseRef, w.elseValue); + const thenExpr = resolveBranch(w.thenRef, w.thenValue, w.thenLoc); + const elseExpr = resolveBranch(w.elseRef, w.elseValue, w.elseLoc); let expr = `(${condExpr} ? ${thenExpr} : ${elseExpr})`; expr = this.applyFallbacks(w, expr); return expr; @@ -3147,11 +3163,7 @@ class CodegenContext { (_, i) => ref.pathSafe?.[i] ?? (i === 0 ? (ref.rootSafe ?? false) : false), ); - if (allowMissingBase || safeFlags.some(Boolean)) { - return `__path(${baseExpr}, ${JSON.stringify(ref.path)}, ${JSON.stringify(safeFlags)}, ${allowMissingBase ? "true" : "false"})`; - } - - return baseExpr + ref.path.map((p) => `[${JSON.stringify(p)}]`).join(""); + return `__path(${baseExpr}, ${JSON.stringify(ref.path)}, ${JSON.stringify(safeFlags)}, ${allowMissingBase ? "true" : "false"})`; } /** diff --git a/packages/bridge-core/src/ExecutionTree.ts b/packages/bridge-core/src/ExecutionTree.ts index 51e6bfc7..b4e5e369 100644 --- a/packages/bridge-core/src/ExecutionTree.ts +++ b/packages/bridge-core/src/ExecutionTree.ts @@ -510,6 +510,8 @@ export class ExecutionTree implements TreeContext { child.tracer = this.tracer; child.logger = this.logger; child.signal = this.signal; + child.source = this.source; + child.filename = this.filename; return child; } @@ -579,7 +581,24 @@ export class ExecutionTree implements TreeContext { `[bridge] Accessing ".${segment}" on an array (${result.length} items) — did you mean to use pickFirst or array mapping? Source: ${trunkKey(ref)}.${ref.path.join(".")}`, ); } - result = result[segment]; + const next = result[segment]; + const isPrimitiveBase = + result !== null && + typeof result !== "object" && + typeof result !== "function"; + if (isPrimitiveBase && next === undefined) { + throw wrapBridgeRuntimeError( + new TypeError( + `Cannot read properties of ${result} (reading '${segment}')`, + ), + { + bridgeLoc, + bridgeSource: this.source, + bridgeFilename: this.filename, + }, + ); + } + result = next; } return result; } diff --git a/packages/bridge-core/src/resolveWires.ts b/packages/bridge-core/src/resolveWires.ts index 9b70029e..0093825d 100644 --- a/packages/bridge-core/src/resolveWires.ts +++ b/packages/bridge-core/src/resolveWires.ts @@ -239,15 +239,19 @@ async function evaluateWireSource( pullChain?: Set, ): Promise { if ("cond" in w) { - const condValue = await ctx.pullSingle(w.cond, pullChain); + const condValue = await ctx.pullSingle( + w.cond, + pullChain, + w.condLoc ?? w.loc, + ); if (condValue) { if (w.thenRef !== undefined) { - return ctx.pullSingle(w.thenRef, pullChain, w.loc); + return ctx.pullSingle(w.thenRef, pullChain, w.thenLoc ?? w.loc); } if (w.thenValue !== undefined) return coerceConstant(w.thenValue); } else { if (w.elseRef !== undefined) { - return ctx.pullSingle(w.elseRef, pullChain, w.loc); + return ctx.pullSingle(w.elseRef, pullChain, w.elseLoc ?? w.loc); } if (w.elseValue !== undefined) return coerceConstant(w.elseValue); } diff --git a/packages/bridge-core/src/types.ts b/packages/bridge-core/src/types.ts index c54fd297..5bd7fdcc 100644 --- a/packages/bridge-core/src/types.ts +++ b/packages/bridge-core/src/types.ts @@ -76,10 +76,13 @@ export type Wire = | { value: string; to: NodeRef; loc?: SourceLocation } | { cond: NodeRef; + condLoc?: SourceLocation; thenRef?: NodeRef; thenValue?: string; + thenLoc?: SourceLocation; elseRef?: NodeRef; elseValue?: string; + elseLoc?: SourceLocation; to: NodeRef; loc?: SourceLocation; fallbacks?: WireFallback[]; diff --git a/packages/bridge-parser/src/parser/parser.ts b/packages/bridge-parser/src/parser/parser.ts index 98727de8..83398ab2 100644 --- a/packages/bridge-parser/src/parser/parser.ts +++ b/packages/bridge-parser/src/parser/parser.ts @@ -1888,7 +1888,9 @@ function processElementLines( branchNode: CstNode, lineNum: number, iterScope?: string | string[], - ) => { kind: "literal"; value: string } | { kind: "ref"; ref: NodeRef }, + ) => + | { kind: "literal"; value: string; loc?: SourceLocation } + | { kind: "ref"; ref: NodeRef; loc?: SourceLocation }, processLocalBindings: ( withDecls: CstNode[], iterScope: string | string[], @@ -2023,37 +2025,35 @@ function processElementLines( const altNode = sub(item, "altValue")!; const preLen = wires.length; const altResult = extractCoalesceAltIterAware(altNode, elemLineNum); - if ("literal" in altResult) { - fallbacks.push({ type, value: altResult.literal }); - } else if ("control" in altResult) { - fallbacks.push({ type, control: altResult.control }); - } else { - fallbacks.push({ type, ref: altResult.sourceRef }); + fallbacks.push(buildWireFallback(type, altNode, altResult)); + if ("sourceRef" in altResult) { fallbackInternalWires.push(...wires.splice(preLen)); } } let catchFallback: string | undefined; let catchControl: ControlFlowInstruction | undefined; let catchFallbackRef: NodeRef | undefined; + let catchLoc: SourceLocation | undefined; let catchFallbackInternalWires: Wire[] = []; const catchAlt = sub(elemLine, "elemCatchAlt"); if (catchAlt) { const preLen = wires.length; const altResult = extractCoalesceAltIterAware(catchAlt, elemLineNum); - if ("literal" in altResult) { - catchFallback = altResult.literal; - } else if ("control" in altResult) { - catchControl = altResult.control; - } else { - catchFallbackRef = altResult.sourceRef; + const catchAttrs = buildCatchAttrs(catchAlt, altResult); + catchLoc = catchAttrs.catchLoc; + catchFallback = catchAttrs.catchFallback; + catchControl = catchAttrs.catchControl; + catchFallbackRef = catchAttrs.catchFallbackRef; + if ("sourceRef" in altResult) { catchFallbackInternalWires = wires.splice(preLen); } } const lastAttrs = { ...(fallbacks.length > 0 ? { fallbacks } : {}), - ...(catchFallback ? { catchFallback } : {}), - ...(catchFallbackRef ? { catchFallbackRef } : {}), + ...(catchLoc ? { catchLoc } : {}), + ...(catchFallback !== undefined ? { catchFallback } : {}), + ...(catchFallbackRef !== undefined ? { catchFallbackRef } : {}), ...(catchControl ? { catchControl } : {}), }; @@ -2197,6 +2197,13 @@ function processElementLines( const sourceParts: { ref: NodeRef; isPipeFork: boolean }[] = []; const elemExprOps = subs(elemLine, "elemExprOp"); + const elemExprRights = subs(elemLine, "elemExprRight"); + const elemCondLoc = locFromNodeRange( + elemFirstParenNode ?? elemSourceNode, + elemExprRights[elemExprRights.length - 1] ?? + elemFirstParenNode ?? + elemSourceNode, + ); // Compute condition ref (expression chain result or plain source) let elemCondRef: NodeRef; @@ -2210,7 +2217,6 @@ function processElementLines( elemSafe || undefined, ); if (elemExprOps.length > 0 && desugarExprChain) { - const elemExprRights = subs(elemLine, "elemExprRight"); elemCondRef = desugarExprChain( parenRef, elemExprOps, @@ -2226,7 +2232,6 @@ function processElementLines( elemCondIsPipeFork = true; } else if (elemExprOps.length > 0 && desugarExprChain) { // Expression in element line — desugar then merge with fallback path - const elemExprRights = subs(elemLine, "elemExprRight"); let leftRef: NodeRef; const directIterRef = elemPipeSegs.length === 0 @@ -2341,9 +2346,12 @@ function processElementLines( withLoc( { cond: elemCondRef, + ...(elemCondLoc ? { condLoc: elemCondLoc } : {}), + thenLoc: thenBranch.loc, ...(thenBranch.kind === "ref" ? { thenRef: thenBranch.ref } : { thenValue: thenBranch.value }), + elseLoc: elseBranch.loc, ...(elseBranch.kind === "ref" ? { elseRef: elseBranch.ref } : { elseValue: elseBranch.value }), @@ -2377,12 +2385,8 @@ function processElementLines( const altNode = sub(item, "altValue")!; const preLen = wires.length; const altResult = extractCoalesceAltIterAware(altNode, elemLineNum); - if ("literal" in altResult) { - fallbacks.push({ type, value: altResult.literal }); - } else if ("control" in altResult) { - fallbacks.push({ type, control: altResult.control }); - } else { - fallbacks.push({ type, ref: altResult.sourceRef }); + fallbacks.push(buildWireFallback(type, altNode, altResult)); + if ("sourceRef" in altResult) { fallbackInternalWires.push(...wires.splice(preLen)); } } @@ -2391,17 +2395,18 @@ function processElementLines( let catchFallback: string | undefined; let catchControl: ControlFlowInstruction | undefined; let catchFallbackRef: NodeRef | undefined; + let catchLoc: SourceLocation | undefined; let catchFallbackInternalWires: Wire[] = []; const catchAlt = sub(elemLine, "elemCatchAlt"); if (catchAlt) { const preLen = wires.length; const altResult = extractCoalesceAltIterAware(catchAlt, elemLineNum); - if ("literal" in altResult) { - catchFallback = altResult.literal; - } else if ("control" in altResult) { - catchControl = altResult.control; - } else { - catchFallbackRef = altResult.sourceRef; + const catchAttrs = buildCatchAttrs(catchAlt, altResult); + catchLoc = catchAttrs.catchLoc; + catchFallback = catchAttrs.catchFallback; + catchControl = catchAttrs.catchControl; + catchFallbackRef = catchAttrs.catchFallbackRef; + if ("sourceRef" in altResult) { catchFallbackInternalWires = wires.splice(preLen); } } @@ -2411,8 +2416,9 @@ function processElementLines( const wireAttrs = { ...(isPipeFork ? { pipe: true as const } : {}), ...(fallbacks.length > 0 ? { fallbacks } : {}), - ...(catchFallback ? { catchFallback } : {}), - ...(catchFallbackRef ? { catchFallbackRef } : {}), + ...(catchLoc ? { catchLoc } : {}), + ...(catchFallback !== undefined ? { catchFallback } : {}), + ...(catchFallbackRef !== undefined ? { catchFallbackRef } : {}), ...(catchControl ? { catchControl } : {}), }; wires.push( @@ -2514,7 +2520,9 @@ function processElementScopeLines( branchNode: CstNode, lineNum: number, iterScope?: string | string[], - ) => { kind: "literal"; value: string } | { kind: "ref"; ref: NodeRef }, + ) => + | { kind: "literal"; value: string; loc?: SourceLocation } + | { kind: "ref"; ref: NodeRef; loc?: SourceLocation }, desugarTemplateStringFn?: ( segs: TemplateSeg[], lineNum: number, @@ -2688,34 +2696,34 @@ function processElementScopeLines( const altNode = sub(item, "altValue")!; const preLen = wires.length; const altResult = extractCoalesceAltIterAware(altNode, scopeLineNum); - if ("literal" in altResult) { - fallbacks.push({ type, value: altResult.literal }); - } else if ("control" in altResult) { - fallbacks.push({ type, control: altResult.control }); - } else { - fallbacks.push({ type, ref: altResult.sourceRef }); + fallbacks.push(buildWireFallback(type, altNode, altResult)); + if ("sourceRef" in altResult) { fallbackInternalWires.push(...wires.splice(preLen)); } } let catchFallback: string | undefined; let catchControl: ControlFlowInstruction | undefined; let catchFallbackRef: NodeRef | undefined; + let catchLoc: SourceLocation | undefined; let catchFallbackInternalWires: Wire[] = []; const catchAlt = sub(scopeLine, "scopeCatchAlt"); if (catchAlt) { const preLen = wires.length; const altResult = extractCoalesceAltIterAware(catchAlt, scopeLineNum); - if ("literal" in altResult) catchFallback = altResult.literal; - else if ("control" in altResult) catchControl = altResult.control; - else { - catchFallbackRef = altResult.sourceRef; + const catchAttrs = buildCatchAttrs(catchAlt, altResult); + catchLoc = catchAttrs.catchLoc; + catchFallback = catchAttrs.catchFallback; + catchControl = catchAttrs.catchControl; + catchFallbackRef = catchAttrs.catchFallbackRef; + if ("sourceRef" in altResult) { catchFallbackInternalWires = wires.splice(preLen); } } const lastAttrs = { ...(fallbacks.length > 0 ? { fallbacks } : {}), - ...(catchFallback ? { catchFallback } : {}), - ...(catchFallbackRef ? { catchFallbackRef } : {}), + ...(catchLoc ? { catchLoc } : {}), + ...(catchFallback !== undefined ? { catchFallback } : {}), + ...(catchFallbackRef !== undefined ? { catchFallbackRef } : {}), ...(catchControl ? { catchControl } : {}), }; if (segs) { @@ -2764,6 +2772,13 @@ function processElementScopeLines( } const exprOps = subs(scopeLine, "scopeExprOp"); + const exprRights = subs(scopeLine, "scopeExprRight"); + const condLoc = locFromNodeRange( + scopeFirstParenNode ?? scopeSourceNode, + exprRights[exprRights.length - 1] ?? + scopeFirstParenNode ?? + scopeSourceNode, + ); let condRef: NodeRef; let condIsPipeFork: boolean; if (scopeFirstParenNode && resolveParenExprFn) { @@ -2775,7 +2790,6 @@ function processElementScopeLines( scopeLineLoc, ); if (exprOps.length > 0 && desugarExprChain) { - const exprRights = subs(scopeLine, "scopeExprRight"); condRef = desugarExprChain( parenRef, exprOps, @@ -2790,7 +2804,6 @@ function processElementScopeLines( } condIsPipeFork = true; } else if (exprOps.length > 0 && desugarExprChain) { - const exprRights = subs(scopeLine, "scopeExprRight"); let leftRef: NodeRef; const directIterRef = scopePipeSegs.length === 0 @@ -2859,39 +2872,42 @@ function processElementScopeLines( const altNode = sub(item, "altValue")!; const preLen = wires.length; const altResult = extractCoalesceAltIterAware(altNode, scopeLineNum); - if ("literal" in altResult) { - fallbacks.push({ type, value: altResult.literal }); - } else if ("control" in altResult) { - fallbacks.push({ type, control: altResult.control }); - } else { - fallbacks.push({ type, ref: altResult.sourceRef }); + fallbacks.push(buildWireFallback(type, altNode, altResult)); + if ("sourceRef" in altResult) { fallbackInternalWires.push(...wires.splice(preLen)); } } let catchFallback: string | undefined; let catchControl: ControlFlowInstruction | undefined; let catchFallbackRef: NodeRef | undefined; + let catchLoc: SourceLocation | undefined; let catchFallbackInternalWires: Wire[] = []; const catchAlt = sub(scopeLine, "scopeCatchAlt"); if (catchAlt) { const preLen = wires.length; const altResult = extractCoalesceAltIterAware(catchAlt, scopeLineNum); - if ("literal" in altResult) catchFallback = altResult.literal; - else if ("control" in altResult) catchControl = altResult.control; - else { - catchFallbackRef = altResult.sourceRef; + const catchAttrs = buildCatchAttrs(catchAlt, altResult); + catchLoc = catchAttrs.catchLoc; + catchFallback = catchAttrs.catchFallback; + catchControl = catchAttrs.catchControl; + catchFallbackRef = catchAttrs.catchFallbackRef; + if ("sourceRef" in altResult) { catchFallbackInternalWires = wires.splice(preLen); } } wires.push({ cond: condRef, + ...(condLoc ? { condLoc } : {}), + thenLoc: thenBranch.loc, ...(thenBranch.kind === "ref" ? { thenRef: thenBranch.ref } : { thenValue: thenBranch.value }), + elseLoc: elseBranch.loc, ...(elseBranch.kind === "ref" ? { elseRef: elseBranch.ref } : { elseValue: elseBranch.value }), ...(fallbacks.length > 0 ? { fallbacks } : {}), + ...(catchLoc ? { catchLoc } : {}), ...(catchFallback !== undefined ? { catchFallback } : {}), ...(catchFallbackRef !== undefined ? { catchFallbackRef } : {}), ...(catchControl ? { catchControl } : {}), @@ -2915,12 +2931,8 @@ function processElementScopeLines( const altNode = sub(item, "altValue")!; const preLen = wires.length; const altResult = extractCoalesceAltIterAware(altNode, scopeLineNum); - if ("literal" in altResult) { - fallbacks.push({ type, value: altResult.literal }); - } else if ("control" in altResult) { - fallbacks.push({ type, control: altResult.control }); - } else { - fallbacks.push({ type, ref: altResult.sourceRef }); + fallbacks.push(buildWireFallback(type, altNode, altResult)); + if ("sourceRef" in altResult) { fallbackInternalWires.push(...wires.splice(preLen)); } } @@ -2928,15 +2940,18 @@ function processElementScopeLines( let catchFallback: string | undefined; let catchControl: ControlFlowInstruction | undefined; let catchFallbackRef: NodeRef | undefined; + let catchLoc: SourceLocation | undefined; let catchFallbackInternalWires: Wire[] = []; const catchAlt = sub(scopeLine, "scopeCatchAlt"); if (catchAlt) { const preLen = wires.length; const altResult = extractCoalesceAltIterAware(catchAlt, scopeLineNum); - if ("literal" in altResult) catchFallback = altResult.literal; - else if ("control" in altResult) catchControl = altResult.control; - else { - catchFallbackRef = altResult.sourceRef; + const catchAttrs = buildCatchAttrs(catchAlt, altResult); + catchLoc = catchAttrs.catchLoc; + catchFallback = catchAttrs.catchFallback; + catchControl = catchAttrs.catchControl; + catchFallbackRef = catchAttrs.catchFallbackRef; + if ("sourceRef" in altResult) { catchFallbackInternalWires = wires.splice(preLen); } } @@ -2945,8 +2960,9 @@ function processElementScopeLines( const wireAttrs = { ...(isPipe ? { pipe: true as const } : {}), ...(fallbacks.length > 0 ? { fallbacks } : {}), - ...(catchFallback ? { catchFallback } : {}), - ...(catchFallbackRef ? { catchFallbackRef } : {}), + ...(catchLoc ? { catchLoc } : {}), + ...(catchFallback !== undefined ? { catchFallback } : {}), + ...(catchFallbackRef !== undefined ? { catchFallbackRef } : {}), ...(catchControl ? { catchControl } : {}), }; wires.push({ from: fromRef, to: elemToRef, ...wireAttrs }); @@ -3901,6 +3917,11 @@ function buildBridgeBody( : undefined; const isSafe = headNode ? !!extractAddressPath(headNode).rootSafe : false; const exprOps = subs(wireNode, "exprOp"); + const exprRights = subs(wireNode, "exprRight"); + const condLoc = locFromNodeRange( + firstParenNode ?? firstSourceNode, + exprRights[exprRights.length - 1] ?? firstParenNode ?? firstSourceNode, + ); let condRef: NodeRef; let condIsPipeFork: boolean; @@ -3913,7 +3934,6 @@ function buildBridgeBody( wireLoc, ); if (exprOps.length > 0) { - const exprRights = subs(wireNode, "exprRight"); condRef = desugarExprChain( parenRef, exprOps, @@ -3928,7 +3948,6 @@ function buildBridgeBody( } condIsPipeFork = true; } else if (exprOps.length > 0) { - const exprRights = subs(wireNode, "exprRight"); const leftRef = buildSourceExpr(firstSourceNode!, lineNum, iterNames); condRef = desugarExprChain( leftRef, @@ -4002,9 +4021,12 @@ function buildBridgeBody( withLoc( { cond: condRef, + ...(condLoc ? { condLoc } : {}), + thenLoc: thenBranch.loc, ...(thenBranch.kind === "ref" ? { thenRef: thenBranch.ref } : { thenValue: thenBranch.value }), + elseLoc: elseBranch.loc, ...(elseBranch.kind === "ref" ? { elseRef: elseBranch.ref } : { elseValue: elseBranch.value }), @@ -4358,28 +4380,31 @@ function buildBridgeBody( branchNode: CstNode, lineNum: number, iterScope?: string | string[], - ): { kind: "literal"; value: string } | { kind: "ref"; ref: NodeRef } { + ): + | { kind: "literal"; value: string; loc?: SourceLocation } + | { kind: "ref"; ref: NodeRef; loc?: SourceLocation } { const c = branchNode.children; + const branchLoc = locFromNode(branchNode); if (c.stringLit) { const raw = (c.stringLit as IToken[])[0].image; const segs = parseTemplateString(raw.slice(1, -1)); if (segs) return { kind: "ref", - ref: desugarTemplateString( - segs, - lineNum, - iterScope, - locFromNode(branchNode), - ), + loc: branchLoc, + ref: desugarTemplateString(segs, lineNum, iterScope, branchLoc), }; - return { kind: "literal", value: raw }; + return { kind: "literal", value: raw, loc: branchLoc }; } if (c.numberLit) - return { kind: "literal", value: (c.numberLit as IToken[])[0].image }; - if (c.trueLit) return { kind: "literal", value: "true" }; - if (c.falseLit) return { kind: "literal", value: "false" }; - if (c.nullLit) return { kind: "literal", value: "null" }; + return { + kind: "literal", + value: (c.numberLit as IToken[])[0].image, + loc: branchLoc, + }; + if (c.trueLit) return { kind: "literal", value: "true", loc: branchLoc }; + if (c.falseLit) return { kind: "literal", value: "false", loc: branchLoc }; + if (c.nullLit) return { kind: "literal", value: "null", loc: branchLoc }; if (c.sourceRef) { const addrNode = (c.sourceRef as CstNode[])[0]; const { root, segments, rootSafe, segmentSafe } = @@ -4388,6 +4413,7 @@ function buildBridgeBody( if (iterRef) { return { kind: "ref", + loc: branchLoc, ref: { ...iterRef, ...(rootSafe ? { rootSafe: true } : {}), @@ -4398,6 +4424,7 @@ function buildBridgeBody( const ref = resolveAddress(root, segments, lineNum); return { kind: "ref", + loc: branchLoc, ref: { ...ref, ...(rootSafe ? { rootSafe: true } : {}), @@ -5072,6 +5099,13 @@ function buildBridgeBody( const scopeFirstParenNode = sub(scopeLine, "scopeFirstParenExpr"); const sourceParts: { ref: NodeRef; isPipeFork: boolean }[] = []; const exprOps = subs(scopeLine, "scopeExprOp"); + const exprRights = subs(scopeLine, "scopeExprRight"); + const condLoc = locFromNodeRange( + scopeFirstParenNode ?? firstSourceNode, + exprRights[exprRights.length - 1] ?? + scopeFirstParenNode ?? + firstSourceNode, + ); // Extract safe flag from head node let scopeBlockSafe: boolean = false; @@ -5092,7 +5126,6 @@ function buildBridgeBody( scopeBlockSafe || undefined, ); if (exprOps.length > 0) { - const exprRights = subs(scopeLine, "scopeExprRight"); condRef = desugarExprChain( parenRef, exprOps, @@ -5107,7 +5140,6 @@ function buildBridgeBody( } condIsPipeFork = true; } else if (exprOps.length > 0) { - const exprRights = subs(scopeLine, "scopeExprRight"); const leftRef = buildSourceExpr(firstSourceNode!, scopeLineNum); condRef = desugarExprChain( leftRef, @@ -5183,9 +5215,12 @@ function buildBridgeBody( withLoc( { cond: condRef, + ...(condLoc ? { condLoc } : {}), + thenLoc: thenBranch.loc, ...(thenBranch.kind === "ref" ? { thenRef: thenBranch.ref } : { thenValue: thenBranch.value }), + elseLoc: elseBranch.loc, ...(elseBranch.kind === "ref" ? { elseRef: elseBranch.ref } : { elseValue: elseBranch.value }), @@ -5381,9 +5416,12 @@ function buildBridgeBody( withLoc( { cond: sourceRef, + ...(sourceLoc ? { condLoc: sourceLoc } : {}), + thenLoc: thenBranch.loc, ...(thenBranch.kind === "ref" ? { thenRef: thenBranch.ref } : { thenValue: thenBranch.value }), + elseLoc: elseBranch.loc, ...(elseBranch.kind === "ref" ? { elseRef: elseBranch.ref } : { elseValue: elseBranch.value }), @@ -5486,9 +5524,12 @@ function buildBridgeBody( withLoc( { cond: condRef, + ...(sourceLoc ? { condLoc: sourceLoc } : {}), + thenLoc: thenBranch.loc, ...(thenBranch.kind === "ref" ? { thenRef: thenBranch.ref } : { thenValue: thenBranch.value }), + elseLoc: elseBranch.loc, ...(elseBranch.kind === "ref" ? { elseRef: elseBranch.ref } : { elseValue: elseBranch.value }), @@ -5924,9 +5965,12 @@ function buildBridgeBody( withLoc( { cond: condRef, + ...(sourceLoc ? { condLoc: sourceLoc } : {}), + thenLoc: thenBranch.loc, ...(thenBranch.kind === "ref" ? { thenRef: thenBranch.ref } : { thenValue: thenBranch.value }), + elseLoc: elseBranch.loc, ...(elseBranch.kind === "ref" ? { elseRef: elseBranch.ref } : { elseValue: elseBranch.value }), diff --git a/packages/bridge/test/runtime-error-format.test.ts b/packages/bridge/test/runtime-error-format.test.ts index 60ccb2fb..9b1eb0ab 100644 --- a/packages/bridge/test/runtime-error-format.test.ts +++ b/packages/bridge/test/runtime-error-format.test.ts @@ -71,6 +71,41 @@ bridge Query.greet { o.message <- i.name ?? panic "Fatale" }`; +const bridgeTernaryText = `version 1.5 + +bridge Query.greet { + with input as i + with output as o + + o.discount <- i.isPro ? 20 : i.asd.asd.asd +}`; + +const bridgeArrayThrowText = `version 1.5 + +bridge Query.processCatalog { + with input as i + with output as o + + o <- i.catalog[] as cat { + .name <- cat.name + .items <- cat.items[] as item { + .sku <- item.sku ?? continue 2 + .price <- item.price ?? throw "panic" + } + } +}`; + +const bridgeTernaryConditionErrorText = `version 1.5 + +bridge Query.pricing { + with input as i + with output as o + + o.tier <- i.isPro ? "premium" : "basic" + o.discount <- i.isPro ? 20 : 5 + o.price <- i.isPro.fail.asd ? i.proPrice : i.basicPrice +}`; + function maxCaretCount(formatted: string): number { return Math.max( 0, @@ -198,6 +233,94 @@ describe("runtime error formatting", () => { ); }); + test("ternary branch errors underline only the failing branch", async () => { + const document = parseBridge(bridgeTernaryText, { + filename: "playground.bridge", + }); + + await assert.rejects( + () => + executeBridge({ + document, + operation: "Query.greet", + input: { isPro: false }, + }), + (err: unknown) => { + const formatted = formatBridgeError(err); + assert.match( + formatted, + /Bridge Execution Error: Cannot read properties of undefined \(reading 'asd'\)/, + ); + assert.match(formatted, /playground\.bridge:7:32/); + assert.match( + formatted, + /o\.discount <- i\.isPro \? 20 : i\.asd\.asd\.asd/, + ); + assert.equal(maxCaretCount(formatted), "i.asd.asd.asd".length); + return true; + }, + ); + }); + + test("array-mapped throw fallbacks retain source snippets", async () => { + const document = parseBridge(bridgeArrayThrowText, { + filename: "playground.bridge", + }); + + await assert.rejects( + () => + executeBridge({ + document, + operation: "Query.processCatalog", + input: { + catalog: [ + { + name: "Cat", + items: [{ sku: "ABC", price: null }], + }, + ], + }, + }), + (err: unknown) => { + const formatted = formatBridgeError(err); + assert.match(formatted, /Bridge Execution Error: panic/); + assert.match(formatted, /playground\.bridge:11:31/); + assert.match(formatted, /\.price <- item\.price \?\? throw "panic"/); + assert.equal(maxCaretCount(formatted), 'throw "panic"'.length); + return true; + }, + ); + }); + + test("ternary condition errors point at the condition and missing segment", async () => { + const document = parseBridge(bridgeTernaryConditionErrorText, { + filename: "playground.bridge", + }); + + await assert.rejects( + () => + executeBridge({ + document, + operation: "Query.pricing", + input: { isPro: false, proPrice: 49.99, basicPrice: 9.99 }, + }), + (err: unknown) => { + const formatted = formatBridgeError(err); + assert.match( + formatted, + /Bridge Execution Error: Cannot read properties of false \(reading 'fail'\)/, + ); + assert.match(formatted, /playground\.bridge:9:14/); + assert.match( + formatted, + /o\.price <- i\.isPro\.fail\.asd \? i\.proPrice : i\.basicPrice/, + ); + assert.equal(maxCaretCount(formatted), "i.isPro.fail.asd".length); + return true; + }, + ); + }); + test("bridgeTransform surfaces formatted runtime errors through GraphQL", async () => { const schema = buildSchema(/* GraphQL */ ` type Query { diff --git a/packages/bridge/test/source-locations.test.ts b/packages/bridge/test/source-locations.test.ts index 9d719d45..cc820944 100644 --- a/packages/bridge/test/source-locations.test.ts +++ b/packages/bridge/test/source-locations.test.ts @@ -55,6 +55,12 @@ bridge Query.test { const ternaryWire = bridge.wires.find((wire) => "cond" in wire); assertLoc(ternaryWire, 5, 3); + assert.equal(ternaryWire?.condLoc?.startLine, 5); + assert.equal(ternaryWire?.condLoc?.startColumn, 13); + assert.equal(ternaryWire?.thenLoc?.startLine, 5); + assert.equal(ternaryWire?.thenLoc?.startColumn, 22); + assert.equal(ternaryWire?.elseLoc?.startLine, 5); + assert.equal(ternaryWire?.elseLoc?.startColumn, 36); }); it("desugared template wires inherit the originating source loc", () => { From febe2c5675cfb32ea040b434dcae8cd589121fe0 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Sat, 7 Mar 2026 16:51:02 +0100 Subject: [PATCH 6/8] We are done --- docs/runtime-error-source-mapping-plan.md | 172 ---------------------- 1 file changed, 172 deletions(-) delete mode 100644 docs/runtime-error-source-mapping-plan.md diff --git a/docs/runtime-error-source-mapping-plan.md b/docs/runtime-error-source-mapping-plan.md deleted file mode 100644 index 2846ec34..00000000 --- a/docs/runtime-error-source-mapping-plan.md +++ /dev/null @@ -1,172 +0,0 @@ -# Runtime Error Source Mapping Plan - -## Goal - -When a runtime error occurs during Bridge execution, surface the `.bridge` source location instead of an engine-internal stack frame. - -Target experience: - -```text -Bridge Execution Error: Cannot read properties of undefined (reading 'name') - --> src/catalog.bridge:14:5 - | -13 | o.items <- catalog.results[] as item { -14 | .name <- item.details.name ?? panic "Missing name" - | ^^^^^^^^^^^^^^^^^^^^^^^^^^ -15 | .price <- item.price -``` - -## Current Code Reality - -### Shared Types - -- `packages/bridge-types/src/index.ts` does not currently define a shared source-location type. -- `packages/bridge-core/src/types.ts` owns the `Wire` union and already re-exports shared types from `@stackables/bridge-types`. - -### Parser - -- `packages/bridge-parser/src/parser/parser.ts` already has token helpers: - - `line(...)` - - `findFirstToken(...)` - - `collectTokens(...)` -- `BridgeParseResult` already exposes `startLines: Map`. -- Wire construction is spread across multiple helpers, not only `buildBridgeBody(...)`: - - `processElementLines(...)` - - `processElementHandleWires(...)` - - `processScopeLines(...)` - - top-level alias handling in `buildBridgeBody(...)` - - top-level bridge wire handling in `buildBridgeBody(...)` - - synthetic wire emitters: - - `desugarTemplateString(...)` - - `desugarExprChain(...)` - - `desugarNot(...)` -- Define inlining clones wires from `defineDef.wires`, so wire-level locations should survive automatically if present on the parsed wires. - -### Runtime - -- Runtime errors currently propagate through: - - `packages/bridge-core/src/resolveWires.ts` - - `packages/bridge-core/src/ExecutionTree.ts` - - `packages/bridge-core/src/tree-types.ts` -- Fatal-error classification currently lives in `isFatalError(...)` in `tree-types.ts`. -- `resolveWiresAsync(...)` already has the right catch boundary for associating a thrown error with the active wire. -- `ExecutionTree.applyPath(...)` is still the main source of bad-path traversal errors. - -### Public Exports - -- `packages/bridge-core/src/index.ts` is the public export surface for new runtime error helpers. -- `packages/bridge/src/index.ts` re-exports `@stackables/bridge-core`, so new core exports automatically flow through the umbrella package. - -### Tests - -- Parser tests do not live under `packages/bridge-parser/test` in this repo. -- Parser and language tests run from `packages/bridge/test/*.test.ts` via the umbrella package. -- New parser coverage for wire locations should therefore be added under `packages/bridge/test/`. - -## Dependency Order - -```text -Phase 1 (shared type + parser wire locs) - ↓ -Phase 2 (runtime enrichment + formatter) - ↓ -Phase 3 (compiler loc stamping) - ↓ -Phase 4 (source text + filename threading in user-facing entry points) -``` - -Phase 1 is the only prerequisite for the later phases. - -## Phase 1 — Shared Wire Locations - -### Scope - -Files: - -- `packages/bridge-types/src/index.ts` -- `packages/bridge-core/src/types.ts` -- `packages/bridge-core/src/index.ts` -- `packages/bridge-parser/src/parser/parser.ts` -- `packages/bridge/test/source-locations.test.ts` (new) - -### Plan - -1. Add a shared `SourceLocation` type in `bridge-types`. -2. Add optional `loc?: SourceLocation` to every `Wire` union arm in `bridge-core/src/types.ts`. -3. Re-export `SourceLocation` from `bridge-core`. -4. Add parser helpers for building locations from CST/token spans. -5. Stamp `loc` on all parser-emitted wire variants, including synthetic/desugared wires: - - constant wires - - pull wires - - ternary wires - - `condAnd` / `condOr` wires - - spread wires - - alias wires - - template-string concat forks - - expression/not synthetic forks -6. Add tests in `packages/bridge/test/source-locations.test.ts` covering representative wire variants and one desugared/internal path. - -### Acceptance - -- Parsed wires carry `loc` data with 1-based line/column spans. -- Existing parser behavior remains unchanged apart from the added metadata. -- `define`-inlined wires preserve locations because cloning keeps the `loc` field. - -## Phase 2 — Runtime Error Enrichment - -### Scope - -Files: - -- `packages/bridge-core/src/tree-types.ts` -- `packages/bridge-core/src/resolveWires.ts` -- `packages/bridge-core/src/ExecutionTree.ts` -- `packages/bridge-core/src/formatBridgeError.ts` (new) -- `packages/bridge-core/src/index.ts` -- `packages/bridge-core/test` coverage currently lives through `packages/bridge/test`, so new runtime tests can be placed there unless a direct core test harness is introduced first. - -### Plan - -1. Introduce `BridgeRuntimeError` in `tree-types.ts` with optional `bridgeLoc` and `cause`. -2. In `resolveWiresAsync(...)`, wrap non-fatal errors with `BridgeRuntimeError` using the active wire's `loc`, preserving the innermost location when one already exists. -3. Thread optional `loc` into `ExecutionTree.applyPath(...)` so bad-path traversal errors can be stamped at the point of throw. -4. Add `formatBridgeError(...)` to render a compact location header and optional source snippet. -5. Export the new error class and formatter from `bridge-core`. - -## Phase 3 — AOT Compiler Loc Stamping - -### Scope - -Files: - -- `packages/bridge-compiler/src/codegen.ts` -- compiler tests under `packages/bridge-compiler/test/` - -### Plan - -1. Thread `w.loc` into emitted try/catch templates for compiled wire execution. -2. Stamp `bridgeLoc` only when the caught error does not already carry one. -3. Skip fire-and-forget branches that intentionally swallow errors. - -## Phase 4 — Source Text Threading - -### Scope - -Likely files: - -- execution options in `bridge-core` -- `packages/bridge-graphql` -- `examples/without-graphql` -- any other user-facing entry points that print runtime errors - -### Plan - -1. Add optional `source` and `filename` to execution options. -2. Call `formatBridgeError(...)` in GraphQL and CLI-style entry points. -3. Add one end-to-end test that verifies a formatted snippet is surfaced from a real `.bridge` source. - -## Notes - -- This plan does not include JS source maps for compiled output. -- Locations are attached at the Bridge DSL layer only, not inside tool implementations. -- Parser coverage should prefer representative cases over exhaustively asserting every construction site line-by-line. From a29095d4c5409bef933b61b9fdbeab5c6c0aa23d Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Sat, 7 Mar 2026 17:19:51 +0100 Subject: [PATCH 7/8] Perf docs --- packages/bridge-compiler/performance.md | 79 ++++++++++++++- packages/bridge-compiler/src/codegen.ts | 28 ++++++ packages/bridge-core/performance.md | 99 +++++++++++++++---- packages/bridge-core/src/ExecutionTree.ts | 70 +++++++++++-- .../bridge-core/src/materializeShadows.ts | 4 +- packages/bridge-core/src/resolveWires.ts | 2 +- packages/bridge-core/src/scheduleTools.ts | 4 +- packages/bridge-core/src/tree-types.ts | 2 +- packages/bridge-core/src/tree-utils.ts | 8 +- 9 files changed, 258 insertions(+), 38 deletions(-) diff --git a/packages/bridge-compiler/performance.md b/packages/bridge-compiler/performance.md index 571dce5c..2afe0695 100644 --- a/packages/bridge-compiler/performance.md +++ b/packages/bridge-compiler/performance.md @@ -4,9 +4,10 @@ Tracks engine performance work: what was tried, what failed, and what's planned. ## Summary -| # | Optimisation | Date | Result | -| --- | --------------------- | ---- | ------ | -| 1 | Future work goes here | | | +| # | Optimisation | Date | Result | +| --- | ------------------------------------ | ---------- | ------------------------------------------------ | +| 1 | Strict-path parity via `__path` | March 2026 | ✅ Done (correctness first, measurable slowdown) | +| 2 | Single-segment fast path via `__get` | March 2026 | ✅ Done (partial recovery on compiled hot paths) | ## Baseline (main, March 2026) @@ -41,4 +42,74 @@ This table is the current perf level. It is updated after a successful optimisat ## Optimisations -### 1. Future work goes here +### 1. Strict-path parity via `__path` + +**Date:** March 2026 +**Status:** ✅ Done + +**Why:** + +Runtime source-mapping work tightened strict path traversal semantics so +primitive property access throws at the failing segment instead of silently +flowing through as `undefined`. Compiled execution still had some strict paths +emitted as raw bracket access, which caused AOT/runtime divergence in parity +fuzzing. + +**What changed:** + +`appendPathExpr(...)` was switched to route compiled path traversal through the +generated `__path(...)` helper so compiled execution matched runtime semantics. + +**Result:** + +Correctness and parity were restored, but this imposed a noticeable cost on the +compiled hot path because even one-segment accesses paid the generic loop-based +helper. + +Observed branch-level compiled numbers before the follow-up optimisation: + +| Benchmark | Baseline | With `__path` everywhere | Change | +| -------------------------------------- | -------- | ------------------------ | ------ | +| compiled: passthrough (no tools) | ~644K | ~561K | -13% | +| compiled: simple chain (1 tool) | ~612K | ~536K | -12% | +| compiled: flat array 1000 | ~27.9K | ~14.1K | -49% | +| compiled: array + tool-per-element 100 | ~58.7K | ~45.2K | -23% | + +### 2. Single-segment fast path via `__get` + +**Date:** March 2026 +**Status:** ✅ Done + +**Hypothesis:** + +The vast majority of compiled property reads in the benchmark suite are short, +especially one-segment accesses. Running every one of them through the generic +`__path(base, path, safe, allowMissingBase)` loop was overpaying for the common +case. + +**What changed:** + +- Added a generated `__get(base, segment, accessSafe, allowMissingBase)` helper + for the one-segment case. +- Kept the strict primitive-property failure semantics from `__path(...)`. +- Left multi-segment accesses on `__path(...)` so correctness stays uniform. + +**Result:** + +This recovered a meaningful portion of the compiled regression while preserving +the stricter source-mapping semantics. + +| Benchmark | Before `__get` | After `__get` | Change | +| -------------------------------------- | -------------- | ------------- | ------ | +| compiled: passthrough (no tools) | ~561K | ~639K | +14% | +| compiled: simple chain (1 tool) | ~536K | ~583K | +9% | +| compiled: flat array 1000 | ~14.1K | ~15.7K | +11% | +| compiled: nested array 20×10 | ~36.0K | ~39.1K | +9% | +| compiled: array + tool-per-element 100 | ~45.2K | ~50.0K | +11% | + +**What remains:** + +Compiled performance is much closer to baseline now, but still below the March +2026 table on some heavy array benchmarks. The obvious next step, if needed, is +specialising short strict paths of length 2–3 rather than routing every +multi-segment path through the generic loop helper. diff --git a/packages/bridge-compiler/src/codegen.ts b/packages/bridge-compiler/src/codegen.ts index 73a49813..b8785a71 100644 --- a/packages/bridge-compiler/src/codegen.ts +++ b/packages/bridge-compiler/src/codegen.ts @@ -746,6 +746,29 @@ class CodegenContext { lines.push(` }`); lines.push(` return err;`); lines.push(` }`); + lines.push( + ` // Single-segment access is split out to preserve the compiled-path recovery documented in packages/bridge-compiler/performance.md (#2).`, + ); + lines.push( + ` function __get(base, segment, accessSafe, allowMissingBase) {`, + ); + lines.push(` if (base == null) {`); + lines.push(` if (allowMissingBase || accessSafe) return undefined;`); + lines.push( + ` throw new TypeError("Cannot read properties of " + base + " (reading '" + segment + "')");`, + ); + lines.push(` }`); + lines.push(` const next = base[segment];`); + lines.push( + ` const isPrimitiveBase = base !== null && typeof base !== "object" && typeof base !== "function";`, + ); + lines.push(` if (isPrimitiveBase && next === undefined) {`); + lines.push( + ` throw new TypeError("Cannot read properties of " + base + " (reading '" + segment + "')");`, + ); + lines.push(` }`); + lines.push(` return next;`); + lines.push(` }`); lines.push(` function __path(base, path, safe, allowMissingBase) {`); lines.push(` let result = base;`); lines.push(` for (let i = 0; i < path.length; i++) {`); @@ -3163,6 +3186,11 @@ class CodegenContext { (_, i) => ref.pathSafe?.[i] ?? (i === 0 ? (ref.rootSafe ?? false) : false), ); + // Prefer the dedicated single-segment helper on the dominant case. + // See packages/bridge-compiler/performance.md (#2). + if (ref.path.length === 1) { + return `__get(${baseExpr}, ${JSON.stringify(ref.path[0])}, ${safeFlags[0] ? "true" : "false"}, ${allowMissingBase ? "true" : "false"})`; + } return `__path(${baseExpr}, ${JSON.stringify(ref.path)}, ${JSON.stringify(safeFlags)}, ${allowMissingBase ? "true" : "false"})`; } diff --git a/packages/bridge-core/performance.md b/packages/bridge-core/performance.md index 56883dfb..5f969c87 100644 --- a/packages/bridge-core/performance.md +++ b/packages/bridge-core/performance.md @@ -4,23 +4,24 @@ Tracks engine performance work: what was tried, what failed, and what's planned. ## Summary -| # | Optimisation | Date | Result | -| --- | ---------------------------------- | ---------- | -------------------------------------- | -| 1 | WeakMap-cached DocumentIndex | March 2026 | ✗ Failed (–4–11%) | -| 2 | Lightweight shadow construction | March 2026 | ✅ Done (+5–7%) | -| 3 | Wire index by trunk key | March 2026 | ✗ Failed (–10–23%) | -| 4 | Cached element trunk key | March 2026 | ✅ Done (~0%, code cleanup) | -| 5 | Skip OTel when idle | March 2026 | ✅ Done (+7–9% tool-heavy) | -| 6 | Constant cache | March 2026 | ✅ Done (~0%, no regression) | -| 7 | pathEquals loop | March 2026 | ✅ Done (~0%, code cleanup) | -| 8 | Pre-group element wires | March 2026 | ✅ Done (see #9) | -| 9 | Batch element materialisation | March 2026 | ✅ Done (+44–130% arrays) | -| 10 | Sync fast path for resolved values | March 2026 | ✅ Done (+8–17% all, +42–114% arrays) | -| 11 | Pre-compute keys & cache wire tags | March 2026 | ✅ Done (+12–16% all, +60–129% arrays) | -| 12 | De-async schedule() & callTool() | March 2026 | ✅ Done (+11–18% tool, ~0% arrays) | -| 13 | Share toolDefCache across shadows | March 2026 | 🔲 Planned | -| 14 | Pre-compute output wire topology | March 2026 | 🔲 Planned | -| 15 | Cache executeBridge setup per doc | March 2026 | 🔲 Planned | +| # | Optimisation | Date | Result | +| --- | ---------------------------------- | ---------- | --------------------------------------------------- | +| 1 | WeakMap-cached DocumentIndex | March 2026 | ✗ Failed (–4–11%) | +| 2 | Lightweight shadow construction | March 2026 | ✅ Done (+5–7%) | +| 3 | Wire index by trunk key | March 2026 | ✗ Failed (–10–23%) | +| 4 | Cached element trunk key | March 2026 | ✅ Done (~0%, code cleanup) | +| 5 | Skip OTel when idle | March 2026 | ✅ Done (+7–9% tool-heavy) | +| 6 | Constant cache | March 2026 | ✅ Done (~0%, no regression) | +| 7 | pathEquals loop | March 2026 | ✅ Done (~0%, code cleanup) | +| 8 | Pre-group element wires | March 2026 | ✅ Done (see #9) | +| 9 | Batch element materialisation | March 2026 | ✅ Done (+44–130% arrays) | +| 10 | Sync fast path for resolved values | March 2026 | ✅ Done (+8–17% all, +42–114% arrays) | +| 11 | Pre-compute keys & cache wire tags | March 2026 | ✅ Done (+12–16% all, +60–129% arrays) | +| 12 | De-async schedule() & callTool() | March 2026 | ✅ Done (+11–18% tool, ~0% arrays) | +| 13 | Share toolDefCache across shadows | March 2026 | 🔲 Planned | +| 14 | Pre-compute output wire topology | March 2026 | 🔲 Planned | +| 15 | Cache executeBridge setup per doc | March 2026 | 🔲 Planned | +| 16 | Cheap strict-path hot-path guards | March 2026 | ✅ Done (partial recovery after error-mapping work) | ## Baseline (main, March 2026) @@ -731,3 +732,67 @@ passed to successive `executeBridge()` calls. If the same document is reused with different tool maps, the cached std resolution may be wrong. Safest approach: cache only when no user tools are provided, or use a `WeakMap` keyed by the tools object. + +### 16. Cheap strict-path hot-path guards + +**Date:** March 2026 +**Status:** ✅ Done +**Target:** Recover part of the runtime hit introduced by precise runtime error source mapping + +**Context:** + +The source-mapping work added stricter path traversal semantics and more +precise error attribution. That was expected to cost something, but the +runtime benchmark rerun showed a larger-than-desired hit on interpreter +hot paths, especially tool-heavy and array-heavy workloads. + +Observed branch-level runtime numbers before this mitigation: + +| Benchmark | Baseline | Before | Change | +| ---------------------------------- | -------- | ------ | ------ | +| exec: passthrough (no tools) | ~830K | ~638K | -23% | +| exec: short-circuit | ~801K | ~560K | -30% | +| exec: simple chain (1 tool) | ~558K | ~318K | -43% | +| exec: flat array 1000 | ~2,980 | ~2,408 | -19% | +| exec: array + tool-per-element 100 | ~3,980 | ~2,085 | -48% | + +**Hypothesis:** + +Two new costs were landing directly in the hottest interpreter path: + +1. Every single-segment path access paid the full generic multi-segment loop. +2. Array warning checks still evaluated `Array.isArray(...)` and the numeric + segment regex even when no logger was configured. + +Both are pure overhead on the benchmark path. + +**What changed:** + +- Added a dedicated single-segment fast path in `ExecutionTree.applyPath()`. +- Kept the strict primitive-property failure semantics from the source-mapping + work, but avoided the multi-segment loop setup for the common case. +- Gated array warning work behind `this.logger?.warn` so the benchmark fast path + skips the branch entirely when logging is disabled. + +**Result:** + +This did not restore the full pre-source-mapping runtime baseline, but it did +recover some of the avoidable overhead while preserving the new error behavior. + +Current runtime numbers after the mitigation: + +| Benchmark | Before | After | Change | +| ---------------------------------- | ------ | ------ | ------ | +| exec: passthrough (no tools) | ~588K | ~636K | +8% | +| exec: short-circuit | ~525K | ~558K | +6% | +| exec: array + tool-per-element 100 | ~1,862 | ~2,102 | +13% | + +**What remains:** + +The remaining interpreter regression appears to be real cost from stricter +error semantics rather than an obvious accidental slowdown. The next sensible +steps are profiling-driven, not blind micro-optimisation: + +- measure `ExecutionTree.applyPath()` in the runtime flamegraph on tool-heavy cases +- consider additional small-shape fast paths for 2- and 3-segment strict traversal +- evaluate whether any error-metadata work can be deferred off the success path diff --git a/packages/bridge-core/src/ExecutionTree.ts b/packages/bridge-core/src/ExecutionTree.ts index b4e5e369..46ee4902 100644 --- a/packages/bridge-core/src/ExecutionTree.ts +++ b/packages/bridge-core/src/ExecutionTree.ts @@ -150,7 +150,7 @@ export class ExecutionTree implements TreeContext { toolFns?: ToolMap; /** Shadow-tree nesting depth (0 for root). */ private depth: number; - /** Pre-computed `trunkKey({ ...this.trunk, element: true })`. See docs/performance.md (#4). */ + /** Pre-computed `trunkKey({ ...this.trunk, element: true })`. See packages/bridge-core/performance.md (#4). */ private elementTrunkKey: string; /** Sparse fieldset filter — set by `run()` when requestedFields is provided. */ requestedFields: string[] | undefined; @@ -311,7 +311,7 @@ export class ExecutionTree implements TreeContext { // When there is no internal tracer, no logger, and OpenTelemetry // has its default no-op provider, skip all instrumentation to // avoid closure allocation, template-string building, and no-op - // metric calls. See docs/performance.md (#5). + // metric calls. See packages/bridge-core/performance.md (#5). if (!tracer && !logger && !isOtelActive()) { try { const result = fnImpl(input, toolContext); @@ -483,7 +483,7 @@ export class ExecutionTree implements TreeContext { shadow(): ExecutionTree { // Lightweight: bypass the constructor to avoid redundant work that // re-derives data identical to the parent (bridge lookup, pipeHandleMap, - // handleVersionMap, constObj, toolFns spread). See docs/performance.md (#2). + // handleVersionMap, constObj, toolFns spread). See packages/bridge-core/performance.md (#2). const child = Object.create(ExecutionTree.prototype) as ExecutionTree; child.trunk = this.trunk; child.document = this.document; @@ -550,6 +550,58 @@ export class ExecutionTree implements TreeContext { private applyPath(resolved: any, ref: NodeRef, bridgeLoc?: Wire["loc"]): any { if (!ref.path.length) return resolved; + // Single-segment access dominates hot paths; keep it on a dedicated branch + // to preserve the partial recovery recorded in packages/bridge-core/performance.md (#16). + if (ref.path.length === 1) { + const segment = ref.path[0]!; + const accessSafe = ref.pathSafe?.[0] ?? ref.rootSafe ?? false; + if (resolved == null) { + if (ref.element || accessSafe) return undefined; + throw wrapBridgeRuntimeError( + new TypeError( + `Cannot read properties of ${resolved} (reading '${segment}')`, + ), + { + bridgeLoc, + bridgeSource: this.source, + bridgeFilename: this.filename, + }, + ); + } + + if (UNSAFE_KEYS.has(segment)) { + throw new Error(`Unsafe property traversal: ${segment}`); + } + if ( + this.logger?.warn && + Array.isArray(resolved) && + !/^\d+$/.test(segment) + ) { + this.logger?.warn?.( + `[bridge] Accessing ".${segment}" on an array (${resolved.length} items) — did you mean to use pickFirst or array mapping? Source: ${trunkKey(ref)}.${ref.path.join(".")}`, + ); + } + + const next = resolved[segment]; + const isPrimitiveBase = + resolved !== null && + typeof resolved !== "object" && + typeof resolved !== "function"; + if (isPrimitiveBase && next === undefined) { + throw wrapBridgeRuntimeError( + new TypeError( + `Cannot read properties of ${resolved} (reading '${segment}')`, + ), + { + bridgeLoc, + bridgeSource: this.source, + bridgeFilename: this.filename, + }, + ); + } + return next; + } + let result: any = resolved; for (let i = 0; i < ref.path.length; i++) { @@ -576,7 +628,11 @@ export class ExecutionTree implements TreeContext { if (UNSAFE_KEYS.has(segment)) throw new Error(`Unsafe property traversal: ${segment}`); - if (Array.isArray(result) && !/^\d+$/.test(segment)) { + if ( + this.logger?.warn && + Array.isArray(result) && + !/^\d+$/.test(segment) + ) { this.logger?.warn?.( `[bridge] Accessing ".${segment}" on an array (${result.length} items) — did you mean to use pickFirst or array mapping? Source: ${trunkKey(ref)}.${ref.path.join(".")}`, ); @@ -606,7 +662,7 @@ export class ExecutionTree implements TreeContext { /** * Pull a single value. Returns synchronously when already in state; * returns a Promise only when the value is a pending tool call. - * See docs/performance.md (#10). + * See packages/bridge-core/performance.md (#10). * * Public to satisfy `TreeContext` — extracted modules call this via * the interface. @@ -619,7 +675,7 @@ export class ExecutionTree implements TreeContext { // Cache trunkKey on the NodeRef via a Symbol key to avoid repeated // string allocation. Symbol keys don't affect V8 hidden classes, // so this won't degrade parser allocation-site throughput. - // See docs/performance.md (#11). + // See packages/bridge-core/performance.md (#11). const key: string = ((ref as any)[TRUNK_KEY_CACHE] ??= trunkKey(ref)); // ── Cycle detection ───────────────────────────────────────────── @@ -791,7 +847,7 @@ export class ExecutionTree implements TreeContext { * Resolve pre-grouped wires on this shadow tree without re-filtering. * Called by the parent's `materializeShadows` to skip per-element wire * filtering. Returns synchronously when the wire resolves sync (hot path). - * See docs/performance.md (#8, #10). + * See packages/bridge-core/performance.md (#8, #10). */ resolvePreGrouped(wires: Wire[]): MaybePromise { return this.resolveWires(wires); diff --git a/packages/bridge-core/src/materializeShadows.ts b/packages/bridge-core/src/materializeShadows.ts index 236834f7..fc0f6a86 100644 --- a/packages/bridge-core/src/materializeShadows.ts +++ b/packages/bridge-core/src/materializeShadows.ts @@ -150,13 +150,13 @@ export async function materializeShadows( // Collect all (shadow × field) resolutions. When every value is already in // state (the hot case for element passthrough), resolvePreGrouped returns // synchronously and we skip Promise.all entirely. - // See docs/performance.md (#9, #10). + // See packages/bridge-core/performance.md (#9, #10). if (deepPaths.size === 0) { const directFieldArray = [...directFields]; const nFields = directFieldArray.length; const nItems = items.length; // Pre-compute pathKeys and wire groups — only depend on j, not i. - // See docs/performance.md (#11). + // See packages/bridge-core/performance.md (#11). const preGroups: Wire[][] = new Array(nFields); for (let j = 0; j < nFields; j++) { const pathKey = [...pathPrefix, directFieldArray[j]!].join("\0"); diff --git a/packages/bridge-core/src/resolveWires.ts b/packages/bridge-core/src/resolveWires.ts index 0093825d..efd2c849 100644 --- a/packages/bridge-core/src/resolveWires.ts +++ b/packages/bridge-core/src/resolveWires.ts @@ -60,7 +60,7 @@ type WireWithGates = Exclude; * Fast path: single `from` wire with no fallback/catch modifiers, which is * the common case for element field wires like `.id <- it.id`. Delegates to * `resolveWiresAsync` for anything more complex. - * See docs/performance.md (#10). + * See packages/bridge-core/performance.md (#10). */ export function resolveWires( ctx: TreeContext, diff --git a/packages/bridge-core/src/scheduleTools.ts b/packages/bridge-core/src/scheduleTools.ts index 8b66f742..06305749 100644 --- a/packages/bridge-core/src/scheduleTools.ts +++ b/packages/bridge-core/src/scheduleTools.ts @@ -239,7 +239,7 @@ export function schedule( // For __local bindings, __define_ pass-throughs, pipe forks backed by // sync tools, and logic nodes — resolve bridge wires and return // synchronously when all sources are already in state. - // See docs/performance.md (#12). + // See packages/bridge-core/performance.md (#12). const groupEntries = Array.from(wireGroups.entries()); const nGroups = groupEntries.length; const values: MaybePromise[] = new Array(nGroups); @@ -271,7 +271,7 @@ export function schedule( * Assemble input from resolved wire values and either invoke a direct tool * function or return the data for pass-through targets (local/define/logic). * Returns synchronously when the tool function (if any) returns sync. - * See docs/performance.md (#12). + * See packages/bridge-core/performance.md (#12). */ export function scheduleFinish( ctx: SchedulerContext, diff --git a/packages/bridge-core/src/tree-types.ts b/packages/bridge-core/src/tree-types.ts index 03ec4520..fcb824f2 100644 --- a/packages/bridge-core/src/tree-types.ts +++ b/packages/bridge-core/src/tree-types.ts @@ -93,7 +93,7 @@ export const MAX_EXECUTION_DEPTH = 30; * A value that may already be resolved (synchronous) or still pending (asynchronous). * Using this instead of always returning `Promise` lets callers skip * microtask scheduling when the value is immediately available. - * See docs/performance.md (#10). + * See packages/bridge-core/performance.md (#10). */ export type MaybePromise = T | Promise; diff --git a/packages/bridge-core/src/tree-utils.ts b/packages/bridge-core/src/tree-utils.ts index 558f24a2..a6ad45c7 100644 --- a/packages/bridge-core/src/tree-utils.ts +++ b/packages/bridge-core/src/tree-utils.ts @@ -28,7 +28,7 @@ export function sameTrunk(a: Trunk, b: Trunk): boolean { // ── Path helpers ──────────────────────────────────────────────────────────── -/** Strict path equality — manual loop avoids `.every()` closure allocation. See docs/performance.md (#7). */ +/** Strict path equality — manual loop avoids `.every()` closure allocation. See packages/bridge-core/performance.md (#7). */ export function pathEquals(a: string[], b: string[]): boolean { if (!a || !b) return a === b; if (a.length !== b.length) return false; @@ -51,7 +51,7 @@ export function pathEquals(a: string[], b: string[]): boolean { * Results are cached in a module-level Map because the same constant * strings appear repeatedly across shadow trees. Only safe for * immutable values (primitives); callers must not mutate the returned - * value. See docs/performance.md (#6). + * value. See packages/bridge-core/performance.md (#6). */ const constantCache = new Map(); export function coerceConstant(raw: string): unknown { @@ -160,7 +160,7 @@ export function setNested(obj: any, path: string[], value: any): void { // This means the execution engine can safely cache computed values on // parser-produced objects without triggering shape transitions that would // degrade the parser's allocation-site throughput. -// See docs/performance.md (#11). +// See packages/bridge-core/performance.md (#11). /** Symbol key for the cached `trunkKey()` result on NodeRef objects. */ export const TRUNK_KEY_CACHE = Symbol.for("bridge.trunkKey"); @@ -175,7 +175,7 @@ export const SIMPLE_PULL_CACHE = Symbol.for("bridge.simplePull"); * path (single `from` wire, no safe/fallbacks/catch modifiers). Returns * `null` otherwise. The result is cached on the wire via a Symbol key so * subsequent calls are a single property read without affecting V8 shapes. - * See docs/performance.md (#11). + * See packages/bridge-core/performance.md (#11). */ export function getSimplePullRef(w: Wire): NodeRef | null { if ("from" in w) { From 7065cfbd5abf87932bf6c42ac0a54ec6b1e51b97 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Sat, 7 Mar 2026 17:33:04 +0100 Subject: [PATCH 8/8] Cleanup --- packages/bridge-compiler/src/codegen.ts | 12 +----- .../bridge-compiler/src/execute-bridge.ts | 8 +++- packages/bridge-compiler/test/codegen.test.ts | 34 +++++++++++++++- packages/bridge-core/src/ExecutionTree.ts | 24 ++--------- packages/bridge-core/src/execute-bridge.ts | 8 +++- packages/bridge-core/src/formatBridgeError.ts | 32 ++++++++++++++- packages/bridge-core/src/index.ts | 10 ++++- packages/bridge-core/src/resolveWires.ts | 15 +------ packages/bridge-core/src/scheduleTools.ts | 8 ---- packages/bridge-core/src/tree-types.ts | 33 +-------------- .../bridge-graphql/src/bridge-transform.ts | 40 ++++++++++++++++--- .../bridge/test/runtime-error-format.test.ts | 6 ++- packages/playground/src/engine.ts | 14 ++++++- 13 files changed, 146 insertions(+), 98 deletions(-) diff --git a/packages/bridge-compiler/src/codegen.ts b/packages/bridge-compiler/src/codegen.ts index b8785a71..d05c52f5 100644 --- a/packages/bridge-compiler/src/codegen.ts +++ b/packages/bridge-compiler/src/codegen.ts @@ -696,12 +696,10 @@ class CodegenContext { ` const __BridgeTimeoutError = __opts?.__BridgeTimeoutError ?? class extends Error { constructor(n, ms) { super('Tool "' + n + '" timed out after ' + ms + 'ms'); this.name = "BridgeTimeoutError"; } };`, ); lines.push( - ` const __BridgeRuntimeError = __opts?.__BridgeRuntimeError ?? class extends Error { constructor(message, options) { super(message, options && "cause" in options ? { cause: options.cause } : undefined); this.name = "BridgeRuntimeError"; this.bridgeLoc = options?.bridgeLoc; this.bridgeSource = options?.bridgeSource; this.bridgeFilename = options?.bridgeFilename; } };`, + ` const __BridgeRuntimeError = __opts?.__BridgeRuntimeError ?? class extends Error { constructor(message, options) { super(message, options && "cause" in options ? { cause: options.cause } : undefined); this.name = "BridgeRuntimeError"; this.bridgeLoc = options?.bridgeLoc; } };`, ); lines.push(` const __signal = __opts?.signal;`); lines.push(` const __timeoutMs = __opts?.toolTimeoutMs ?? 0;`); - lines.push(` const __bridgeSource = __opts?.source;`); - lines.push(` const __bridgeFilename = __opts?.filename;`); lines.push( ` const __ctx = { logger: __opts?.logger ?? {}, signal: __signal };`, ); @@ -715,7 +713,7 @@ class CodegenContext { ` if (err?.name === "BridgeRuntimeError" && err.bridgeLoc !== undefined) throw err;`, ); lines.push( - ` throw new __BridgeRuntimeError(err instanceof Error ? err.message : String(err), { cause: err, bridgeLoc: loc, bridgeSource: __bridgeSource, bridgeFilename: __bridgeFilename });`, + ` throw new __BridgeRuntimeError(err instanceof Error ? err.message : String(err), { cause: err, bridgeLoc: loc });`, ); lines.push(` }`); lines.push(` function __wrapBridgeError(fn, loc) {`); @@ -737,12 +735,6 @@ class CodegenContext { ` if (err && (typeof err === "object" || typeof err === "function")) {`, ); lines.push(` if (err.bridgeLoc === undefined) err.bridgeLoc = loc;`); - lines.push( - ` if (err.bridgeSource === undefined) err.bridgeSource = __bridgeSource;`, - ); - lines.push( - ` if (err.bridgeFilename === undefined) err.bridgeFilename = __bridgeFilename;`, - ); lines.push(` }`); lines.push(` return err;`); lines.push(` }`); diff --git a/packages/bridge-compiler/src/execute-bridge.ts b/packages/bridge-compiler/src/execute-bridge.ts index 5d53d4fb..04981eb5 100644 --- a/packages/bridge-compiler/src/execute-bridge.ts +++ b/packages/bridge-compiler/src/execute-bridge.ts @@ -19,6 +19,7 @@ import { BridgeAbortError, BridgeRuntimeError, BridgeTimeoutError, + attachBridgeErrorDocumentContext, executeBridge as executeCoreBridge, } from "@stackables/bridge-core"; import { std as bundledStd } from "@stackables/bridge-stdlib"; @@ -331,6 +332,11 @@ export async function executeBridge( } : undefined, }; - const data = await fn(input, flatTools, context, opts); + let data: unknown; + try { + data = await fn(input, flatTools, context, opts); + } catch (err) { + throw attachBridgeErrorDocumentContext(err, document); + } return { data: data as T, traces: tracer?.traces ?? [] }; } diff --git a/packages/bridge-compiler/test/codegen.test.ts b/packages/bridge-compiler/test/codegen.test.ts index 88c3fba5..3676d0a5 100644 --- a/packages/bridge-compiler/test/codegen.test.ts +++ b/packages/bridge-compiler/test/codegen.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { describe, test } from "node:test"; import { parseBridgeFormat } from "@stackables/bridge-parser"; -import { executeBridge } from "@stackables/bridge-core"; +import { executeBridge, formatBridgeError } from "@stackables/bridge-core"; import type { BridgeDocument } from "@stackables/bridge-core"; import { compileBridge, executeBridge as executeAot } from "../src/index.ts"; @@ -1268,6 +1268,38 @@ bridge Query.secure { // ── Phase: Abort signal & timeout ──────────────────────────────────────────── describe("executeAot: abort signal & timeout", () => { + test("runtime errors retain document formatting context", async () => { + const bridgeText = `version 1.5 +bridge Query.test { + with input as i + with output as o + + o.name <- i.missing.value +}`; + const document = parseBridgeFormat(bridgeText); + document.source = bridgeText; + document.filename = "playground.bridge"; + + await assert.rejects( + () => + executeAot({ + document, + operation: "Query.test", + input: {}, + }), + (err: unknown) => { + const formatted = formatBridgeError(err); + assert.match( + formatted, + /Bridge Execution Error: Cannot read properties of undefined \(reading '(missing|value)'\)/, + ); + assert.match(formatted, /playground\.bridge:6:13/); + assert.match(formatted, /o\.name <- i\.missing\.value/); + return true; + }, + ); + }); + test("abort signal prevents tool execution", async () => { const document = parseBridgeFormat(`version 1.5 bridge Query.test { diff --git a/packages/bridge-core/src/ExecutionTree.ts b/packages/bridge-core/src/ExecutionTree.ts index 46ee4902..ce39025c 100644 --- a/packages/bridge-core/src/ExecutionTree.ts +++ b/packages/bridge-core/src/ExecutionTree.ts @@ -561,11 +561,7 @@ export class ExecutionTree implements TreeContext { new TypeError( `Cannot read properties of ${resolved} (reading '${segment}')`, ), - { - bridgeLoc, - bridgeSource: this.source, - bridgeFilename: this.filename, - }, + { bridgeLoc }, ); } @@ -592,11 +588,7 @@ export class ExecutionTree implements TreeContext { new TypeError( `Cannot read properties of ${resolved} (reading '${segment}')`, ), - { - bridgeLoc, - bridgeSource: this.source, - bridgeFilename: this.filename, - }, + { bridgeLoc }, ); } return next; @@ -618,11 +610,7 @@ export class ExecutionTree implements TreeContext { new TypeError( `Cannot read properties of ${result} (reading '${segment}')`, ), - { - bridgeLoc, - bridgeSource: this.source, - bridgeFilename: this.filename, - }, + { bridgeLoc }, ); } @@ -647,11 +635,7 @@ export class ExecutionTree implements TreeContext { new TypeError( `Cannot read properties of ${result} (reading '${segment}')`, ), - { - bridgeLoc, - bridgeSource: this.source, - bridgeFilename: this.filename, - }, + { bridgeLoc }, ); } result = next; diff --git a/packages/bridge-core/src/execute-bridge.ts b/packages/bridge-core/src/execute-bridge.ts index f819687d..235aeb62 100644 --- a/packages/bridge-core/src/execute-bridge.ts +++ b/packages/bridge-core/src/execute-bridge.ts @@ -1,4 +1,5 @@ import { ExecutionTree } from "./ExecutionTree.ts"; +import { attachBridgeErrorDocumentContext } from "./formatBridgeError.ts"; import { TraceCollector } from "./tracing.ts"; import type { Logger } from "./tree-types.ts"; import type { ToolTrace, TraceLevel } from "./tracing.ts"; @@ -158,7 +159,12 @@ export async function executeBridge( tree.tracer = new TraceCollector(traceLevel); } - const data = await tree.run(input, options.requestedFields); + let data: unknown; + try { + data = await tree.run(input, options.requestedFields); + } catch (err) { + throw attachBridgeErrorDocumentContext(err, doc); + } return { data: data as T, traces: tree.getTraces() }; } diff --git a/packages/bridge-core/src/formatBridgeError.ts b/packages/bridge-core/src/formatBridgeError.ts index f9d45838..f4f5757f 100644 --- a/packages/bridge-core/src/formatBridgeError.ts +++ b/packages/bridge-core/src/formatBridgeError.ts @@ -1,10 +1,15 @@ -import type { SourceLocation } from "./types.ts"; +import type { BridgeDocument, SourceLocation } from "./types.ts"; export type FormatBridgeErrorOptions = { source?: string; filename?: string; }; +export type BridgeErrorDocumentContext = Pick< + BridgeDocument, + "source" | "filename" +>; + function getMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } @@ -25,6 +30,31 @@ function getBridgeMetadata(err: unknown): { }; } +export function attachBridgeErrorDocumentContext( + err: T, + document?: BridgeErrorDocumentContext, +): T { + if ( + !document || + !err || + (typeof err !== "object" && typeof err !== "function") + ) { + return err; + } + + const carrier = err as { + bridgeSource?: string; + bridgeFilename?: string; + }; + if (carrier.bridgeSource === undefined) { + carrier.bridgeSource = document.source; + } + if (carrier.bridgeFilename === undefined) { + carrier.bridgeFilename = document.filename; + } + return err; +} + function getBridgeLoc(err: unknown): SourceLocation | undefined { return getBridgeMetadata(err)?.bridgeLoc; } diff --git a/packages/bridge-core/src/index.ts b/packages/bridge-core/src/index.ts index e4c8b372..13ad98a3 100644 --- a/packages/bridge-core/src/index.ts +++ b/packages/bridge-core/src/index.ts @@ -35,8 +35,14 @@ export { mergeBridgeDocuments } from "./merge-documents.ts"; export { ExecutionTree } from "./ExecutionTree.ts"; export { TraceCollector, boundedClone } from "./tracing.ts"; export type { ToolTrace, TraceLevel } from "./tracing.ts"; -export { formatBridgeError } from "./formatBridgeError.ts"; -export type { FormatBridgeErrorOptions } from "./formatBridgeError.ts"; +export { + formatBridgeError, + attachBridgeErrorDocumentContext, +} from "./formatBridgeError.ts"; +export type { + FormatBridgeErrorOptions, + BridgeErrorDocumentContext, +} from "./formatBridgeError.ts"; export { BridgeAbortError, BridgePanicError, diff --git a/packages/bridge-core/src/resolveWires.ts b/packages/bridge-core/src/resolveWires.ts index efd2c849..ab64e567 100644 --- a/packages/bridge-core/src/resolveWires.ts +++ b/packages/bridge-core/src/resolveWires.ts @@ -119,8 +119,6 @@ async function resolveWiresAsync( lastError = wrapBridgeRuntimeError(err, { bridgeLoc: w.loc, - bridgeSource: ctx.source, - bridgeFilename: ctx.filename, }); } } @@ -154,11 +152,7 @@ export async function applyFallbackGates( if (isFalsyGateOpen || isNullishGateOpen) { if (fallback.control) { - return applyControlFlowWithLoc( - ctx, - fallback.control, - fallback.loc ?? w.loc, - ); + return applyControlFlowWithLoc(fallback.control, fallback.loc ?? w.loc); } if (fallback.ref) { value = await ctx.pullSingle( @@ -191,7 +185,7 @@ export async function applyCatchGate( pullChain?: Set, ): Promise { if (w.catchControl) { - return applyControlFlowWithLoc(ctx, w.catchControl, w.catchLoc ?? w.loc); + return applyControlFlowWithLoc(w.catchControl, w.catchLoc ?? w.loc); } if (w.catchFallbackRef) { return ctx.pullSingle(w.catchFallbackRef, pullChain, w.catchLoc ?? w.loc); @@ -201,7 +195,6 @@ export async function applyCatchGate( } function applyControlFlowWithLoc( - ctx: TreeContext, control: ControlFlowInstruction, bridgeLoc: Wire["loc"], ): symbol | import("./tree-types.ts").LoopControlSignal { @@ -211,15 +204,11 @@ function applyControlFlowWithLoc( if (err instanceof BridgePanicError) { throw attachBridgeErrorMetadata(err, { bridgeLoc, - bridgeSource: ctx.source, - bridgeFilename: ctx.filename, }); } if (isFatalError(err)) throw err; throw wrapBridgeRuntimeError(err, { bridgeLoc, - bridgeSource: ctx.source, - bridgeFilename: ctx.filename, }); } } diff --git a/packages/bridge-core/src/scheduleTools.ts b/packages/bridge-core/src/scheduleTools.ts index 06305749..bb853135 100644 --- a/packages/bridge-core/src/scheduleTools.ts +++ b/packages/bridge-core/src/scheduleTools.ts @@ -44,10 +44,6 @@ export interface SchedulerContext extends ToolLookupContext { readonly handleVersionMap: ReadonlyMap; /** Tool trunks marked with `memoize`. */ readonly memoizedToolKeys: ReadonlySet; - /** Optional original bridge source used for formatted runtime errors. */ - readonly source?: string; - /** Optional bridge filename used for formatted runtime errors. */ - readonly filename?: string; // ── Methods ──────────────────────────────────────────────────────────── /** Recursive entry point — parent delegation calls this. */ @@ -339,8 +335,6 @@ export function scheduleFinish( throw wrapBridgeRuntimeError(new Error(`No tool found for "${toolName}"`), { bridgeLoc, - bridgeSource: ctx.source, - bridgeFilename: ctx.filename, }); } @@ -388,8 +382,6 @@ export async function scheduleToolDef( new Error(`Tool function "${toolDef.fn}" not registered`), { bridgeLoc, - bridgeSource: ctx.source, - bridgeFilename: ctx.filename, }, ); } diff --git a/packages/bridge-core/src/tree-types.ts b/packages/bridge-core/src/tree-types.ts index fcb824f2..66de63cd 100644 --- a/packages/bridge-core/src/tree-types.ts +++ b/packages/bridge-core/src/tree-types.ts @@ -41,30 +41,22 @@ export class BridgeTimeoutError extends Error { /** Runtime error enriched with the originating Bridge wire location. */ export class BridgeRuntimeError extends Error { bridgeLoc?: SourceLocation; - bridgeSource?: string; - bridgeFilename?: string; constructor( message: string, options: { bridgeLoc?: SourceLocation; - bridgeSource?: string; - bridgeFilename?: string; cause?: unknown; } = {}, ) { super(message, "cause" in options ? { cause: options.cause } : undefined); this.name = "BridgeRuntimeError"; this.bridgeLoc = options.bridgeLoc; - this.bridgeSource = options.bridgeSource; - this.bridgeFilename = options.bridgeFilename; } } type BridgeErrorMetadataCarrier = { bridgeLoc?: SourceLocation; - bridgeSource?: string; - bridgeFilename?: string; }; // ── Sentinels ─────────────────────────────────────────────────────────────── @@ -141,10 +133,6 @@ export interface TreeContext { ): MaybePromise; /** External abort signal — cancels execution when triggered. */ signal?: AbortSignal; - /** Optional original bridge source used for formatted runtime errors. */ - source?: string; - /** Optional bridge filename used for formatted runtime errors. */ - filename?: string; } /** Returns `true` when `value` is a thenable (Promise or Promise-like). */ @@ -170,32 +158,21 @@ export function wrapBridgeRuntimeError( err: unknown, options: { bridgeLoc?: SourceLocation; - bridgeSource?: string; - bridgeFilename?: string; } = {}, ): BridgeRuntimeError { if (err instanceof BridgeRuntimeError) { - if ( - err.bridgeLoc || - err.bridgeSource || - err.bridgeFilename || - (!options.bridgeLoc && !options.bridgeSource && !options.bridgeFilename) - ) { + if (err.bridgeLoc || !options.bridgeLoc) { return err; } return new BridgeRuntimeError(err.message, { bridgeLoc: options.bridgeLoc, - bridgeSource: options.bridgeSource, - bridgeFilename: options.bridgeFilename, cause: err.cause ?? err, }); } return new BridgeRuntimeError(errorMessage(err), { bridgeLoc: options.bridgeLoc, - bridgeSource: options.bridgeSource, - bridgeFilename: options.bridgeFilename, cause: err, }); } @@ -204,8 +181,6 @@ export function attachBridgeErrorMetadata( err: T, options: { bridgeLoc?: SourceLocation; - bridgeSource?: string; - bridgeFilename?: string; } = {}, ): T { if (!err || (typeof err !== "object" && typeof err !== "function")) { @@ -216,12 +191,6 @@ export function attachBridgeErrorMetadata( if (carrier.bridgeLoc === undefined) { carrier.bridgeLoc = options.bridgeLoc; } - if (carrier.bridgeSource === undefined) { - carrier.bridgeSource = options.bridgeSource; - } - if (carrier.bridgeFilename === undefined) { - carrier.bridgeFilename = options.bridgeFilename; - } return err; } diff --git a/packages/bridge-graphql/src/bridge-transform.ts b/packages/bridge-graphql/src/bridge-transform.ts index ba3ae831..fd1f1cba 100644 --- a/packages/bridge-graphql/src/bridge-transform.ts +++ b/packages/bridge-graphql/src/bridge-transform.ts @@ -273,7 +273,13 @@ export function bridgeTransform( } return data; } catch (err) { - throw new Error(formatBridgeError(err), { cause: err }); + throw new Error( + formatBridgeError(err, { + source: activeDoc.source, + filename: activeDoc.filename, + }), + { cause: err }, + ); } } @@ -406,7 +412,13 @@ export function bridgeTransform( try { result = await source.response(info.path, array); } catch (err) { - throw new Error(formatBridgeError(err), { cause: err }); + throw new Error( + formatBridgeError(err, { + source: source.source, + filename: source.filename, + }), + { cause: err }, + ); } // Scalar return types (JSON, JSONObject, etc.) won't trigger @@ -417,7 +429,13 @@ export function bridgeTransform( try { return result.collectOutput(); } catch (err) { - throw new Error(formatBridgeError(err), { cause: err }); + throw new Error( + formatBridgeError(err, { + source: result.source, + filename: result.filename, + }), + { cause: err }, + ); } } if (Array.isArray(result) && result[0] instanceof ExecutionTree) { @@ -428,7 +446,13 @@ export function bridgeTransform( ), ); } catch (err) { - throw new Error(formatBridgeError(err), { cause: err }); + throw new Error( + formatBridgeError(err, { + source: source.source, + filename: source.filename, + }), + { cause: err }, + ); } } } @@ -443,7 +467,13 @@ export function bridgeTransform( source.getForcedExecution(), ]).then(([data]) => data); } catch (err) { - throw new Error(formatBridgeError(err), { cause: err }); + throw new Error( + formatBridgeError(err, { + source: source.source, + filename: source.filename, + }), + { cause: err }, + ); } } return result; diff --git a/packages/bridge/test/runtime-error-format.test.ts b/packages/bridge/test/runtime-error-format.test.ts index 9b1eb0ab..c87d0656 100644 --- a/packages/bridge/test/runtime-error-format.test.ts +++ b/packages/bridge/test/runtime-error-format.test.ts @@ -118,8 +118,6 @@ describe("runtime error formatting", () => { const sourceLine = "o.message <- i.empty.array.error"; const formatted = formatBridgeError( new BridgeRuntimeError("boom", { - bridgeSource: sourceLine, - bridgeFilename: "playground.bridge", bridgeLoc: { startLine: 1, startColumn: 14, @@ -127,6 +125,10 @@ describe("runtime error formatting", () => { endColumn: 32, }, }), + { + source: sourceLine, + filename: "playground.bridge", + }, ); assert.equal(maxCaretCount(formatted), "i.empty.array.error".length); diff --git a/packages/playground/src/engine.ts b/packages/playground/src/engine.ts index 5edc2943..83c1eb20 100644 --- a/packages/playground/src/engine.ts +++ b/packages/playground/src/engine.ts @@ -260,7 +260,12 @@ export async function runBridge( }; } catch (err: unknown) { return { - errors: [formatBridgeError(err)], + errors: [ + formatBridgeError(err, { + source: document.source, + filename: document.filename, + }), + ], }; } finally { _onCacheHit = null; @@ -658,7 +663,12 @@ export async function runBridgeStandalone( }; } catch (err: unknown) { return { - errors: [formatBridgeError(err)], + errors: [ + formatBridgeError(err, { + source: document.source, + filename: document.filename, + }), + ], }; } finally { _onCacheHit = null;