-
Notifications
You must be signed in to change notification settings - Fork 0
Description
Goal
When a runtime error occurs (bad path traversal, missing field, etc.), developers see:
Bridge Execution Error: Cannot read properties of undefined (reading 'name')
--> src/catalog.bridge:14:5
|
13 | o.items <- catalog.results[] as item {
14 | .name <- item.details.name ?? panic "Missing name"
| ^^^^^^^^^^^^^^^^^^^^^^^^^^
15 | .price <- item.price
instead of an engine-internal stack trace pointing into resolveWires.ts.
Current State
- Every Chevrotain
ITokenalready carriesstartLine,endLine,startColumn,endColumn— used for diagnostics but discarded when buildingWireobjects. BridgeParseResultalready hasstartLines: Map<Instruction, number>— top-level line tracking is a proven pattern.resolveWiresAsync()has acatch (err)block where the wirewis in scope — clean insertion point.codegen.tsalready emits try/catch blocks in three modes — baking in loc coordinates is a template-string change.
Dependency Order
Phase 1 (parser + types)
↓
Phase 2 (interpreter) ←── depends on Phase 1 locs on Wire
↓
Phase 3 (AOT compiler) ←── independent of Phase 2, parallel after Phase 1
↓
Phase 4 (source text threading) ←── enhances 2 & 3, fully additive
Phase 1 — AST Location Tracking
Files: bridge-types/src/index.ts, bridge-core/src/types.ts, bridge-parser/src/parser/parser.ts
1.1 Add SourceLocation to bridge-types
// packages/bridge-types/src/index.ts
export type SourceLocation = {
startLine: number; // 1-based
startColumn: number; // 1-based
endLine: number;
endColumn: number;
};Place in bridge-types — the root of the dependency chain — so all packages can import it without direction violations.
1.2 Add loc? to every Wire variant
In bridge-core/src/types.ts, add loc?: SourceLocation as an optional field to all five Wire union arms (including the { value: string; to: NodeRef } constant arm for completeness).
Non-breaking: all downstream code narrows on "from" in w, "value" in w, etc. — type narrowing is unaffected.
1.3 Update toBridgeAst() visitor
In bridge-parser/src/parser/parser.ts, populate loc at each Wire construction site. The tools are already present:
function line(token: IToken | undefined): number { return token?.startLine ?? 0; }
function findFirstToken(node: CstNode): IToken | undefined { ... }Construction sites to update (search for Wire object literals in the visitor):
| Site | Tokens to use |
|---|---|
processElementLines() — pull wires |
elemLineNum already computed; extract full loc from findFirstToken / last token of the line |
Constant wires (= "value") |
= token position |
Conditional wires (cond/ternary) |
span from condition token to end |
condAnd/condOr wires |
span of &&/|| expression |
Fallback wires (||, ?? gates) |
position of the operator token |
Strategy: extract a small helper function makeLoc(start: IToken, end?: IToken): SourceLocation to avoid repetition.
1.4 Tests — packages/bridge-parser/test/source-locations.test.ts (new file)
test("pull wire loc is populated", () => {
const { document } = parseBridge(`version 1.5\nbridge Query.test {\n .name <- user.name\n}`);
const b = document.instructions[0] as Bridge;
const wire = b.wires[0]!;
assert("loc" in wire && wire.loc !== undefined);
assert.equal(wire.loc!.startLine, 3);
assert.equal(wire.loc!.startColumn, 3);
});
test("constant wire loc is populated", () => { ... });
test("conditional wire loc is populated", () => { ... });Acceptance: parseBridge populates loc on all wire variants; existing parser tests unchanged.
Phase 2 — Interpreter Error Enrichment
Files: bridge-core/src/tree-types.ts, bridge-core/src/resolveWires.ts, bridge-core/src/ExecutionTree.ts
2.1 Add BridgeRuntimeError to tree-types.ts
export class BridgeRuntimeError extends Error {
/** Source location in the .bridge file */
bridgeLoc?: SourceLocation;
override cause?: unknown;
constructor(message: string, cause?: unknown) {
super(message);
this.name = "BridgeRuntimeError";
this.cause = cause;
}
}isFatalError() only guards BridgePanicError, BridgeAbortError, and BridgeTimeoutError — no change required. BridgeRuntimeError must not be considered fatal.
2.2 Enrich errors in resolveWiresAsync()
At the Layer 3 catch gate in resolveWires.ts:
} catch (err: unknown) {
if (isFatalError(err)) throw err;
// Attach source location — innermost wire wins
if (err instanceof Error && !("bridgeLoc" in err) && "loc" in w && w.loc != null) {
const rte = new BridgeRuntimeError(err.message, err);
rte.bridgeLoc = w.loc;
rte.stack = err.stack;
lastError = rte;
} else {
lastError = err;
}
const recoveredValue = await applyCatchGate(ctx, w, pullChain);
if (recoveredValue !== undefined) return recoveredValue;
}The !("bridgeLoc" in err) guard ensures the innermost wire location is preserved, not overwritten by outer wires.
2.3 Enrich applyPath() errors in ExecutionTree.ts
applyPath receives a NodeRef. Pass the wire's loc as an extra optional argument:
private applyPath(resolved: any, ref: NodeRef, loc?: SourceLocation): any {
...
throw Object.assign(
new TypeError(`Cannot read properties of ${result} (reading '${ref.path[0]}')`),
{ bridgeLoc: loc }
);
}All call-sites update their applyPath(resolved, ref) calls to applyPath(resolved, ref, w.loc).
2.4 Add formatBridgeError() — bridge-core/src/formatBridgeError.ts (new file)
import type { BridgeRuntimeError } from "./tree-types.ts";
export function formatBridgeError(
err: BridgeRuntimeError,
source?: string, // raw .bridge source text (optional — enables snippet lines)
filename?: string, // e.g. "catalog.bridge"
): string {
const loc = err.bridgeLoc;
if (!loc) return err.message;
const header = `Bridge Execution Error: ${err.message}`;
const pointer = ` --> ${filename ?? "<bridge>"}:${loc.startLine}:${loc.startColumn}`;
if (!source) return `${header}\n${pointer}`;
// Render Rust-style snippet: one line before, the offending line, caret underline, one line after
const lines = source.split("\n");
const lineIdx = loc.startLine - 1; // 0-based
const width = String(loc.endLine).length;
const pad = (n: number | string) => String(n).padStart(width);
const bar = " ".repeat(width) + " |";
const snippet = [
bar,
lineIdx > 0 ? `${pad(loc.startLine - 1)} | ${lines[lineIdx - 1] ?? ""}` : null,
`${pad(loc.startLine)} | ${lines[lineIdx] ?? ""}`,
`${bar} ${" ".repeat(loc.startColumn - 1)}${"^".repeat(Math.max(1, loc.endColumn - loc.startColumn))}`,
lineIdx + 1 < lines.length ? `${pad(loc.startLine + 1)} | ${lines[lineIdx + 1] ?? ""}` : null,
]
.filter(Boolean)
.join("\n");
return `${header}\n${pointer}\n${snippet}`;
}Export from bridge-core's public surface (src/index.ts).
2.5 Tests — packages/bridge-core/test/source-mapping.test.ts (new file)
test("path traversal error carries bridgeLoc", async () => {
// Build a Bridge with a wire that will fail at runtime
// Execute via ExecutionTree
// Catch the error and assert err instanceof BridgeRuntimeError
// assert err.bridgeLoc.startLine === expected line
});
test("formatBridgeError renders snippet", () => {
const err = new BridgeRuntimeError("...");
err.bridgeLoc = { startLine: 3, startColumn: 3, endLine: 3, endColumn: 15 };
const out = formatBridgeError(err, "version 1.5\nbridge Q.t {\n .name <- x.y\n}", "test.bridge");
assert(out.includes("--> test.bridge:3:3"));
assert(out.includes("^^^"));
});Phase 3 — AOT Compiler Support
File: bridge-compiler/src/codegen.ts
3.1 Inject loc into emitted try/catch blocks
In emitToolCall() and equivalent wire-emission functions, when w.loc is present, add a catch clause that stamps bridgeLoc before rethrowing. The !_e?.bridgeLoc guard preserves the innermost location.
Pattern to emit:
try {
_t1 = await __call(tools["httpCall"], { ... }, "httpCall");
} catch (_e) {
if (!_e?.bridgeLoc) _e.bridgeLoc = { startLine: 14, startColumn: 5, endLine: 14, endColumn: 30 };
throw _e;
}The three existing try/catch modes:
| Mode | Action |
|---|---|
onError ToolDef-level |
Inject loc stamp before the catch body |
catch-guarded (bridge wire) |
Inject after the fatal-error rethrow check |
fire-and-forget (catch null) |
Skip — errors are intentionally swallowed |
3.2 Tests — packages/bridge-compiler/test/source-mapping.test.ts (new file)
test("compiled bridge stamps bridgeLoc on path error", async () => {
// Compile a bridge with a wire that will fail (bad path)
// Execute compiled output
// Assert error.bridgeLoc matches wire's source position
});Phase 4 — Source Text Threading (snippet display)
This phase unlocks the full "After" experience with the pretty Rust-style snippet.
4.1 Add source?: string to BridgeExecutionOptions
The options object is already passed to ExecutionTree. Adding an optional field is non-breaking.
export type BridgeExecutionOptions = {
// ...existing fields...
/** Raw source text of the .bridge file — enables source-snippet error formatting */
source?: string;
/** Filename for error display (e.g. "catalog.bridge") */
filename?: string;
};4.2 GraphQL driver integration (bridge-graphql)
When BridgeRuntimeError is caught during field resolution:
- Call
formatBridgeError(err, options.source, options.filename). - Include formatted message in GraphQL
extensions.runtimeErrorresponse.
4.3 CLI / without-graphql integration
When catching errors in user-facing entry points, detect BridgeRuntimeError and call formatBridgeError() before printing.
Checklist
Phase 1
-
SourceLocationtype added tobridge-types -
loc?: SourceLocationadded to allWireunion arms inbridge-core/src/types.ts - Visitor updated — pull wires populated with loc
- Visitor updated — constant wires populated with loc
- Visitor updated — conditional/condAnd/condOr wires populated with loc
- Visitor updated — fallback wires (
||/??) populated with loc - Parser tests:
source-locations.test.tscovering all wire variants -
pnpm build+pnpm testgreen
Phase 2
-
BridgeRuntimeErroradded totree-types.ts, exported -
resolveWiresAsync()catch gate enriches non-fatal errors withbridgeLoc -
applyPath()threadslocand stamps thrown TypeErrors -
formatBridgeError()implemented and exported - Core tests:
source-mapping.test.ts -
pnpm build+pnpm testgreen
Phase 3
-
codegen.tsemits loc-stamping catch blocks foronErrorandcatch-guardedmodes -
fire-and-forgetmode correctly skipped - Compiler tests:
source-mapping.test.ts -
pnpm build+pnpm testgreen
Phase 4
-
source?/filename?added to execution options - GraphQL driver calls
formatBridgeError()and surfaces result - CLI /
without-graphqlexamples updated - End-to-end test with real
.bridgefile verifying snippet output - Changeset added (
pnpm changeset)
Non-goals
- JS source maps for compiled AOT output (
.bridge→.js//# sourceMappingURL) — out of scope. - Tracking locations inside tool implementations — the engine boundary is the
.bridgefile. - Performance regression —
locis an optional field; interpreter fast-path (resolveWiressingle-wire branch) is unaffected.