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;