Skip to content

Source Mapping #107

@aarne

Description

@aarne

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 IToken already carries startLine, endLine, startColumn, endColumn — used for diagnostics but discarded when building Wire objects.
  • BridgeParseResult already has startLines: Map<Instruction, number> — top-level line tracking is a proven pattern.
  • resolveWiresAsync() has a catch (err) block where the wire w is in scope — clean insertion point.
  • codegen.ts already 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:

  1. Call formatBridgeError(err, options.source, options.filename).
  2. Include formatted message in GraphQL extensions.runtimeError response.

4.3 CLI / without-graphql integration

When catching errors in user-facing entry points, detect BridgeRuntimeError and call formatBridgeError() before printing.


Checklist

Phase 1

  • SourceLocation type added to bridge-types
  • loc?: SourceLocation added to all Wire union arms in bridge-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.ts covering all wire variants
  • pnpm build + pnpm test green

Phase 2

  • BridgeRuntimeError added to tree-types.ts, exported
  • resolveWiresAsync() catch gate enriches non-fatal errors with bridgeLoc
  • applyPath() threads loc and stamps thrown TypeErrors
  • formatBridgeError() implemented and exported
  • Core tests: source-mapping.test.ts
  • pnpm build + pnpm test green

Phase 3

  • codegen.ts emits loc-stamping catch blocks for onError and catch-guarded modes
  • fire-and-forget mode correctly skipped
  • Compiler tests: source-mapping.test.ts
  • pnpm build + pnpm test green

Phase 4

  • source? / filename? added to execution options
  • GraphQL driver calls formatBridgeError() and surfaces result
  • CLI / without-graphql examples updated
  • End-to-end test with real .bridge file 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 .bridge file.
  • Performance regression — loc is an optional field; interpreter fast-path (resolveWires single-wire branch) is unaffected.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions