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 {