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..3a428233
--- /dev/null
+++ b/.changeset/runtime-error-tool-formatting.md
@@ -0,0 +1,14 @@
+---
+"@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.
+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/.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/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 38fb7bdc..d05c52f5 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,99 @@ 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; } };`,
+ );
lines.push(` const __signal = __opts?.signal;`);
lines.push(` const __timeoutMs = __opts?.toolTimeoutMs ?? 0;`);
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") throw __attachBridgeMeta(err, loc);`,
+ );
+ lines.push(` if (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 });`,
+ );
+ lines.push(` }`);
+ lines.push(` function __wrapBridgeError(fn, loc) {`);
+ lines.push(` try {`);
+ lines.push(` return fn();`);
+ lines.push(` } catch (err) {`);
+ lines.push(` __rethrowBridgeError(err, loc);`);
+ lines.push(` }`);
+ lines.push(` }`);
+ lines.push(` async function __wrapBridgeErrorAsync(fn, loc) {`);
+ lines.push(` try {`);
+ lines.push(` return await fn();`);
+ lines.push(` } catch (err) {`);
+ lines.push(` __rethrowBridgeError(err, loc);`);
+ lines.push(` }`);
+ lines.push(` }`);
+ lines.push(` function __attachBridgeMeta(err, loc) {`);
+ lines.push(
+ ` if (err && (typeof err === "object" || typeof err === "function")) {`,
+ );
+ lines.push(` if (err.bridgeLoc === undefined) err.bridgeLoc = loc;`);
+ lines.push(` }`);
+ lines.push(` return err;`);
+ lines.push(` }`);
+ lines.push(
+ ` // Single-segment access is split out to preserve the compiled-path recovery documented in packages/bridge-compiler/performance.md (#2).`,
+ );
+ 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++) {`);
+ 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(` 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(` }`);
// 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();`);
@@ -1407,7 +1495,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}`;
}
@@ -1432,7 +1520,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. */
@@ -2222,29 +2310,32 @@ 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)
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";
let expr = `(${condExpr} ? ${thenExpr} : ${elseExpr})`;
expr = this.applyFallbacks(w, expr);
- return expr;
+ return this.wrapWireExpr(w, expr);
}
// Logical AND
@@ -2258,7 +2349,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 +2363,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 +2375,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;
@@ -2300,7 +2412,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 {
@@ -2317,35 +2429,34 @@ 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);
}
}
+ 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 =
- `(${e})` +
- ref.path.map((p) => `?.[${JSON.stringify(p)}]`).join("");
- return e;
+ if (ref.path.length > 0) e = this.appendPathExpr(`(${e})`, ref);
+ 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;
@@ -2358,21 +2469,20 @@ 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);
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("");
+ let expr = this.appendPathExpr(elVar, w.from, true);
+ expr = this.wrapExprWithLoc(expr, w.fromLoc);
expr = this.applyFallbacks(w, expr);
return expr;
}
@@ -2888,7 +2998,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 +3012,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 +3033,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 {
@@ -3013,7 +3126,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}`;
}
@@ -3027,19 +3140,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
@@ -3049,9 +3150,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;
}
@@ -3065,17 +3164,26 @@ 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),
);
+ // 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"})`;
}
/**
@@ -3134,8 +3242,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-compiler/src/execute-bridge.ts b/packages/bridge-compiler/src/execute-bridge.ts
index 464294b5..04981eb5 100644
--- a/packages/bridge-compiler/src/execute-bridge.ts
+++ b/packages/bridge-compiler/src/execute-bridge.ts
@@ -17,7 +17,9 @@ import {
TraceCollector,
BridgePanicError,
BridgeAbortError,
+ BridgeRuntimeError,
BridgeTimeoutError,
+ attachBridgeErrorDocumentContext,
executeBridge as executeCoreBridge,
} from "@stackables/bridge-core";
import { std as bundledStd } from "@stackables/bridge-stdlib";
@@ -104,6 +106,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 +301,7 @@ export async function executeBridge(
__BridgePanicError: BridgePanicError,
__BridgeAbortError: BridgeAbortError,
__BridgeTimeoutError: BridgeTimeoutError,
+ __BridgeRuntimeError: BridgeRuntimeError,
__trace: tracer
? (
toolName: string,
@@ -328,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/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 fbe161db..ce39025c 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`.
@@ -147,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;
@@ -308,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);
@@ -480,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;
@@ -507,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;
}
@@ -542,36 +547,98 @@ 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;
+ // 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 },
+ );
+ }
- // 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]}')`,
- );
+ if (UNSAFE_KEYS.has(segment)) {
+ throw new Error(`Unsafe property traversal: ${segment}`);
+ }
+ if (
+ this.logger?.warn &&
+ Array.isArray(resolved) &&
+ !/^\d+$/.test(segment)
+ ) {
+ this.logger?.warn?.(
+ `[bridge] Accessing ".${segment}" on an array (${resolved.length} items) — did you mean to use pickFirst or array mapping? Source: ${trunkKey(ref)}.${ref.path.join(".")}`,
+ );
+ }
+
+ const next = resolved[segment];
+ const isPrimitiveBase =
+ resolved !== null &&
+ typeof resolved !== "object" &&
+ typeof resolved !== "function";
+ if (isPrimitiveBase && next === undefined) {
+ throw wrapBridgeRuntimeError(
+ new TypeError(
+ `Cannot read properties of ${resolved} (reading '${segment}')`,
+ ),
+ { bridgeLoc },
+ );
+ }
+ return next;
}
+ let result: any = resolved;
+
for (let i = 0; i < ref.path.length; i++) {
const segment = ref.path[i]!;
+ const accessSafe =
+ ref.pathSafe?.[i] ?? (i === 0 ? (ref.rootSafe ?? false) : false);
+
+ if (result == null) {
+ if ((i === 0 && ref.element) || accessSafe) {
+ result = undefined;
+ continue;
+ }
+ throw wrapBridgeRuntimeError(
+ new TypeError(
+ `Cannot read properties of ${result} (reading '${segment}')`,
+ ),
+ { bridgeLoc },
+ );
+ }
+
if (UNSAFE_KEYS.has(segment))
throw new Error(`Unsafe property traversal: ${segment}`);
- if (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(".")}`,
);
}
- result = result[segment];
- 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]}')`,
+ const next = result[segment];
+ const isPrimitiveBase =
+ result !== null &&
+ typeof result !== "object" &&
+ typeof result !== "function";
+ if (isPrimitiveBase && next === undefined) {
+ throw wrapBridgeRuntimeError(
+ new TypeError(
+ `Cannot read properties of ${result} (reading '${segment}')`,
+ ),
+ { bridgeLoc },
);
}
+ result = next;
}
return result;
}
@@ -579,7 +646,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.
@@ -587,11 +654,12 @@ 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,
// 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 ─────────────────────────────────────────────
@@ -649,12 +717,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),
);
}
@@ -763,7 +831,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/execute-bridge.ts b/packages/bridge-core/src/execute-bridge.ts
index 89bcc253..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";
@@ -133,12 +134,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);
}
@@ -147,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
new file mode 100644
index 00000000..f4f5757f
--- /dev/null
+++ b/packages/bridge-core/src/formatBridgeError.ts
@@ -0,0 +1,127 @@
+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);
+}
+
+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;
+ };
+}
+
+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;
+}
+
+function getBridgeSource(
+ err: unknown,
+ options: FormatBridgeErrorOptions,
+): string | undefined {
+ return options.source ?? getBridgeMetadata(err)?.bridgeSource;
+}
+
+function getBridgeFilename(
+ err: unknown,
+ options: FormatBridgeErrorOptions,
+): string {
+ return (
+ options.filename ?? getBridgeMetadata(err)?.bridgeFilename ?? ""
+ );
+}
+
+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 650a050b..13ad98a3 100644
--- a/packages/bridge-core/src/index.ts
+++ b/packages/bridge-core/src/index.ts
@@ -35,9 +35,18 @@ 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,
+ attachBridgeErrorDocumentContext,
+} from "./formatBridgeError.ts";
+export type {
+ FormatBridgeErrorOptions,
+ BridgeErrorDocumentContext,
+} from "./formatBridgeError.ts";
export {
BridgeAbortError,
BridgePanicError,
+ BridgeRuntimeError,
BridgeTimeoutError,
MAX_EXECUTION_DEPTH,
} from "./tree-types.ts";
@@ -56,6 +65,7 @@ export type {
HandleBinding,
Instruction,
NodeRef,
+ SourceLocation,
ToolCallFn,
ToolContext,
ToolDef,
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 55d2aff9..ab64e567 100644
--- a/packages/bridge-core/src/resolveWires.ts
+++ b/packages/bridge-core/src/resolveWires.ts
@@ -9,13 +9,16 @@
* 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";
@@ -57,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,
@@ -71,7 +74,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 +117,9 @@ async function resolveWiresAsync(
const recoveredValue = await applyCatchGate(ctx, w, pullChain);
if (recoveredValue !== undefined) return recoveredValue;
- lastError = err;
+ lastError = wrapBridgeRuntimeError(err, {
+ bridgeLoc: w.loc,
+ });
}
}
@@ -140,9 +151,15 @@ 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(fallback.control, fallback.loc ?? w.loc);
+ }
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);
}
@@ -167,12 +184,35 @@ export async function applyCatchGate(
w: WireWithGates,
pullChain?: Set,
): Promise {
- if (w.catchControl) return applyControlFlow(w.catchControl);
- if (w.catchFallbackRef) return ctx.pullSingle(w.catchFallbackRef, pullChain);
+ if (w.catchControl) {
+ return applyControlFlowWithLoc(w.catchControl, w.catchLoc ?? w.loc);
+ }
+ if (w.catchFallbackRef) {
+ return ctx.pullSingle(w.catchFallbackRef, pullChain, w.catchLoc ?? w.loc);
+ }
if (w.catchFallback != null) return coerceConstant(w.catchFallback);
return undefined;
}
+function applyControlFlowWithLoc(
+ 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,
+ });
+ }
+ if (isFatalError(err)) throw err;
+ throw wrapBridgeRuntimeError(err, {
+ bridgeLoc,
+ });
+ }
+}
+
// ── Layer 1: Wire source evaluation ─────────────────────────────────────────
/**
@@ -188,12 +228,20 @@ 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);
+ if (w.thenRef !== undefined) {
+ 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);
+ if (w.elseRef !== undefined) {
+ return ctx.pullSingle(w.elseRef, pullChain, w.elseLoc ?? w.loc);
+ }
if (w.elseValue !== undefined) return coerceConstant(w.elseValue);
}
return undefined;
@@ -201,20 +249,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 +274,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 +298,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..bb853135 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 {
@@ -52,6 +52,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. */
@@ -226,7 +235,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);
@@ -258,7 +267,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,
@@ -270,6 +279,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 +333,9 @@ export function scheduleFinish(
return input;
}
- throw new Error(`No tool found for "${toolName}"`);
+ throw wrapBridgeRuntimeError(new Error(`No tool found for "${toolName}"`), {
+ bridgeLoc,
+ });
}
// ── Schedule ToolDef ────────────────────────────────────────────────────────
@@ -361,9 +373,18 @@ 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,
+ },
+ );
+ }
// 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/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-core/src/tree-types.ts b/packages/bridge-core/src/tree-types.ts
index 36412723..66de63cd 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,32 @@ 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;
+
+ constructor(
+ message: string,
+ options: {
+ bridgeLoc?: SourceLocation;
+ cause?: unknown;
+ } = {},
+ ) {
+ super(message, "cause" in options ? { cause: options.cause } : undefined);
+ this.name = "BridgeRuntimeError";
+ this.bridgeLoc = options.bridgeLoc;
+ }
+}
+
+type BridgeErrorMetadataCarrier = {
+ bridgeLoc?: SourceLocation;
+};
+
// ── Sentinels ───────────────────────────────────────────────────────────────
/** Sentinel for `continue` — skip the current array element */
@@ -43,10 +66,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 ───────────────────────────────────────────────────────────────
@@ -59,7 +85,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;
@@ -100,7 +126,11 @@ 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;
}
@@ -120,13 +150,61 @@ 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;
+ } = {},
+): BridgeRuntimeError {
+ if (err instanceof BridgeRuntimeError) {
+ if (err.bridgeLoc || !options.bridgeLoc) {
+ return err;
+ }
+
+ return new BridgeRuntimeError(err.message, {
+ bridgeLoc: options.bridgeLoc,
+ cause: err.cause ?? err,
+ });
+ }
+
+ return new BridgeRuntimeError(errorMessage(err), {
+ bridgeLoc: options.bridgeLoc,
+ cause: err,
+ });
+}
+
+export function attachBridgeErrorMetadata(
+ err: T,
+ options: {
+ bridgeLoc?: SourceLocation;
+ } = {},
+): T {
+ if (!err || (typeof err !== "object" && typeof err !== "function")) {
+ return err;
+ }
+
+ const carrier = err as BridgeErrorMetadataCarrier;
+ if (carrier.bridgeLoc === undefined) {
+ carrier.bridgeLoc = options.bridgeLoc;
+ }
+ return 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/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) {
diff --git a/packages/bridge-core/src/types.ts b/packages/bridge-core/src/types.ts
index 0485e82b..5bd7fdcc 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.
*
@@ -41,6 +43,7 @@ export interface WireFallback {
ref?: NodeRef;
value?: string;
control?: ControlFlowInstruction;
+ loc?: SourceLocation;
}
/**
@@ -58,24 +61,32 @@ 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;
}
- | { value: string; to: NodeRef }
+ | { 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[];
+ catchLoc?: SourceLocation;
catchFallback?: string;
catchFallbackRef?: NodeRef;
catchControl?: ControlFlowInstruction;
@@ -90,7 +101,9 @@ export type Wire =
rightSafe?: true;
};
to: NodeRef;
+ loc?: SourceLocation;
fallbacks?: WireFallback[];
+ catchLoc?: SourceLocation;
catchFallback?: string;
catchFallbackRef?: NodeRef;
catchControl?: ControlFlowInstruction;
@@ -105,7 +118,9 @@ export type Wire =
rightSafe?: true;
};
to: NodeRef;
+ loc?: SourceLocation;
fallbacks?: WireFallback[];
+ catchLoc?: SourceLocation;
catchFallback?: string;
catchFallbackRef?: NodeRef;
catchControl?: ControlFlowInstruction;
@@ -246,6 +261,7 @@ export type {
ToolMap,
ToolMetadata,
CacheStore,
+ SourceLocation,
} from "@stackables/bridge-types";
/**
@@ -308,6 +324,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..fd1f1cba 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,36 @@ 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, {
+ source: activeDoc.source,
+ filename: activeDoc.filename,
+ }),
+ { cause: err },
+ );
}
- return data;
}
const standalonePrecomputed = precomputeStandalone();
@@ -349,6 +360,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 +408,52 @@ 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, {
+ source: source.source,
+ filename: source.filename,
+ }),
+ { 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, {
+ source: result.source,
+ filename: result.filename,
+ }),
+ { 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, {
+ source: source.source,
+ filename: source.filename,
+ }),
+ { cause: err },
+ );
+ }
}
}
@@ -415,9 +461,20 @@ 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, {
+ source: source.source,
+ filename: source.filename,
+ }),
+ { 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 7604a763..83398ab2 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,
@@ -1071,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);
@@ -1337,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 {
@@ -1385,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
@@ -1426,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);
@@ -1452,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);
@@ -1470,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,
+ };
}
// ═══════════════════════════════════════════════════════════════════════════
@@ -1501,6 +1516,68 @@ function line(token: IToken | undefined): number {
return token?.startLine ?? 0;
}
+function makeLoc(
+ start: IToken | undefined,
+ end: IToken | undefined = start,
+): SourceLocation | undefined {
+ if (!start) return undefined;
+ const last = end ?? start;
+ return {
+ startLine: start.startLine ?? 0,
+ startColumn: start.startColumn ?? 0,
+ endLine: last.endLine ?? last.startLine ?? 0,
+ endColumn: last.endColumn ?? last.startColumn ?? 0,
+ };
+}
+
+type CoalesceAltResult =
+ | { literal: string }
+ | { sourceRef: NodeRef }
+ | { control: ControlFlowInstruction };
+
+function 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;
@@ -1615,6 +1692,35 @@ function findFirstToken(node: CstNode): IToken | undefined {
return undefined;
}
+function findLastToken(node: CstNode): IToken | undefined {
+ const tokens: IToken[] = [];
+ collectTokens(node, tokens);
+ if (tokens.length === 0) return undefined;
+ tokens.sort((left, right) => left.startOffset - right.startOffset);
+ return tokens[tokens.length - 1];
+}
+
+function locFromNode(node: CstNode | undefined): SourceLocation | undefined {
+ if (!node) return undefined;
+ return makeLoc(findFirstToken(node), findLastToken(node));
+}
+
+function locFromNodeRange(
+ startNode: CstNode | undefined,
+ endNode: CstNode | undefined = startNode,
+): SourceLocation | undefined {
+ if (!startNode) return undefined;
+ return makeLoc(
+ findFirstToken(startNode),
+ findLastToken(endNode ?? startNode),
+ );
+}
+
+function withLoc(wire: T, loc: SourceLocation | undefined): T {
+ if (!loc) return wire;
+ return { ...wire, loc } as T;
+}
+
/* ── parsePath: split "a.b[0].c" → ["a","b","0","c"] ── */
function parsePath(text: string): string[] {
return text.split(/\.|\[|\]/).filter(Boolean);
@@ -1776,12 +1882,15 @@ function processElementLines(
lineNum: number,
iterScope?: string | string[],
safe?: boolean,
+ loc?: SourceLocation,
) => NodeRef,
extractTernaryBranchFn: (
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[],
@@ -1799,17 +1908,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 +1976,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 +1984,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 = (
@@ -1907,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 } : {}),
};
@@ -1946,16 +2062,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 +2130,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(
@@ -2071,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;
@@ -2084,7 +2217,6 @@ function processElementLines(
elemSafe || undefined,
);
if (elemExprOps.length > 0 && desugarExprChain) {
- const elemExprRights = subs(elemLine, "elemExprRight");
elemCondRef = desugarExprChain(
parenRef,
elemExprOps,
@@ -2092,6 +2224,7 @@ function processElementLines(
elemLineNum,
iterNames,
elemSafe || undefined,
+ elemLineLoc,
);
} else {
elemCondRef = parenRef;
@@ -2099,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
@@ -2117,6 +2249,7 @@ function processElementLines(
elemLineNum,
iterNames,
elemSafe || undefined,
+ elemLineLoc,
);
elemCondIsPipeFork = true;
} else if (elemPipeSegs.length === 0) {
@@ -2146,6 +2279,7 @@ function processElementLines(
elemCondRef,
elemLineNum,
elemSafe || undefined,
+ elemLineLoc,
);
elemCondIsPipeFork = true;
}
@@ -2208,24 +2342,32 @@ 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,
+ ...(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 }),
+ ...(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;
@@ -2243,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));
}
}
@@ -2257,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);
}
}
@@ -2277,11 +2416,14 @@ 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({ 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 +2441,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,27 +2514,33 @@ function processElementScopeLines(
lineNum: number,
iterScope?: string | string[],
safe?: boolean,
+ loc?: SourceLocation,
) => NodeRef,
extractTernaryBranchFn?: (
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,
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 +2595,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];
@@ -2542,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) {
@@ -2577,15 +2731,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);
@@ -2610,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) {
@@ -2618,9 +2787,9 @@ function processElementScopeLines(
scopeLineNum,
iterNames,
scopeSafe || undefined,
+ scopeLineLoc,
);
if (exprOps.length > 0 && desugarExprChain) {
- const exprRights = subs(scopeLine, "scopeExprRight");
condRef = desugarExprChain(
parenRef,
exprOps,
@@ -2628,13 +2797,13 @@ function processElementScopeLines(
scopeLineNum,
iterNames,
scopeSafe || undefined,
+ scopeLineLoc,
);
} else {
condRef = parenRef;
}
condIsPipeFork = true;
} else if (exprOps.length > 0 && desugarExprChain) {
- const exprRights = subs(scopeLine, "scopeExprRight");
let leftRef: NodeRef;
const directIterRef =
scopePipeSegs.length === 0
@@ -2652,6 +2821,7 @@ function processElementScopeLines(
scopeLineNum,
iterNames,
scopeSafe || undefined,
+ scopeLineLoc,
);
condIsPipeFork = true;
} else if (scopePipeSegs.length === 0) {
@@ -2702,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 } : {}),
@@ -2758,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));
}
}
@@ -2771,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);
}
}
@@ -2788,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 });
@@ -3645,6 +3818,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 +3834,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 +3885,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);
@@ -3733,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;
@@ -3742,9 +3931,9 @@ function buildBridgeBody(
lineNum,
iterNames,
isSafe,
+ wireLoc,
);
if (exprOps.length > 0) {
- const exprRights = subs(wireNode, "exprRight");
condRef = desugarExprChain(
parenRef,
exprOps,
@@ -3752,13 +3941,13 @@ function buildBridgeBody(
lineNum,
iterNames,
isSafe,
+ wireLoc,
);
} else {
condRef = parenRef;
}
condIsPipeFork = true;
} else if (exprOps.length > 0) {
- const exprRights = subs(wireNode, "exprRight");
const leftRef = buildSourceExpr(firstSourceNode!, lineNum, iterNames);
condRef = desugarExprChain(
leftRef,
@@ -3767,6 +3956,7 @@ function buildBridgeBody(
lineNum,
iterNames,
isSafe,
+ wireLoc,
);
condIsPipeFork = true;
} else {
@@ -3779,7 +3969,7 @@ function buildBridgeBody(
}
if (wc.notPrefix) {
- condRef = desugarNot(condRef, lineNum, isSafe);
+ condRef = desugarNot(condRef, lineNum, isSafe, wireLoc);
condIsPipeFork = true;
}
@@ -3827,20 +4017,28 @@ 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,
+ ...(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 } : {}),
+ ...(catchFallback !== undefined ? { catchFallback } : {}),
+ ...(catchFallbackRef !== undefined ? { catchFallbackRef } : {}),
+ ...(catchControl ? { catchControl } : {}),
+ to: toRef,
+ },
+ wireLoc,
+ ),
+ );
wires.push(...fallbackInternalWires);
wires.push(...catchFallbackInternalWires);
continue;
@@ -3888,15 +4086,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 +4112,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 +4201,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 +4238,7 @@ function buildBridgeBody(
segs: TemplateSeg[],
lineNum: number,
iterScope?: string | string[],
+ loc?: SourceLocation,
): NodeRef {
const forkInstance = 100000 + nextForkSeq++;
const forkModule = SELF_MODULE;
@@ -4055,7 +4265,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 +4275,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 +4345,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 };
@@ -4158,34 +4380,57 @@ 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),
+ 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 } = extractAddressPath(addrNode);
+ const { root, segments, rootSafe, segmentSafe } =
+ extractAddressPath(addrNode);
const iterRef = resolveIterRef(root, segments, iterScope);
if (iterRef) {
return {
kind: "ref",
- ref: iterRef,
+ loc: branchLoc,
+ 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",
+ loc: branchLoc,
+ ref: {
+ ...ref,
+ ...(rootSafe ? { rootSafe: true } : {}),
+ ...(segmentSafe ? { pathSafe: segmentSafe } : {}),
+ },
+ };
}
throw new Error(`Line ${lineNum}: Invalid ternary branch`);
}
@@ -4261,7 +4506,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 +4546,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 +4567,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 +4604,7 @@ function buildBridgeBody(
lineNum,
iterScope,
innerSafe,
+ loc,
);
} else {
resultRef = innerRef;
@@ -4354,7 +4612,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 +4635,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 +4707,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 +4721,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 +4786,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 +4872,7 @@ function buildBridgeBody(
sourceRef: NodeRef,
_lineNum: number,
safe?: boolean,
+ loc?: SourceLocation,
): NodeRef {
const forkInstance = 100000 + nextForkSeq++;
const forkTrunkModule = SELF_MODULE;
@@ -4601,18 +4891,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 +4930,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 +4971,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 +4991,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 +5013,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 +5067,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);
@@ -4781,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;
@@ -4801,7 +5126,6 @@ function buildBridgeBody(
scopeBlockSafe || undefined,
);
if (exprOps.length > 0) {
- const exprRights = subs(scopeLine, "scopeExprRight");
condRef = desugarExprChain(
parenRef,
exprOps,
@@ -4809,13 +5133,13 @@ function buildBridgeBody(
scopeLineNum,
undefined,
scopeBlockSafe || undefined,
+ scopeLineLoc,
);
} else {
condRef = parenRef;
}
condIsPipeFork = true;
} else if (exprOps.length > 0) {
- const exprRights = subs(scopeLine, "scopeExprRight");
const leftRef = buildSourceExpr(firstSourceNode!, scopeLineNum);
condRef = desugarExprChain(
leftRef,
@@ -4824,6 +5148,7 @@ function buildBridgeBody(
scopeLineNum,
undefined,
scopeBlockSafe || undefined,
+ scopeLineLoc,
);
condIsPipeFork = true;
} else {
@@ -4841,6 +5166,7 @@ function buildBridgeBody(
condRef,
scopeLineNum,
scopeBlockSafe || undefined,
+ scopeLineLoc,
);
condIsPipeFork = true;
}
@@ -4885,20 +5211,28 @@ 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,
+ ...(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 } : {}),
+ ...(catchFallback !== undefined ? { catchFallback } : {}),
+ ...(catchFallbackRef !== undefined ? { catchFallbackRef } : {}),
+ ...(catchControl ? { catchControl } : {}),
+ to: toRef,
+ },
+ scopeLineLoc,
+ ),
+ );
wires.push(...fallbackInternalWires);
wires.push(...catchFallbackInternalWires);
continue;
@@ -4950,7 +5284,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 +5305,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)) {
@@ -4985,15 +5322,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;
@@ -5002,17 +5336,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 }
@@ -5022,6 +5357,7 @@ function buildBridgeBody(
// ── Compute the source ref ──
let sourceRef: NodeRef;
+ let sourceLoc: SourceLocation | undefined;
let aliasSafe: boolean | undefined;
const aliasStringToken = (
@@ -5034,8 +5370,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,10 +5384,14 @@ function buildBridgeBody(
stringExprOps,
stringExprRights,
lineNum,
+ undefined,
+ undefined,
+ aliasLoc,
);
} 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
@@ -5067,17 +5412,25 @@ 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,
+ ...(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 }),
+ ...modifierAttrs,
+ to: ternaryToRef,
+ },
+ aliasLoc,
+ ),
+ );
wires.push(...aliasFallbackInternalWires);
wires.push(...aliasCatchFallbackInternalWires);
continue;
@@ -5094,6 +5447,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) {
@@ -5104,7 +5464,6 @@ function buildBridgeBody(
isSafe,
);
if (exprOps.length > 0) {
- const exprRights = subs(nodeAliasNode, "aliasExprRight");
condRef = desugarExprChain(
parenRef,
exprOps,
@@ -5112,12 +5471,12 @@ function buildBridgeBody(
lineNum,
undefined,
isSafe,
+ aliasLoc,
);
} else {
condRef = parenRef;
}
} else if (exprOps.length > 0) {
- const exprRights = subs(nodeAliasNode, "aliasExprRight");
const leftRef = buildSourceExpr(firstSourceNode!, lineNum);
condRef = desugarExprChain(
leftRef,
@@ -5126,6 +5485,7 @@ function buildBridgeBody(
lineNum,
undefined,
isSafe,
+ aliasLoc,
);
} else {
const result = buildSourceExprSafe(firstSourceNode!, lineNum);
@@ -5137,7 +5497,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 +5520,25 @@ 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,
+ ...(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 }),
+ ...modifierAttrs,
+ to: ternaryToRef,
+ },
+ aliasLoc,
+ ),
+ );
wires.push(...aliasFallbackInternalWires);
wires.push(...aliasCatchFallbackInternalWires);
continue;
@@ -5197,9 +5565,12 @@ function buildBridgeBody(
};
const aliasAttrs = {
...(aliasSafe ? { safe: true as const } : {}),
+ ...(sourceLoc ? { fromLoc: sourceLoc } : {}),
...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 +5589,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 +5601,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 +5635,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 +5656,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;
@@ -5308,15 +5690,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;
@@ -5325,18 +5704,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 } : {}),
@@ -5344,11 +5724,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 +5751,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)
@@ -5374,15 +5770,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;
@@ -5391,24 +5784,27 @@ 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 }
: {}),
...(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);
@@ -5461,6 +5857,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;
@@ -5472,9 +5873,9 @@ function buildBridgeBody(
lineNum,
undefined,
isSafe,
+ wireLoc,
);
if (exprOps.length > 0) {
- const exprRights = subs(wireNode, "exprRight");
condRef = desugarExprChain(
parenRef,
exprOps,
@@ -5482,6 +5883,7 @@ function buildBridgeBody(
lineNum,
undefined,
isSafe,
+ wireLoc,
);
} else {
condRef = parenRef;
@@ -5489,7 +5891,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,
@@ -5498,6 +5899,7 @@ function buildBridgeBody(
lineNum,
undefined,
isSafe,
+ wireLoc,
);
condIsPipeFork = true;
} else {
@@ -5511,7 +5913,7 @@ function buildBridgeBody(
// ── Apply `not` prefix if present ──
if (wc.notPrefix) {
- condRef = desugarNot(condRef, lineNum, isSafe);
+ condRef = desugarNot(condRef, lineNum, isSafe, wireLoc);
condIsPipeFork = true;
}
@@ -5533,17 +5935,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;
@@ -5552,30 +5951,39 @@ 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);
}
}
- 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,
+ ...(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 }),
+ ...(fallbacks.length > 0 ? { fallbacks } : {}),
+ ...(catchLoc ? { catchLoc } : {}),
+ ...(catchFallback !== undefined ? { catchFallback } : {}),
+ ...(catchFallbackRef !== undefined ? { catchFallbackRef } : {}),
+ ...(catchControl ? { catchControl } : {}),
+ to: toRef,
+ },
+ wireLoc,
+ ),
+ );
wires.push(...fallbackInternalWires);
wires.push(...catchFallbackInternalWires);
continue;
@@ -5595,18 +6003,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;
@@ -5615,12 +6024,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);
}
}
@@ -5628,13 +6037,15 @@ 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 } : {}),
};
- 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..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,10 +27,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" },
- }]);
+ assertDeepStrictEqualIgnoringLoc(pullWire.fallbacks, [
+ {
+ type: "falsy",
+ control: { kind: "throw", message: "name is required" },
+ },
+ ]);
});
test("panic on ?? gate", () => {
@@ -45,10 +48,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" },
- }]);
+ assertDeepStrictEqualIgnoringLoc(pullWire.fallbacks, [
+ {
+ type: "nullish",
+ control: { kind: "panic", message: "fatal: name cannot be null" },
+ },
+ ]);
});
test("continue on ?? gate", () => {
@@ -69,7 +74,9 @@ bridge Query.test {
w.to.path.join(".") === "items.name",
);
assert.ok(elemWire);
- assert.deepStrictEqual(elemWire.fallbacks, [{ type: "nullish", control: { kind: "continue" } }]);
+ assertDeepStrictEqualIgnoringLoc(elemWire.fallbacks, [
+ { type: "nullish", control: { kind: "continue" } },
+ ]);
});
test("break on ?? gate", () => {
@@ -90,7 +97,9 @@ bridge Query.test {
w.to.path.join(".") === "items.name",
);
assert.ok(elemWire);
- assert.deepStrictEqual(elemWire.fallbacks, [{ type: "nullish", control: { kind: "break" } }]);
+ assertDeepStrictEqualIgnoringLoc(elemWire.fallbacks, [
+ { type: "nullish", control: { kind: "break" } },
+ ]);
});
test("break/continue with levels on ?? gate", () => {
@@ -120,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 } },
]);
});
@@ -195,10 +204,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" },
- }]);
+ assertDeepStrictEqualIgnoringLoc(pullWire.fallbacks, [
+ {
+ type: "falsy",
+ control: { kind: "throw", message: "name is required" },
+ },
+ ]);
});
test("panic on ?? gate round-trips", () => {
@@ -221,10 +232,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" },
- }]);
+ assertDeepStrictEqualIgnoringLoc(pullWire.fallbacks, [
+ {
+ type: "nullish",
+ control: { kind: "panic", message: "fatal" },
+ },
+ ]);
});
test("continue on ?? gate round-trips", () => {
@@ -252,7 +265,9 @@ bridge Query.test {
w.to.path.join(".") === "items.name",
);
assert.ok(elemWire);
- assert.deepStrictEqual(elemWire.fallbacks, [{ type: "nullish", control: { kind: "continue" } }]);
+ assertDeepStrictEqualIgnoringLoc(elemWire.fallbacks, [
+ { type: "nullish", control: { kind: "continue" } },
+ ]);
});
test("break on catch gate round-trips", () => {
@@ -314,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 } },
]);
});
@@ -563,7 +578,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 +592,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 +614,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 +628,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..118c68a0
--- /dev/null
+++ b/packages/bridge/test/parse-test-utils.ts
@@ -0,0 +1,33 @@
+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" ||
+ key.endsWith("Loc") ||
+ key === "source" ||
+ key === "filename"
+ ) {
+ 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..3e7aa8e0 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" },
});
});
@@ -1102,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/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/runtime-error-format.test.ts b/packages/bridge/test/runtime-error-format.test.ts
new file mode 100644
index 00000000..c87d0656
--- /dev/null
+++ b/packages/bridge/test/runtime-error-format.test.ts
@@ -0,0 +1,389 @@
+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
+}`;
+
+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"
+}`;
+
+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,
+ ...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", {
+ bridgeLoc: {
+ startLine: 1,
+ startColumn: 14,
+ endLine: 1,
+ endColumn: 32,
+ },
+ }),
+ {
+ source: sourceLine,
+ filename: "playground.bridge",
+ },
+ );
+
+ 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("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("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 {
+ 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/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);
diff --git a/packages/bridge/test/source-locations.test.ts b/packages/bridge/test/source-locations.test.ts
new file mode 100644
index 00000000..cc820944
--- /dev/null
+++ b/packages/bridge/test/source-locations.test.ts
@@ -0,0 +1,109 @@
+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);
+ 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", () => {
+ 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);
+ });
+
+ 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..83c1eb20 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}`;
});
@@ -255,7 +261,10 @@ export async function runBridge(
} catch (err: unknown) {
return {
errors: [
- `Execution error: ${err instanceof Error ? err.message : String(err)}`,
+ formatBridgeError(err, {
+ source: document.source,
+ filename: document.filename,
+ }),
],
};
} finally {
@@ -273,7 +282,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 +322,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 +393,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 +543,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 {
@@ -647,7 +664,10 @@ export async function runBridgeStandalone(
} catch (err: unknown) {
return {
errors: [
- `Execution error: ${err instanceof Error ? err.message : String(err)}`,
+ formatBridgeError(err, {
+ source: document.source,
+ filename: document.filename,
+ }),
],
};
} finally {