From f1aefca1024b2cdeaf06a69c1bae413eeed63cfd Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 08:28:48 +0100 Subject: [PATCH 1/4] feat(bridge-core): add enumerateTraversalIds for bridge traversal path enumeration (#117) * Initial plan * feat(bridge-core): add enumerateTraversalIds function Enumerates all possible traversal paths through a Bridge. Each entry represents a unique code path determined by the wire structure (fallback chains, catch gates, array scopes, ternary branches). Useful for complexity assessment and future integration into the execution engine for monitoring. Co-authored-by: aarne <82001+aarne@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: aarne <82001+aarne@users.noreply.github.com> --- .../bridge-core/src/enumerate-traversals.ts | 202 +++++++++++ packages/bridge-core/src/index.ts | 5 + .../bridge/test/enumerate-traversals.test.ts | 322 ++++++++++++++++++ 3 files changed, 529 insertions(+) create mode 100644 packages/bridge-core/src/enumerate-traversals.ts create mode 100644 packages/bridge/test/enumerate-traversals.test.ts diff --git a/packages/bridge-core/src/enumerate-traversals.ts b/packages/bridge-core/src/enumerate-traversals.ts new file mode 100644 index 00000000..fcbe9fe4 --- /dev/null +++ b/packages/bridge-core/src/enumerate-traversals.ts @@ -0,0 +1,202 @@ +/** + * Enumerate all possible traversal paths through a Bridge. + * + * Every bridge has a finite set of execution paths ("traversals"), + * determined by the wire structure alone — independent of runtime values. + * + * Examples: + * `o <- i.a || i.b catch i.c` → 3 traversals (primary, fallback, catch) + * `o <- i.arr[] as a { .data <- a.a ?? a.b }` → 3 traversals + * (empty-array, primary for .data, nullish fallback for .data) + * + * Used for complexity assessment and will integrate into the execution + * engine for monitoring. + */ + +import type { Bridge, Wire, WireFallback } from "./types.ts"; + +// ── Public types ──────────────────────────────────────────────────────────── + +/** + * A single traversal path through a bridge wire. + */ +export interface TraversalEntry { + /** Stable identifier for this traversal path. */ + id: string; + /** Index of the originating wire in `bridge.wires` (-1 for synthetic entries like empty-array). */ + wireIndex: number; + /** Target path segments from the wire's `to` NodeRef. */ + target: string[]; + /** Classification of this traversal path. */ + kind: + | "primary" + | "fallback" + | "catch" + | "empty-array" + | "then" + | "else" + | "const"; + /** Fallback chain index (only when kind is `"fallback"`). */ + fallbackIndex?: number; + /** Gate type (only when kind is `"fallback"`): `"falsy"` for `||`, `"nullish"` for `??`. */ + gateType?: "falsy" | "nullish"; +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function pathKey(path: string[]): string { + return path.length > 0 ? path.join(".") : "*"; +} + +function hasCatch(w: Wire): boolean { + if ("value" in w) return false; + return ( + w.catchFallback != null || + w.catchFallbackRef != null || + w.catchControl != null + ); +} + +/** + * True when the wire is an array-source wire that simply feeds an array + * iteration scope without any fallback/catch choices of its own. + * + * Such wires always execute (to fetch the array), so they are not a + * traversal "choice". The separate `empty-array` entry already covers + * the "no elements" outcome. + */ +function isPlainArraySourceWire( + w: Wire, + arrayIterators: Record | undefined, +): boolean { + if (!arrayIterators) return false; + if (!("from" in w)) return false; + if (w.from.element) return false; + const targetPath = w.to.path.join("."); + if (!(targetPath in arrayIterators)) return false; + return !w.fallbacks?.length && !hasCatch(w); +} + +function addFallbackEntries( + entries: TraversalEntry[], + base: string, + wireIndex: number, + target: string[], + fallbacks: WireFallback[] | undefined, +): void { + if (!fallbacks) return; + for (let i = 0; i < fallbacks.length; i++) { + entries.push({ + id: `${base}/fallback:${i}`, + wireIndex, + target, + kind: "fallback", + fallbackIndex: i, + gateType: fallbacks[i].type, + }); + } +} + +function addCatchEntry( + entries: TraversalEntry[], + base: string, + wireIndex: number, + target: string[], + w: Wire, +): void { + if (hasCatch(w)) { + entries.push({ id: `${base}/catch`, wireIndex, target, kind: "catch" }); + } +} + +// ── Main function ─────────────────────────────────────────────────────────── + +/** + * Enumerate every possible traversal path through a bridge. + * + * Returns a flat list of {@link TraversalEntry} objects, one per + * unique code-path through the bridge's wires. The total length + * of the returned array is a useful proxy for bridge complexity. + */ +export function enumerateTraversalIds(bridge: Bridge): TraversalEntry[] { + const entries: TraversalEntry[] = []; + + // Track per-target occurrence counts for disambiguation when + // multiple wires write to the same target (overdefinition). + const targetCounts = new Map(); + + for (let i = 0; i < bridge.wires.length; i++) { + const w = bridge.wires[i]; + const target = w.to.path; + const tKey = pathKey(target); + + // Disambiguate overdefined targets (same target written by >1 wire). + const seen = targetCounts.get(tKey) ?? 0; + targetCounts.set(tKey, seen + 1); + const base = seen > 0 ? `${tKey}#${seen}` : tKey; + + // ── Constant wire ─────────────────────────────────────────────── + if ("value" in w) { + entries.push({ id: `${base}/const`, wireIndex: i, target, kind: "const" }); + continue; + } + + // ── Pull wire ─────────────────────────────────────────────────── + if ("from" in w) { + // Skip plain array source wires — they always execute and the + // separate "empty-array" entry covers the "no elements" path. + if (!isPlainArraySourceWire(w, bridge.arrayIterators)) { + entries.push({ + id: `${base}/primary`, + wireIndex: i, + target, + kind: "primary", + }); + addFallbackEntries(entries, base, i, target, w.fallbacks); + addCatchEntry(entries, base, i, target, w); + } + continue; + } + + // ── Conditional (ternary) wire ────────────────────────────────── + if ("cond" in w) { + entries.push({ id: `${base}/then`, wireIndex: i, target, kind: "then" }); + entries.push({ id: `${base}/else`, wireIndex: i, target, kind: "else" }); + addFallbackEntries(entries, base, i, target, w.fallbacks); + addCatchEntry(entries, base, i, target, w); + continue; + } + + // ── condAnd / condOr (logical binary) ─────────────────────────── + entries.push({ + id: `${base}/primary`, + wireIndex: i, + target, + kind: "primary", + }); + if ("condAnd" in w) { + addFallbackEntries(entries, base, i, target, w.fallbacks); + addCatchEntry(entries, base, i, target, w); + } else { + // condOr + const wo = w as Extract; + addFallbackEntries(entries, base, i, target, wo.fallbacks); + addCatchEntry(entries, base, i, target, w); + } + } + + // ── Array iterators — each scope adds an "empty-array" path ───── + if (bridge.arrayIterators) { + for (const key of Object.keys(bridge.arrayIterators)) { + const id = key ? `${key}/empty-array` : "*/empty-array"; + entries.push({ + id, + wireIndex: -1, + target: key ? key.split(".") : [], + kind: "empty-array", + }); + } + } + + return entries; +} diff --git a/packages/bridge-core/src/index.ts b/packages/bridge-core/src/index.ts index db41eb9a..6753dc58 100644 --- a/packages/bridge-core/src/index.ts +++ b/packages/bridge-core/src/index.ts @@ -82,6 +82,11 @@ export type { WireFallback, } from "./types.ts"; +// ── Traversal enumeration ─────────────────────────────────────────────────── + +export { enumerateTraversalIds } from "./enumerate-traversals.ts"; +export type { TraversalEntry } from "./enumerate-traversals.ts"; + // ── Utilities ─────────────────────────────────────────────────────────────── export { parsePath } from "./utils.ts"; diff --git a/packages/bridge/test/enumerate-traversals.test.ts b/packages/bridge/test/enumerate-traversals.test.ts new file mode 100644 index 00000000..4885029f --- /dev/null +++ b/packages/bridge/test/enumerate-traversals.test.ts @@ -0,0 +1,322 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { parseBridge } from "@stackables/bridge-parser"; +import { enumerateTraversalIds } from "@stackables/bridge-core"; +import type { Bridge, TraversalEntry } from "@stackables/bridge-core"; + +function getBridge(source: string): Bridge { + const doc = parseBridge(source); + const bridge = doc.instructions.find( + (i): i is Bridge => i.kind === "bridge", + ); + assert.ok(bridge, "expected a bridge instruction"); + return bridge; +} + +function ids(entries: TraversalEntry[]): string[] { + return entries.map((e) => e.id); +} + +// ── Simple wires ──────────────────────────────────────────────────────────── + +describe("enumerateTraversalIds", () => { + test("simple pull wire — 1 traversal (primary)", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with api + with input as i + with output as o + api.q <- i.q + o.result <- api.label +}`); + const entries = enumerateTraversalIds(bridge); + const primaries = entries.filter((e) => e.kind === "primary"); + assert.ok(primaries.length >= 2, "at least 2 primary wires"); + assert.ok( + entries.every((e) => e.kind === "primary"), + "no fallbacks or catches", + ); + }); + + test("constant wire — 1 traversal (const)", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with api + with output as o + api.mode = "fast" + o.result <- api.label +}`); + const entries = enumerateTraversalIds(bridge); + const consts = entries.filter((e) => e.kind === "const"); + assert.equal(consts.length, 1); + assert.ok(consts[0].id.endsWith("/const")); + }); + + // ── Fallback chains ─────────────────────────────────────────────────────── + + test("|| fallback — 2 traversals (primary + fallback)", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with a + with b + with input as i + with output as o + a.q <- i.q + b.q <- i.q + o.label <- a.label || b.label +}`); + const entries = enumerateTraversalIds(bridge); + const labelEntries = entries.filter((e) => + e.target.includes("label") && e.target.length === 1, + ); + assert.equal(labelEntries.length, 2); + assert.equal(labelEntries[0].kind, "primary"); + assert.equal(labelEntries[1].kind, "fallback"); + assert.equal(labelEntries[1].gateType, "falsy"); + assert.equal(labelEntries[1].fallbackIndex, 0); + }); + + test("?? fallback — 2 traversals (primary + nullish fallback)", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with api + with input as i + with output as o + api.q <- i.q + o.label <- api.label ?? "default" +}`); + const entries = enumerateTraversalIds(bridge); + const labelEntries = entries.filter((e) => + e.target.includes("label") && e.target.length === 1, + ); + assert.equal(labelEntries.length, 2); + assert.equal(labelEntries[0].kind, "primary"); + assert.equal(labelEntries[1].kind, "fallback"); + assert.equal(labelEntries[1].gateType, "nullish"); + }); + + test("|| || — 3 traversals (primary + 2 fallbacks)", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with a + with b + with input as i + with output as o + a.q <- i.q + b.q <- i.q + o.label <- a.label || b.label || "fallback" +}`); + const entries = enumerateTraversalIds(bridge); + const labelEntries = entries.filter((e) => + e.target.includes("label") && e.target.length === 1, + ); + assert.equal(labelEntries.length, 3); + assert.equal(labelEntries[0].kind, "primary"); + assert.equal(labelEntries[1].kind, "fallback"); + assert.equal(labelEntries[1].fallbackIndex, 0); + assert.equal(labelEntries[2].kind, "fallback"); + assert.equal(labelEntries[2].fallbackIndex, 1); + }); + + // ── Catch ───────────────────────────────────────────────────────────────── + + test("catch — 2 traversals (primary + catch)", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with api + with input as i + with output as o + api.q <- i.q + o.lat <- api.lat catch 0 +}`); + const entries = enumerateTraversalIds(bridge); + const latEntries = entries.filter((e) => + e.target.includes("lat") && e.target.length === 1, + ); + assert.equal(latEntries.length, 2); + assert.equal(latEntries[0].kind, "primary"); + assert.equal(latEntries[1].kind, "catch"); + }); + + // ── Problem statement example: || + catch ───────────────────────────────── + + test("o <- i.a || i.b catch i.c — 3 traversals", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with a + with b + with input as i + with output as o + a.q <- i.q + b.q <- i.q + o.result <- a.value || b.value catch i.fallback +}`); + const entries = enumerateTraversalIds(bridge); + const resultEntries = entries.filter((e) => + e.target.includes("result") && e.target.length === 1, + ); + assert.equal(resultEntries.length, 3); + assert.equal(resultEntries[0].kind, "primary"); + assert.equal(resultEntries[1].kind, "fallback"); + assert.equal(resultEntries[2].kind, "catch"); + }); + + // ── Array iterators ─────────────────────────────────────────────────────── + + test("array block — adds empty-array traversal", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with api + with output as o + o <- api.items[] as it { + .id <- it.id + .name <- it.name + } +}`); + const entries = enumerateTraversalIds(bridge); + const emptyArr = entries.filter((e) => e.kind === "empty-array"); + assert.equal(emptyArr.length, 1); + assert.equal(emptyArr[0].wireIndex, -1); + }); + + // ── Problem statement example: array + ?? ───────────────────────────────── + + test("o.out <- i.array[] as a { .data <- a.a ?? a.b } — 3 traversals", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with api + with output as o + o <- api.items[] as a { + .data <- a.a ?? a.b + } +}`); + const entries = enumerateTraversalIds(bridge); + // Should have: empty-array + primary(.data) + fallback(.data) + assert.equal(entries.length, 3); + const emptyArr = entries.filter((e) => e.kind === "empty-array"); + assert.equal(emptyArr.length, 1); + const dataEntries = entries.filter((e) => + e.target.join(".").includes("data"), + ); + assert.equal(dataEntries.length, 2); + assert.equal(dataEntries[0].kind, "primary"); + assert.equal(dataEntries[1].kind, "fallback"); + assert.equal(dataEntries[1].gateType, "nullish"); + }); + + // ── Nested arrays ───────────────────────────────────────────────────────── + + test("nested array blocks — 2 empty-array entries", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with api + with output as o + o <- api.journeys[] as j { + .label <- j.label + .legs <- j.legs[] as l { + .name <- l.name + } + } +}`); + const entries = enumerateTraversalIds(bridge); + const emptyArr = entries.filter((e) => e.kind === "empty-array"); + assert.equal(emptyArr.length, 2, "two array scopes"); + }); + + // ── IDs are unique ──────────────────────────────────────────────────────── + + test("all IDs within a bridge are unique", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with a + with b + with input as i + with output as o + a.q <- i.q + b.q <- i.q + o.label <- a.label || b.label catch "none" + o.score <- a.score ?? 0 +}`); + const entries = enumerateTraversalIds(bridge); + const allIds = ids(entries); + const unique = new Set(allIds); + assert.equal(unique.size, allIds.length, `IDs must be unique: ${JSON.stringify(allIds)}`); + }); + + // ── TraversalEntry shape ────────────────────────────────────────────────── + + test("entries have correct structure", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with api + with input as i + with output as o + api.q <- i.q + o.result <- api.value || "default" catch 0 +}`); + const entries = enumerateTraversalIds(bridge); + for (const entry of entries) { + assert.ok(typeof entry.id === "string", "id is string"); + assert.ok(typeof entry.wireIndex === "number", "wireIndex is number"); + assert.ok(Array.isArray(entry.target), "target is array"); + assert.ok(typeof entry.kind === "string", "kind is string"); + } + const fb = entries.find((e) => e.kind === "fallback"); + assert.ok(fb, "should have a fallback entry"); + assert.equal(fb!.fallbackIndex, 0); + assert.equal(fb!.gateType, "falsy"); + }); + + // ── Conditional wire ────────────────────────────────────────────────────── + + test("conditional (ternary) wire — 2 traversals (then + else)", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with api + with input as i + with output as o + api.q <- i.q + o.label <- i.flag ? api.a : api.b +}`); + const entries = enumerateTraversalIds(bridge); + const labelEntries = entries.filter((e) => + e.target.includes("label") && e.target.length === 1, + ); + assert.ok(labelEntries.length >= 2, "at least then + else"); + const then = labelEntries.find((e) => e.kind === "then"); + const els = labelEntries.find((e) => e.kind === "else"); + assert.ok(then, "should have a then entry"); + assert.ok(els, "should have an else entry"); + }); + + // ── Total count is a complexity proxy ───────────────────────────────────── + + test("total traversal count reflects complexity", () => { + const simple = getBridge(`version 1.5 +bridge Query.simple { + with api + with output as o + o.value <- api.value +}`); + const complex = getBridge(`version 1.5 +bridge Query.complex { + with a + with b + with input as i + with output as o + a.q <- i.q + b.q <- i.q + o.x <- a.x || b.x catch "none" + o.y <- a.y ?? b.y + o.items <- a.items[] as it { + .name <- it.name || "anon" + } +}`); + const simpleCount = enumerateTraversalIds(simple).length; + const complexCount = enumerateTraversalIds(complex).length; + assert.ok( + complexCount > simpleCount, + `complex (${complexCount}) should exceed simple (${simpleCount})`, + ); + }); +}); From dc670b2d7460e640929d1313f569671b96f0cb2d Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:36:23 +0100 Subject: [PATCH 2/4] feat: runtime execution trace bitmask tracking with manifest view (#119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * feat: add execution trace bitmask to core engine - Add bitIndex to TraversalEntry for bitmask encoding - Add buildTraversalManifest (alias for enumerateTraversalIds) - Add decodeExecutionTrace to decode bitmask back to entries - Add buildTraceBitsMap for runtime wire→bit lookup - Add traceBits/traceMask to TreeContext interface - Inject trace recording in resolveWires (primary/fallback/catch/then/else) - Add executionTrace to ExecuteBridgeResult - Propagate trace mask through shadow trees - All existing tests pass (133 execute-bridge + 64 resilience + 14 traversal) Co-authored-by: aarne <82001+aarne@users.noreply.github.com> * test: add comprehensive tests for execution trace feature - Test buildTraversalManifest alias and bitIndex assignment - Test decodeExecutionTrace with empty/single/multiple/round-trip - Test end-to-end trace collection: primary, fallback, catch, then/else, const - All 27 tests pass Co-authored-by: aarne <82001+aarne@users.noreply.github.com> * feat: add execution trace and manifest view to playground - Add executionTrace to RunResult type in engine - Pipe executionTrace through runBridgeStandalone - Add getTraversalManifest and decodeExecutionTrace helpers to engine - Show execution trace badge (hex) in ResultView - Add Manifest tab to Bridge DSL panel header - ManifestView shows all traversal entries with kind badges - Active paths highlighted with ring + dot based on execution trace - Works across mobile and desktop layouts Co-authored-by: aarne <82001+aarne@users.noreply.github.com> * refactor: address code review feedback - Rename dslTab → activeDslTab for clarity - Rename fi → fallbackIndex in applyFallbackGates loop - Add comment explaining bitmask check in decodeExecutionTrace - Add INVARIANT comment for traceMask/traceBits coupling - Document bitIndex assignment lifecycle in enumerateTraversalIds Co-authored-by: aarne <82001+aarne@users.noreply.github.com> * feat: BigInt trace mask, manifest grouping/filtering, scroll fix - Switch traceMask from number to bigint for unlimited entry support - Update all recording helpers to use BigInt bitwise ops - Update decodeExecutionTrace, ExecuteBridgeResult, TreeContext - Update playground engine, ResultView, Playground types - ManifestView: group entries by wire, show group headers for alternatives - ManifestView: add "Show alternatives only" toggle filter - ManifestView: fix scrolling with h-full / max-h-[60vh] - Update tests to use bigint assertions - All 1143 tests pass Co-authored-by: aarne <82001+aarne@users.noreply.github.com> * fix: add aria-label to manifest group count for accessibility Co-authored-by: aarne <82001+aarne@users.noreply.github.com> * feat: source descriptions, alias labels, empty-array grouping, always-on toggle - Add `description` field to TraversalEntry with human-readable source info (e.g., "api.username", "|| \"Anonymous\"", "catch continue", "= \"SBB\"") - Fix alias wires showing as `*` — use `to.field` for `__local` module targets - Fix empty-array entries grouped under same `*` — assign unique wireIndex per scope - Handle map resolves tool, input, context, pipe, and alias refs to handle aliases - Filter toggle always visible regardless of whether alternatives exist - Consistent group headers shown for all entries (not just alternatives) - Empty-array entries show iterator variable in description (e.g., "c[] empty") - All 1143 tests pass Co-authored-by: aarne <82001+aarne@users.noreply.github.com> * UI tweaks * LSP for dead code in playground * Docs --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: aarne <82001+aarne@users.noreply.github.com> Co-authored-by: Aarne Laur --- .../playground-dead-code-trace-manifest.md | 6 + .../bridge-compiler/src/execute-bridge.ts | 4 +- packages/bridge-core/src/ExecutionTree.ts | 32 ++ .../bridge-core/src/enumerate-traversals.ts | 364 ++++++++++++++++-- packages/bridge-core/src/execute-bridge.ts | 16 +- packages/bridge-core/src/index.ts | 9 +- packages/bridge-core/src/resolveWires.ts | 57 ++- packages/bridge-core/src/tree-types.ts | 13 + .../test/traversal-manifest-locations.test.ts | 114 ++++++ packages/bridge-parser/src/parser/parser.ts | 100 +++-- .../bridge/test/enumerate-traversals.test.ts | 305 ++++++++++++++- packages/bridge/test/source-locations.test.ts | 54 +++ .../src/content/docs/advanced/trace-id.mdx | 97 +++++ packages/playground/src/Playground.tsx | 147 ++++++- .../playground/src/codemirror/dead-code.ts | 59 +++ packages/playground/src/codemirror/theme.ts | 67 ++-- packages/playground/src/components/Editor.tsx | 20 + packages/playground/src/engine.ts | 52 +++ packages/playground/src/usePlaygroundState.ts | 13 + 19 files changed, 1413 insertions(+), 116 deletions(-) create mode 100644 .changeset/playground-dead-code-trace-manifest.md create mode 100644 packages/bridge-core/test/traversal-manifest-locations.test.ts create mode 100644 packages/docs-site/src/content/docs/advanced/trace-id.mdx create mode 100644 packages/playground/src/codemirror/dead-code.ts diff --git a/.changeset/playground-dead-code-trace-manifest.md b/.changeset/playground-dead-code-trace-manifest.md new file mode 100644 index 00000000..1ecbca35 --- /dev/null +++ b/.changeset/playground-dead-code-trace-manifest.md @@ -0,0 +1,6 @@ +--- +"@stackables/bridge": patch +"@stackables/bridge-core": patch +--- + +Bridge Trace IDs - The engine now returns a compact Trace ID alongside your data (e.g., 0x2a). This ID can be decoded into an exact execution map showing precisely which wires, fallbacks, and conditions activated. Because every bridge has a finite number of execution paths, these IDs are perfect for zero-PII monitoring and bucketing telemetry data. diff --git a/packages/bridge-compiler/src/execute-bridge.ts b/packages/bridge-compiler/src/execute-bridge.ts index 04981eb5..408ca1ca 100644 --- a/packages/bridge-compiler/src/execute-bridge.ts +++ b/packages/bridge-compiler/src/execute-bridge.ts @@ -83,6 +83,8 @@ export type ExecuteBridgeOptions = { export type ExecuteBridgeResult = { data: T; traces: ToolTrace[]; + /** Compact bitmask encoding which traversal paths were taken during execution. */ + executionTrace: bigint; }; // ── Cache ─────────────────────────────────────────────────────────────────── @@ -338,5 +340,5 @@ export async function executeBridge( } catch (err) { throw attachBridgeErrorDocumentContext(err, document); } - return { data: data as T, traces: tracer?.traces ?? [] }; + return { data: data as T, traces: tracer?.traces ?? [], executionTrace: 0n }; } diff --git a/packages/bridge-core/src/ExecutionTree.ts b/packages/bridge-core/src/ExecutionTree.ts index 430a55c7..82629915 100644 --- a/packages/bridge-core/src/ExecutionTree.ts +++ b/packages/bridge-core/src/ExecutionTree.ts @@ -63,6 +63,8 @@ import { matchesRequestedFields, } from "./requested-fields.ts"; import { raceTimeout } from "./utils.ts"; +import type { TraceWireBits } from "./enumerate-traversals.ts"; +import { buildTraceBitsMap, enumerateTraversalIds } from "./enumerate-traversals.ts"; function stableMemoizeKey(value: unknown): string { if (value === undefined) { @@ -145,6 +147,17 @@ export class ExecutionTree implements TreeContext { private forcedExecution?: Promise; /** Shared trace collector — present only when tracing is enabled. */ tracer?: TraceCollector; + /** + * Per-wire bit positions for execution trace recording. + * Built once from the bridge manifest. Shared across shadow trees. + */ + traceBits?: Map; + /** + * Shared mutable trace bitmask — `[mask]`. Boxed in a single-element + * array so shadow trees can share the same mutable reference. + * Uses `bigint` to support manifests with more than 31 entries. + */ + traceMask?: [bigint]; /** Structured logger passed from BridgeOptions. Defaults to no-ops. */ logger?: Logger; /** External abort signal — cancels execution when triggered. */ @@ -726,6 +739,8 @@ export class ExecutionTree implements TreeContext { child.toolFns = this.toolFns; child.elementTrunkKey = this.elementTrunkKey; child.tracer = this.tracer; + child.traceBits = this.traceBits; + child.traceMask = this.traceMask; child.logger = this.logger; child.signal = this.signal; child.source = this.source; @@ -761,6 +776,23 @@ export class ExecutionTree implements TreeContext { return this.tracer?.traces ?? []; } + /** Returns the execution trace bitmask (0n when tracing is disabled). */ + getExecutionTrace(): bigint { + return this.traceMask?.[0] ?? 0n; + } + + /** + * Enable execution trace recording. + * Builds the wire-to-bit map from the bridge manifest and initialises + * the shared mutable bitmask. Safe to call before `run()`. + */ + enableExecutionTrace(): void { + if (!this.bridge) return; + const manifest = enumerateTraversalIds(this.bridge); + this.traceBits = buildTraceBitsMap(this.bridge, manifest); + this.traceMask = [0n]; + } + /** * Traverse `ref.path` on an already-resolved value, respecting null guards. * Extracted from `pullSingle` so the sync and async paths can share logic. diff --git a/packages/bridge-core/src/enumerate-traversals.ts b/packages/bridge-core/src/enumerate-traversals.ts index fcbe9fe4..e1117599 100644 --- a/packages/bridge-core/src/enumerate-traversals.ts +++ b/packages/bridge-core/src/enumerate-traversals.ts @@ -9,11 +9,20 @@ * `o <- i.arr[] as a { .data <- a.a ?? a.b }` → 3 traversals * (empty-array, primary for .data, nullish fallback for .data) * - * Used for complexity assessment and will integrate into the execution - * engine for monitoring. + * The traversal manifest is a static analysis result. At runtime, the + * execution engine produces a compact numeric `executionTrace` (bitmask) + * that records which traversal paths were actually taken. Use + * {@link decodeExecutionTrace} to map the bitmask back to entries. */ -import type { Bridge, Wire, WireFallback } from "./types.ts"; +import type { + Bridge, + Wire, + WireFallback, + NodeRef, + ControlFlowInstruction, + SourceLocation, +} from "./types.ts"; // ── Public types ──────────────────────────────────────────────────────────── @@ -40,6 +49,17 @@ export interface TraversalEntry { fallbackIndex?: number; /** Gate type (only when kind is `"fallback"`): `"falsy"` for `||`, `"nullish"` for `??`. */ gateType?: "falsy" | "nullish"; + /** Bit position in the execution trace bitmask (0-based). */ + bitIndex: number; + /** Source span for the specific traversal branch, when known. */ + loc?: SourceLocation; + /** Source span covering the entire wire (full line), when known. */ + wireLoc?: SourceLocation; + /** + * Human-readable description of the source for this path. + * Examples: `"api.username"`, `"|| \"Anonymous\""`, `"catch continue"`, `"= \"SBB\""`. + */ + description?: string; } // ── Helpers ───────────────────────────────────────────────────────────────── @@ -77,13 +97,112 @@ function isPlainArraySourceWire( return !w.fallbacks?.length && !hasCatch(w); } +// ── Description helpers ──────────────────────────────────────────────────── + +/** Map from ref type+field → handle alias for readable ref descriptions. */ +function buildHandleMap(bridge: Bridge): Map { + const map = new Map(); + for (const h of bridge.handles) { + if (h.kind === "tool" || h.kind === "define") { + // Tool/define refs use type="Tools" and field=tool name. + map.set(`Tools:${h.name}`, h.handle); + } else if (h.kind === "input") { + map.set("input", h.handle); + } else if (h.kind === "context") { + map.set("context", h.handle); + } + } + // Pipe handles use a non-"_" module (e.g., "std.str") with type="Query". + if (bridge.pipeHandles) { + for (const ph of bridge.pipeHandles) { + map.set(`pipe:${ph.baseTrunk.module}`, ph.handle); + } + } + return map; +} + +function refLabel(ref: NodeRef, hmap: Map): string { + if (ref.element) { + return ref.path.length > 0 ? ref.path.join(".") : "element"; + } + // __local refs are alias variables — use the field name (alias name) directly. + if (ref.module === "__local") { + return ref.path.length > 0 + ? `${ref.field}.${ref.path.join(".")}` + : ref.field; + } + let alias: string | undefined; + if (ref.type === "Tools") { + alias = hmap.get(`Tools:${ref.field}`); + } else if (ref.module !== "_") { + // Pipe handle — look up by module name. + alias = hmap.get(`pipe:${ref.module}`); + } else { + alias = hmap.get("input") ?? hmap.get("context"); + } + alias ??= ref.field; + return ref.path.length > 0 ? `${alias}.${ref.path.join(".")}` : alias; +} + +function controlLabel(ctrl: ControlFlowInstruction): string { + const n = + ctrl.kind === "continue" || ctrl.kind === "break" + ? ctrl.levels != null && ctrl.levels > 1 + ? ` ${ctrl.levels}` + : "" + : ""; + if (ctrl.kind === "throw" || ctrl.kind === "panic") { + return `${ctrl.kind} "${ctrl.message}"`; + } + return `${ctrl.kind}${n}`; +} + +function fallbackDescription( + fb: WireFallback, + hmap: Map, +): string { + const gate = fb.type === "falsy" ? "||" : "??"; + if (fb.value != null) return `${gate} ${fb.value}`; + if (fb.ref) return `${gate} ${refLabel(fb.ref, hmap)}`; + if (fb.control) return `${gate} ${controlLabel(fb.control)}`; + return gate; +} + +function catchDescription(w: Wire, hmap: Map): string { + if ("value" in w) return "catch"; + if (w.catchFallback != null) return `catch ${w.catchFallback}`; + if (w.catchFallbackRef) return `catch ${refLabel(w.catchFallbackRef, hmap)}`; + if (w.catchControl) return `catch ${controlLabel(w.catchControl)}`; + return "catch"; +} + +/** + * Compute the effective target path for a wire. + * For `__local` module wires (aliases), use `to.field` as the target + * since `to.path` is always empty for alias wires. + */ +function effectiveTarget(w: Wire): string[] { + if (w.to.path.length === 0 && w.to.module === "__local") { + return [w.to.field]; + } + return w.to.path; +} + +function primaryLoc(w: Wire): SourceLocation | undefined { + if ("value" in w) return w.loc; + if ("from" in w) return w.fromLoc ?? w.loc; + return w.loc; +} + function addFallbackEntries( entries: TraversalEntry[], base: string, wireIndex: number, target: string[], - fallbacks: WireFallback[] | undefined, + w: Wire, + hmap: Map, ): void { + const fallbacks = "fallbacks" in w ? w.fallbacks : undefined; if (!fallbacks) return; for (let i = 0; i < fallbacks.length; i++) { entries.push({ @@ -93,6 +212,10 @@ function addFallbackEntries( kind: "fallback", fallbackIndex: i, gateType: fallbacks[i].type, + bitIndex: -1, // assigned after enumeration + loc: fallbacks[i].loc, + wireLoc: w.loc, + description: fallbackDescription(fallbacks[i], hmap), }); } } @@ -103,9 +226,19 @@ function addCatchEntry( wireIndex: number, target: string[], w: Wire, + hmap: Map, ): void { if (hasCatch(w)) { - entries.push({ id: `${base}/catch`, wireIndex, target, kind: "catch" }); + entries.push({ + id: `${base}/catch`, + wireIndex, + target, + kind: "catch", + bitIndex: -1, + loc: "catchLoc" in w ? w.catchLoc : undefined, + wireLoc: w.loc, + description: catchDescription(w, hmap), + }); } } @@ -117,9 +250,14 @@ function addCatchEntry( * Returns a flat list of {@link TraversalEntry} objects, one per * unique code-path through the bridge's wires. The total length * of the returned array is a useful proxy for bridge complexity. + * + * `bitIndex` is initially set to `-1` during construction and + * assigned sequentially (0, 1, 2, …) at the end. No entry is + * exposed with `bitIndex === -1`. */ export function enumerateTraversalIds(bridge: Bridge): TraversalEntry[] { const entries: TraversalEntry[] = []; + const hmap = buildHandleMap(bridge); // Track per-target occurrence counts for disambiguation when // multiple wires write to the same target (overdefinition). @@ -127,7 +265,7 @@ export function enumerateTraversalIds(bridge: Bridge): TraversalEntry[] { for (let i = 0; i < bridge.wires.length; i++) { const w = bridge.wires[i]; - const target = w.to.path; + const target = effectiveTarget(w); const tKey = pathKey(target); // Disambiguate overdefined targets (same target written by >1 wire). @@ -137,7 +275,16 @@ export function enumerateTraversalIds(bridge: Bridge): TraversalEntry[] { // ── Constant wire ─────────────────────────────────────────────── if ("value" in w) { - entries.push({ id: `${base}/const`, wireIndex: i, target, kind: "const" }); + entries.push({ + id: `${base}/const`, + wireIndex: i, + target, + kind: "const", + bitIndex: -1, + loc: w.loc, + wireLoc: w.loc, + description: `= ${w.value}`, + }); continue; } @@ -151,52 +298,217 @@ export function enumerateTraversalIds(bridge: Bridge): TraversalEntry[] { wireIndex: i, target, kind: "primary", + bitIndex: -1, + loc: primaryLoc(w), + wireLoc: w.loc, + description: refLabel(w.from, hmap), }); - addFallbackEntries(entries, base, i, target, w.fallbacks); - addCatchEntry(entries, base, i, target, w); + addFallbackEntries(entries, base, i, target, w, hmap); + addCatchEntry(entries, base, i, target, w, hmap); } continue; } // ── Conditional (ternary) wire ────────────────────────────────── if ("cond" in w) { - entries.push({ id: `${base}/then`, wireIndex: i, target, kind: "then" }); - entries.push({ id: `${base}/else`, wireIndex: i, target, kind: "else" }); - addFallbackEntries(entries, base, i, target, w.fallbacks); - addCatchEntry(entries, base, i, target, w); + const thenDesc = w.thenRef + ? `? ${refLabel(w.thenRef, hmap)}` + : w.thenValue != null + ? `? ${w.thenValue}` + : "then"; + const elseDesc = w.elseRef + ? `: ${refLabel(w.elseRef, hmap)}` + : w.elseValue != null + ? `: ${w.elseValue}` + : "else"; + entries.push({ + id: `${base}/then`, + wireIndex: i, + target, + kind: "then", + bitIndex: -1, + loc: w.thenLoc ?? w.loc, + wireLoc: w.loc, + description: thenDesc, + }); + entries.push({ + id: `${base}/else`, + wireIndex: i, + target, + kind: "else", + bitIndex: -1, + loc: w.elseLoc ?? w.loc, + wireLoc: w.loc, + description: elseDesc, + }); + addFallbackEntries(entries, base, i, target, w, hmap); + addCatchEntry(entries, base, i, target, w, hmap); continue; } // ── condAnd / condOr (logical binary) ─────────────────────────── - entries.push({ - id: `${base}/primary`, - wireIndex: i, - target, - kind: "primary", - }); if ("condAnd" in w) { - addFallbackEntries(entries, base, i, target, w.fallbacks); - addCatchEntry(entries, base, i, target, w); + const desc = w.condAnd.rightRef + ? `${refLabel(w.condAnd.leftRef, hmap)} && ${refLabel(w.condAnd.rightRef, hmap)}` + : w.condAnd.rightValue != null + ? `${refLabel(w.condAnd.leftRef, hmap)} && ${w.condAnd.rightValue}` + : refLabel(w.condAnd.leftRef, hmap); + entries.push({ + id: `${base}/primary`, + wireIndex: i, + target, + kind: "primary", + bitIndex: -1, + loc: primaryLoc(w), + wireLoc: w.loc, + description: desc, + }); + addFallbackEntries(entries, base, i, target, w, hmap); + addCatchEntry(entries, base, i, target, w, hmap); } else { // condOr const wo = w as Extract; - addFallbackEntries(entries, base, i, target, wo.fallbacks); - addCatchEntry(entries, base, i, target, w); + const desc = wo.condOr.rightRef + ? `${refLabel(wo.condOr.leftRef, hmap)} || ${refLabel(wo.condOr.rightRef, hmap)}` + : wo.condOr.rightValue != null + ? `${refLabel(wo.condOr.leftRef, hmap)} || ${wo.condOr.rightValue}` + : refLabel(wo.condOr.leftRef, hmap); + entries.push({ + id: `${base}/primary`, + wireIndex: i, + target, + kind: "primary", + bitIndex: -1, + loc: primaryLoc(wo), + wireLoc: wo.loc, + description: desc, + }); + addFallbackEntries(entries, base, i, target, wo, hmap); + addCatchEntry(entries, base, i, target, w, hmap); } } // ── Array iterators — each scope adds an "empty-array" path ───── if (bridge.arrayIterators) { + let emptyIdx = 0; for (const key of Object.keys(bridge.arrayIterators)) { - const id = key ? `${key}/empty-array` : "*/empty-array"; + const iterName = bridge.arrayIterators[key]; + const target = key ? key.split(".") : []; + const label = key || "(root)"; + const id = `${label}/empty-array`; entries.push({ id, - wireIndex: -1, - target: key ? key.split(".") : [], + // Use unique negative wireIndex per empty-array so they don't group together. + wireIndex: -++emptyIdx, + target, kind: "empty-array", + bitIndex: -1, + description: `${iterName}[] empty`, }); } } + // Assign sequential bit indices + for (let i = 0; i < entries.length; i++) { + entries[i].bitIndex = i; + } + return entries; } + +// ── New public API ────────────────────────────────────────────────────────── + +/** + * Build the static traversal manifest for a bridge. + * + * Alias for {@link enumerateTraversalIds} with the recommended naming. + * Returns the ordered array of {@link TraversalEntry} objects. Each entry + * carries a `bitIndex` that maps it to a bit position in the runtime + * execution trace bitmask. + */ +export const buildTraversalManifest = enumerateTraversalIds; + +/** + * Decode a runtime execution trace bitmask against a traversal manifest. + * + * Returns the subset of {@link TraversalEntry} objects whose bits are set + * in the trace — i.e. the paths that were actually taken during execution. + * + * @param manifest The static manifest from {@link buildTraversalManifest}. + * @param trace The bigint bitmask produced by the execution engine. + */ +export function decodeExecutionTrace( + manifest: TraversalEntry[], + trace: bigint, +): TraversalEntry[] { + const result: TraversalEntry[] = []; + for (const entry of manifest) { + // Check if the bit at position `entry.bitIndex` is set in the trace, + // indicating this path was taken during execution. + if (trace & (1n << BigInt(entry.bitIndex))) { + result.push(entry); + } + } + return result; +} + +// ── Runtime trace helpers ─────────────────────────────────────────────────── + +/** + * Per-wire bit positions used by the execution engine to record which + * traversal paths were taken. Built once per bridge from the manifest. + */ +export interface TraceWireBits { + /** Bit index for the primary / then / const path. */ + primary?: number; + /** Bit index for the else branch (conditional wires only). */ + else?: number; + /** Bit indices for each fallback gate (same order as `fallbacks` array). */ + fallbacks?: number[]; + /** Bit index for the catch path. */ + catch?: number; +} + +/** + * Build a lookup map from Wire objects to their trace bit positions. + * + * This is called once per bridge at setup time. The returned map is + * used by `resolveWires` to flip bits in the shared trace mask with + * minimal overhead (one Map.get + one bitwise OR per decision). + */ +export function buildTraceBitsMap( + bridge: Bridge, + manifest: TraversalEntry[], +): Map { + const map = new Map(); + for (const entry of manifest) { + if (entry.wireIndex < 0) continue; // synthetic entries (empty-array) + const wire = bridge.wires[entry.wireIndex]; + if (!wire) continue; + + let bits = map.get(wire); + if (!bits) { + bits = {}; + map.set(wire, bits); + } + + switch (entry.kind) { + case "primary": + case "then": + case "const": + bits.primary = entry.bitIndex; + break; + case "else": + bits.else = entry.bitIndex; + break; + case "fallback": + if (!bits.fallbacks) bits.fallbacks = []; + bits.fallbacks[entry.fallbackIndex ?? 0] = entry.bitIndex; + break; + case "catch": + bits.catch = entry.bitIndex; + break; + } + } + return map; +} diff --git a/packages/bridge-core/src/execute-bridge.ts b/packages/bridge-core/src/execute-bridge.ts index 235aeb62..ea570942 100644 --- a/packages/bridge-core/src/execute-bridge.ts +++ b/packages/bridge-core/src/execute-bridge.ts @@ -78,6 +78,8 @@ export type ExecuteBridgeOptions = { export type ExecuteBridgeResult = { data: T; traces: ToolTrace[]; + /** Compact bitmask encoding which traversal paths were taken during execution. */ + executionTrace: bigint; }; /** @@ -159,12 +161,24 @@ export async function executeBridge( tree.tracer = new TraceCollector(traceLevel); } + // Always enable execution trace recording — the overhead is one + // Map.get + one bitwise OR per wire decision (negligible). + tree.enableExecutionTrace(); + let data: unknown; try { data = await tree.run(input, options.requestedFields); } catch (err) { + if (err && typeof err === "object") { + (err as { executionTrace?: bigint }).executionTrace = + tree.getExecutionTrace(); + } throw attachBridgeErrorDocumentContext(err, doc); } - return { data: data as T, traces: tree.getTraces() }; + return { + data: data as T, + traces: tree.getTraces(), + executionTrace: tree.getExecutionTrace(), + }; } diff --git a/packages/bridge-core/src/index.ts b/packages/bridge-core/src/index.ts index 6753dc58..46fdaf71 100644 --- a/packages/bridge-core/src/index.ts +++ b/packages/bridge-core/src/index.ts @@ -84,8 +84,13 @@ export type { // ── Traversal enumeration ─────────────────────────────────────────────────── -export { enumerateTraversalIds } from "./enumerate-traversals.ts"; -export type { TraversalEntry } from "./enumerate-traversals.ts"; +export { + enumerateTraversalIds, + buildTraversalManifest, + decodeExecutionTrace, + buildTraceBitsMap, +} from "./enumerate-traversals.ts"; +export type { TraversalEntry, TraceWireBits } from "./enumerate-traversals.ts"; // ── Utilities ─────────────────────────────────────────────────────────────── diff --git a/packages/bridge-core/src/resolveWires.ts b/packages/bridge-core/src/resolveWires.ts index 7750e025..81e12efc 100644 --- a/packages/bridge-core/src/resolveWires.ts +++ b/packages/bridge-core/src/resolveWires.ts @@ -21,6 +21,7 @@ import { wrapBridgeRuntimeError, } from "./tree-types.ts"; import { coerceConstant, getSimplePullRef } from "./tree-utils.ts"; +import type { TraceWireBits } from "./enumerate-traversals.ts"; // ── Wire type helpers ──────────────────────────────────────────────────────── @@ -72,9 +73,13 @@ export function resolveWires( if (wires.length === 1) { const w = wires[0]!; - if ("value" in w) return coerceConstant(w.value); + if ("value" in w) { + recordPrimary(ctx, w); + return coerceConstant(w.value); + } const ref = getSimplePullRef(w); if (ref) { + recordPrimary(ctx, w); return ctx.pullSingle( ref, pullChain, @@ -121,7 +126,10 @@ async function resolveWiresAsync( if (ctx.signal?.aborted) throw new BridgeAbortError(); // Constant wire — always wins, no modifiers - if ("value" in w) return coerceConstant(w.value); + if ("value" in w) { + recordPrimary(ctx, w); + return coerceConstant(w.value); + } try { // Layer 1: Execution @@ -168,11 +176,13 @@ export async function applyFallbackGates( ): Promise { if (!w.fallbacks?.length) return value; - for (const fallback of w.fallbacks) { + for (let fallbackIndex = 0; fallbackIndex < w.fallbacks.length; fallbackIndex++) { + const fallback = w.fallbacks[fallbackIndex]; const isFalsyGateOpen = fallback.type === "falsy" && !value; const isNullishGateOpen = fallback.type === "nullish" && value == null; if (isFalsyGateOpen || isNullishGateOpen) { + recordFallback(ctx, w, fallbackIndex); if (fallback.control) { return applyControlFlowWithLoc(fallback.control, fallback.loc ?? w.loc); } @@ -207,12 +217,17 @@ export async function applyCatchGate( pullChain?: Set, ): Promise { if (w.catchControl) { + recordCatch(ctx, w); return applyControlFlowWithLoc(w.catchControl, w.catchLoc ?? w.loc); } if (w.catchFallbackRef) { + recordCatch(ctx, w); return ctx.pullSingle(w.catchFallbackRef, pullChain, w.catchLoc ?? w.loc); } - if (w.catchFallback != null) return coerceConstant(w.catchFallback); + if (w.catchFallback != null) { + recordCatch(ctx, w); + return coerceConstant(w.catchFallback); + } return undefined; } @@ -256,11 +271,13 @@ async function evaluateWireSource( w.condLoc ?? w.loc, ); if (condValue) { + recordPrimary(ctx, w); // "then" branch → primary bit if (w.thenRef !== undefined) { return ctx.pullSingle(w.thenRef, pullChain, w.thenLoc ?? w.loc); } if (w.thenValue !== undefined) return coerceConstant(w.thenValue); } else { + recordElse(ctx, w); // "else" branch if (w.elseRef !== undefined) { return ctx.pullSingle(w.elseRef, pullChain, w.elseLoc ?? w.loc); } @@ -270,6 +287,7 @@ async function evaluateWireSource( } if ("condAnd" in w) { + recordPrimary(ctx, w); const { leftRef, rightRef, rightValue, safe, rightSafe } = w.condAnd; const leftVal = await pullSafe(ctx, leftRef, safe, pullChain, w.loc); if (!leftVal) return false; @@ -282,6 +300,7 @@ async function evaluateWireSource( } if ("condOr" in w) { + recordPrimary(ctx, w); const { leftRef, rightRef, rightValue, safe, rightSafe } = w.condOr; const leftVal = await pullSafe(ctx, leftRef, safe, pullChain, w.loc); if (leftVal) return true; @@ -294,6 +313,7 @@ async function evaluateWireSource( } if ("from" in w) { + recordPrimary(ctx, w); if (w.safe) { try { return await ctx.pullSingle(w.from, pullChain, w.fromLoc ?? w.loc); @@ -348,3 +368,32 @@ function pullSafe( return undefined; }); } + +// ── Trace recording helpers ───────────────────────────────────────────────── +// These are designed for minimal overhead: when `traceBits` is not set on the +// context (tracing disabled), the functions return immediately after a single +// falsy check. When enabled, one Map.get + one bitwise OR is the hot path. +// +// INVARIANT: `traceMask` is always set when `traceBits` is set — both are +// initialised together by `ExecutionTree.enableExecutionTrace()`. + +function recordPrimary(ctx: TreeContext, w: Wire): void { + const bits = ctx.traceBits?.get(w) as TraceWireBits | undefined; + if (bits?.primary != null) ctx.traceMask![0] |= 1n << BigInt(bits.primary); +} + +function recordElse(ctx: TreeContext, w: Wire): void { + const bits = ctx.traceBits?.get(w) as TraceWireBits | undefined; + if (bits?.else != null) ctx.traceMask![0] |= 1n << BigInt(bits.else); +} + +function recordFallback(ctx: TreeContext, w: Wire, index: number): void { + const bits = ctx.traceBits?.get(w) as TraceWireBits | undefined; + const fb = bits?.fallbacks; + if (fb && fb[index] != null) ctx.traceMask![0] |= 1n << BigInt(fb[index]); +} + +function recordCatch(ctx: TreeContext, w: Wire): void { + const bits = ctx.traceBits?.get(w) as TraceWireBits | undefined; + if (bits?.catch != null) ctx.traceMask![0] |= 1n << BigInt(bits.catch); +} diff --git a/packages/bridge-core/src/tree-types.ts b/packages/bridge-core/src/tree-types.ts index d236cde7..95137997 100644 --- a/packages/bridge-core/src/tree-types.ts +++ b/packages/bridge-core/src/tree-types.ts @@ -136,6 +136,19 @@ export interface TreeContext { classifyOverdefinitionWire?(wire: Wire): number; /** External abort signal — cancels execution when triggered. */ signal?: AbortSignal; + /** + * Per-wire bit positions for execution trace recording. + * Present only when execution tracing is enabled. Looked up by + * `resolveWires` to flip bits in `traceMask`. + */ + traceBits?: Map; + /** + * Shared mutable trace bitmask — `[mask]`. Boxed in a single-element + * array so shadow trees can share the same mutable reference without + * extra allocation. Present only when execution tracing is enabled. + * Uses `bigint` to support manifests with more than 31 entries. + */ + traceMask?: [bigint]; } /** Returns `true` when `value` is a thenable (Promise or Promise-like). */ diff --git a/packages/bridge-core/test/traversal-manifest-locations.test.ts b/packages/bridge-core/test/traversal-manifest-locations.test.ts new file mode 100644 index 00000000..837c7b0b --- /dev/null +++ b/packages/bridge-core/test/traversal-manifest-locations.test.ts @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { parseBridgeChevrotain } from "../../bridge-parser/src/index.ts"; +import { + buildTraversalManifest, + type Bridge, + type SourceLocation, + type TraversalEntry, + type Wire, +} from "../src/index.ts"; + +function getBridge(text: string): Bridge { + const document = parseBridgeChevrotain(text); + const bridge = document.instructions.find( + (instruction): instruction is Bridge => instruction.kind === "bridge", + ); + assert.ok(bridge, "expected a bridge instruction"); + return bridge; +} + +function assertLoc( + entry: TraversalEntry | undefined, + expected: SourceLocation | undefined, +): void { + assert.ok(entry, "expected traversal entry to exist"); + assert.deepEqual(entry.loc, expected); +} + +function isPullWire(wire: Wire): wire is Extract { + return "from" in wire; +} + +function isTernaryWire(wire: Wire): wire is Extract { + return "cond" in wire; +} + +describe("buildTraversalManifest source locations", () => { + it("maps pull, fallback, and catch entries to granular source spans", () => { + 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 pullWires = bridge.wires.filter(isPullWire); + const aliasWire = pullWires.find((wire) => wire.to.field === "clean"); + const messageWire = pullWires.find( + (wire) => wire.to.path.join(".") === "message", + ); + + assert.ok(aliasWire); + assert.ok(messageWire); + + const manifest = buildTraversalManifest(bridge); + assertLoc( + manifest.find((entry) => entry.id === "message/primary"), + messageWire.fromLoc, + ); + assertLoc( + manifest.find((entry) => entry.id === "message/fallback:0"), + messageWire.fallbacks?.[0]?.loc, + ); + assertLoc( + manifest.find((entry) => entry.id === "message/catch"), + messageWire.catchLoc, + ); + assertLoc( + manifest.find((entry) => entry.id === "clean/primary"), + aliasWire.fromLoc, + ); + assertLoc( + manifest.find((entry) => entry.id === "clean/catch"), + aliasWire.catchLoc, + ); + }); + + it("maps ternary branches to then/else spans", () => { + 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(isTernaryWire); + assert.ok(ternaryWire); + + const manifest = buildTraversalManifest(bridge); + assertLoc( + manifest.find((entry) => entry.id === "name/then"), + ternaryWire.thenLoc, + ); + assertLoc( + manifest.find((entry) => entry.id === "name/else"), + ternaryWire.elseLoc, + ); + }); + + it("maps constant entries to the wire span", () => { + const bridge = getBridge(`version 1.5 +bridge Query.test { + with output as o + o.name = "Ada" +}`); + + const manifest = buildTraversalManifest(bridge); + assertLoc( + manifest.find((entry) => entry.id === "name/const"), + bridge.wires[0]?.loc, + ); + }); +}); diff --git a/packages/bridge-parser/src/parser/parser.ts b/packages/bridge-parser/src/parser/parser.ts index 83398ab2..cdede099 100644 --- a/packages/bridge-parser/src/parser/parser.ts +++ b/packages/bridge-parser/src/parser/parser.ts @@ -2620,18 +2620,23 @@ function processElementScopeLines( 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: spreadToPath, - }, - 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: spreadToPath, + }, + spread: true as const, + ...(spreadSafe ? { safe: true as const } : {}), + }, + locFromNode(spreadLine), + ), + ); } processElementScopeLines( nestedScopeLines, @@ -2657,16 +2662,21 @@ function processElementScopeLines( // ── Constant wire: .field = value ── if (sc.scopeEquals) { const value = extractBareValue(sub(scopeLine, "scopeValue")!); - 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, + }, + }, + scopeLineLoc, + ), + ); continue; } @@ -2895,24 +2905,29 @@ function processElementScopeLines( 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 } : {}), - to: elemToRef, - }); + 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 } : {}), + ...(catchLoc ? { catchLoc } : {}), + ...(catchFallback !== undefined ? { catchFallback } : {}), + ...(catchFallbackRef !== undefined ? { catchFallbackRef } : {}), + ...(catchControl ? { catchControl } : {}), + to: elemToRef, + }, + scopeLineLoc, + ), + ); wires.push(...fallbackInternalWires); wires.push(...catchFallbackInternalWires); continue; @@ -2959,13 +2974,16 @@ function processElementScopeLines( const { ref: fromRef, isPipeFork: isPipe } = sourceParts[0]; const wireAttrs = { ...(isPipe ? { pipe: true as const } : {}), + ...(condLoc ? { fromLoc: condLoc } : {}), ...(fallbacks.length > 0 ? { fallbacks } : {}), ...(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 }, scopeLineLoc), + ); wires.push(...fallbackInternalWires); wires.push(...catchFallbackInternalWires); } diff --git a/packages/bridge/test/enumerate-traversals.test.ts b/packages/bridge/test/enumerate-traversals.test.ts index 4885029f..f53fce15 100644 --- a/packages/bridge/test/enumerate-traversals.test.ts +++ b/packages/bridge/test/enumerate-traversals.test.ts @@ -1,8 +1,13 @@ import { describe, test } from "node:test"; import assert from "node:assert/strict"; import { parseBridge } from "@stackables/bridge-parser"; -import { enumerateTraversalIds } from "@stackables/bridge-core"; -import type { Bridge, TraversalEntry } from "@stackables/bridge-core"; +import { + enumerateTraversalIds, + buildTraversalManifest, + decodeExecutionTrace, + executeBridge, +} from "@stackables/bridge-core"; +import type { Bridge, TraversalEntry, BridgeDocument } from "@stackables/bridge-core"; function getBridge(source: string): Bridge { const doc = parseBridge(source); @@ -320,3 +325,299 @@ bridge Query.complex { ); }); }); + +// ── buildTraversalManifest ────────────────────────────────────────────────── + +describe("buildTraversalManifest", () => { + test("is an alias for enumerateTraversalIds", () => { + assert.strictEqual(buildTraversalManifest, enumerateTraversalIds); + }); + + test("entries have sequential bitIndex starting at 0", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with a + with b + with input as i + with output as o + a.q <- i.q + b.q <- i.q + o.label <- a.label || b.label catch "none" + o.score <- a.score ?? 0 +}`); + const manifest = buildTraversalManifest(bridge); + for (let i = 0; i < manifest.length; i++) { + assert.equal(manifest[i].bitIndex, i, `entry ${i} should have bitIndex ${i}`); + } + }); +}); + +// ── decodeExecutionTrace ──────────────────────────────────────────────────── + +describe("decodeExecutionTrace", () => { + test("empty trace returns empty array", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with api + with input as i + with output as o + api.q <- i.q + o.result <- api.label +}`); + const manifest = buildTraversalManifest(bridge); + const result = decodeExecutionTrace(manifest, 0n); + assert.equal(result.length, 0); + }); + + test("single bit decodes to one entry", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with api + with input as i + with output as o + api.q <- i.q + o.result <- api.label || "fallback" +}`); + const manifest = buildTraversalManifest(bridge); + const primary = manifest.find((e) => e.kind === "primary" && e.target.includes("result")); + assert.ok(primary); + const result = decodeExecutionTrace(manifest, 1n << BigInt(primary.bitIndex)); + assert.equal(result.length, 1); + assert.equal(result[0].id, primary.id); + }); + + test("multiple bits decode to multiple entries", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with a + with b + with input as i + with output as o + a.q <- i.q + b.q <- i.q + o.label <- a.label || b.label catch "none" +}`); + const manifest = buildTraversalManifest(bridge); + const labelEntries = manifest.filter( + (e) => e.target.includes("label") && e.target.length === 1, + ); + assert.equal(labelEntries.length, 3); + + // Set all label bits + let mask = 0n; + for (const e of labelEntries) { + mask |= 1n << BigInt(e.bitIndex); + } + const decoded = decodeExecutionTrace(manifest, mask); + assert.equal(decoded.length, 3); + assert.deepEqual( + decoded.map((e) => e.kind), + ["primary", "fallback", "catch"], + ); + }); + + test("round-trip: build manifest, set bits, decode", () => { + const bridge = getBridge(`version 1.5 +bridge Query.demo { + with api + with input as i + with output as o + api.q <- i.q + o.label <- i.flag ? api.a : api.b +}`); + const manifest = buildTraversalManifest(bridge); + const thenEntry = manifest.find((e) => e.kind === "then"); + assert.ok(thenEntry); + const decoded = decodeExecutionTrace(manifest, 1n << BigInt(thenEntry.bitIndex)); + assert.equal(decoded.length, 1); + assert.equal(decoded[0].kind, "then"); + }); +}); + +// ── End-to-end execution trace ────────────────────────────────────────────── + +function getDoc(source: string): BridgeDocument { + const raw = parseBridge(source); + return JSON.parse(JSON.stringify(raw)) as BridgeDocument; +} + +describe("executionTrace: end-to-end", () => { + test("simple pull wire — primary bits are set", async () => { + const doc = getDoc(`version 1.5 +bridge Query.demo { + with api + with input as i + with output as o + api.q <- i.q + o.result <- api.label +}`); + const { executionTrace } = await executeBridge({ + document: doc, + operation: "Query.demo", + input: { q: "test" }, + tools: { api: async () => ({ label: "Hello" }) }, + }); + + assert.ok(executionTrace > 0n, "trace should have bits set"); + + // Decode and verify + const bridge = doc.instructions.find( + (i): i is Bridge => i.kind === "bridge", + )!; + const manifest = buildTraversalManifest(bridge); + const decoded = decodeExecutionTrace(manifest, executionTrace); + const kinds = decoded.map((e) => e.kind); + assert.ok(kinds.includes("primary"), "should include primary paths"); + }); + + test("fallback fires — fallback bit is set", async () => { + const doc = getDoc(`version 1.5 +bridge Query.demo { + with api + with input as i + with output as o + api.q <- i.q + o.label <- api.label || "default" +}`); + const { executionTrace, data } = await executeBridge({ + document: doc, + operation: "Query.demo", + input: { q: "test" }, + tools: { api: async () => ({ label: null }) }, + }); + + assert.equal((data as any).label, "default"); + + const bridge = doc.instructions.find( + (i): i is Bridge => i.kind === "bridge", + )!; + const manifest = buildTraversalManifest(bridge); + const decoded = decodeExecutionTrace(manifest, executionTrace); + const kinds = decoded.map((e) => e.kind); + assert.ok(kinds.includes("fallback"), "should include fallback path"); + }); + + test("catch fires — catch bit is set", async () => { + const doc = getDoc(`version 1.5 +bridge Query.demo { + with api + with input as i + with output as o + api.q <- i.q + o.lat <- api.lat catch 0 +}`); + const { executionTrace, data } = await executeBridge({ + document: doc, + operation: "Query.demo", + input: { q: "test" }, + tools: { api: async () => { throw new Error("boom"); } }, + }); + + assert.equal((data as any).lat, 0); + + const bridge = doc.instructions.find( + (i): i is Bridge => i.kind === "bridge", + )!; + const manifest = buildTraversalManifest(bridge); + const decoded = decodeExecutionTrace(manifest, executionTrace); + const kinds = decoded.map((e) => e.kind); + assert.ok(kinds.includes("catch"), "should include catch path"); + }); + + test("ternary — then branch bit is set", async () => { + const doc = getDoc(`version 1.5 +bridge Query.demo { + with api + with input as i + with output as o + api.q <- i.q + o.label <- i.flag ? api.a : api.b +}`); + const { executionTrace } = await executeBridge({ + document: doc, + operation: "Query.demo", + input: { q: "test", flag: true }, + tools: { api: async () => ({ a: "yes", b: "no" }) }, + }); + + const bridge = doc.instructions.find( + (i): i is Bridge => i.kind === "bridge", + )!; + const manifest = buildTraversalManifest(bridge); + const decoded = decodeExecutionTrace(manifest, executionTrace); + const kinds = decoded.map((e) => e.kind); + assert.ok(kinds.includes("then"), "should include then path"); + assert.ok(!kinds.includes("else"), "should NOT include else path"); + }); + + test("ternary — else branch bit is set", async () => { + const doc = getDoc(`version 1.5 +bridge Query.demo { + with api + with input as i + with output as o + api.q <- i.q + o.label <- i.flag ? api.a : api.b +}`); + const { executionTrace } = await executeBridge({ + document: doc, + operation: "Query.demo", + input: { q: "test", flag: false }, + tools: { api: async () => ({ a: "yes", b: "no" }) }, + }); + + const bridge = doc.instructions.find( + (i): i is Bridge => i.kind === "bridge", + )!; + const manifest = buildTraversalManifest(bridge); + const decoded = decodeExecutionTrace(manifest, executionTrace); + const kinds = decoded.map((e) => e.kind); + assert.ok(kinds.includes("else"), "should include else path"); + assert.ok(!kinds.includes("then"), "should NOT include then path"); + }); + + test("constant wire — const bit is set", async () => { + const doc = getDoc(`version 1.5 +bridge Query.demo { + with api + with output as o + api.mode = "fast" + o.result <- api.label +}`); + const { executionTrace } = await executeBridge({ + document: doc, + operation: "Query.demo", + input: {}, + tools: { api: async () => ({ label: "done" }) }, + }); + + const bridge = doc.instructions.find( + (i): i is Bridge => i.kind === "bridge", + )!; + const manifest = buildTraversalManifest(bridge); + const decoded = decodeExecutionTrace(manifest, executionTrace); + const kinds = decoded.map((e) => e.kind); + assert.ok(kinds.includes("const"), "should include const path"); + }); + + test("executionTrace is a bigint suitable for hex encoding", async () => { + const doc = getDoc(`version 1.5 +bridge Query.demo { + with api + with input as i + with output as o + api.q <- i.q + o.result <- api.label +}`); + const { executionTrace } = await executeBridge({ + document: doc, + operation: "Query.demo", + input: { q: "Berlin" }, + tools: { api: async () => ({ label: "Berlin" }) }, + }); + + assert.equal(typeof executionTrace, "bigint"); + const hex = `0x${executionTrace.toString(16)}`; + assert.ok(hex.startsWith("0x"), "should be hex-encodable"); + }); +}); diff --git a/packages/bridge/test/source-locations.test.ts b/packages/bridge/test/source-locations.test.ts index cc820944..574c386d 100644 --- a/packages/bridge/test/source-locations.test.ts +++ b/packages/bridge/test/source-locations.test.ts @@ -106,4 +106,58 @@ bridge Query.test { assert.equal(messageWire.catchLoc?.startLine, 6); assert.equal(messageWire.catchLoc?.startColumn, 66); }); + + it("element scope wires in nested blocks carry source locations", () => { + const bridge = getBridge(`version 1.5 +bridge Query.test { + with input as i + with output as o + o.legs <- i.legs[] as s { + .destination { + .station { + .id <- s.arrival.station.id + .name <- s.arrival.station.name + } + .plannedTime <- s.arrival.arrival + .delayMinutes <- s.arrival.delay || 0 + } + } +}`); + + const destinationIdWire = bridge.wires.find( + (wire) => + "to" in wire && + wire.to.path.join(".") === "legs.destination.station.id", + ); + assertLoc(destinationIdWire, 8, 9); + assert.ok(destinationIdWire && "from" in destinationIdWire); + assert.equal(destinationIdWire.fromLoc?.startLine, 8); + assert.equal(destinationIdWire.fromLoc?.startColumn, 16); + + const destinationPlannedTimeWire = bridge.wires.find( + (wire) => + "to" in wire && + wire.to.path.join(".") === "legs.destination.plannedTime", + ); + assertLoc(destinationPlannedTimeWire, 11, 7); + assert.ok( + destinationPlannedTimeWire && "from" in destinationPlannedTimeWire, + ); + assert.equal(destinationPlannedTimeWire.fromLoc?.startLine, 11); + assert.equal(destinationPlannedTimeWire.fromLoc?.startColumn, 23); + + const destinationDelayWire = bridge.wires.find( + (wire) => + "to" in wire && + wire.to.path.join(".") === "legs.destination.delayMinutes", + ); + assert.ok( + destinationDelayWire && + "from" in destinationDelayWire && + "fallbacks" in destinationDelayWire, + ); + assertLoc(destinationDelayWire, 12, 7); + assert.equal(destinationDelayWire.fallbacks?.[0]?.loc?.startLine, 12); + assert.equal(destinationDelayWire.fallbacks?.[0]?.loc?.startColumn, 43); + }); }); diff --git a/packages/docs-site/src/content/docs/advanced/trace-id.mdx b/packages/docs-site/src/content/docs/advanced/trace-id.mdx new file mode 100644 index 00000000..86ed40c6 --- /dev/null +++ b/packages/docs-site/src/content/docs/advanced/trace-id.mdx @@ -0,0 +1,97 @@ +--- +title: Execution Trace IDs +description: Deterministic, zero-data representation of a bridge's execution path. +--- + +import { Aside } from "@astrojs/starlight/components"; + +Trace IDs provide a deterministic, zero-data representation of a bridge's execution path. Returned as a compact integer or hex string (e.g., `0x2a`), the ID encodes every control flow decision—such as fallbacks, ternaries, and error catches—made during a request. + +Because Trace IDs contain only topological data (which code branches executed) and zero runtime values, they are completely free of Personally Identifiable Information (PII) and are safe to log to external observability platforms. + +## Architecture & Mechanics + +Bridge utilizes a statically analyzable, pull-based graph. The Trace ID relies on the separation of a **Static Manifest** and a **Runtime Bitmask**. + +### 1. The Static Manifest + +At build time, the compiler evaluates the AST of a `.bridge` file and assigns a strict, zero-indexed integer to every possible branching path (e.g., `primary`, `fallback`, `catch`, `then`, `else`). + +For example, a ternary operation generates two distinct indices in the manifest: + +```bridge +o.price <- i.isPro ? i.proPrice : i.basicPrice +# Index 4: i.proPrice (then) +# Index 5: i.basicPrice (else) + +``` + +### 2. The Runtime Bitmask + +During execution, the `ExecutionTree` maintains a shared numeric mask initialized to `0`. As the engine resolves wires, it performs a bitwise `OR` operation to flip the bit corresponding to the executed path index. + +At the end of the request, the final integer is returned as the Trace ID. + +### 3. O(1) Array Coverage Masking + +For array iterations (`items[] as item`), the trace acts as a **Coverage Bitmask**. Instead of appending a new trace for every element, the engine simply records which paths were taken *at least once* across the entire array. This guarantees that processing an array of 10,000 items produces a Trace ID of the exact same byte size as an array of 1 item. + +--- + +## Runtime Usage + +The Trace ID is returned alongside your standard data payload from the `executeBridge` function. + +```typescript +import { executeBridge } from "@stackables/bridge-core"; + +const { data, traceId } = await executeBridge({ + bridge: Query.pricing, + input: { isPro: false } +}); + +// data: { tier: "basic", discount: 5, price: 9.99 } +// traceId: 42 (or "0x2a") + +// Safe to log without scrubbing PII +logger.info("Bridge executed", { + operation: "Query.pricing", + trace: traceId +}); + +``` + +### Telemetry Bucketing + +Because every `.bridge` file has a finite, mathematically bounded number of execution paths, Trace IDs serve as perfect bucketing keys in platforms like Datadog or Sentry. You can group logs by Trace ID to instantly monitor the distribution of "happy path" requests versus fallback/error paths. + +### API Reference: Decoding Traces + +If you are building custom tooling or UI visualizers, you can decode a trace programmatically using the core library: + +```typescript +import { + enumerateTraversalIds, + decodeExecutionTrace +} from "@stackables/bridge-core"; + +// 1. Generate the static map of all branches +const manifest = enumerateTraversalIds(bridgeAst); + +// 2. Decode the runtime trace against the manifest +const activePaths = decodeExecutionTrace(manifest, 42); + +console.log(activePaths); +/* Returns: +[ + { target: ["tier"], kind: "else", label: '"basic"' }, + { target: ["discount"], kind: "else", label: '5' }, + ... +] +*/ + +``` + + diff --git a/packages/playground/src/Playground.tsx b/packages/playground/src/Playground.tsx index cdb6104c..b158e95d 100644 --- a/packages/playground/src/Playground.tsx +++ b/packages/playground/src/Playground.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef } from "react"; +import { useState, useCallback, useRef, useMemo } from "react"; import { Panel, Group, @@ -11,6 +11,7 @@ import { StandaloneQueryPanel } from "./components/StandaloneQueryPanel"; import { clearHttpCache } from "./engine"; import type { RunResult, BridgeOperation, OutputFieldNode } from "./engine"; import type { GraphQLSchema } from "graphql"; +import type { SourceLocation } from "@stackables/bridge"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import type { PlaygroundMode } from "./share"; @@ -22,8 +23,8 @@ function ResizeHandle({ direction }: { direction: "horizontal" | "vertical" }) { className={cn( "shrink-0 outline-none", direction === "horizontal" - ? "w-2 cursor-[col-resize]" - : "h-2 cursor-[row-resize]", + ? "w-2 cursor-col-resize" + : "h-2 cursor-row-resize", )} /> ); @@ -217,13 +218,47 @@ function QueryTabBar({ ); } -// ── bridge DSL panel header (label only) ───────────────────────────────────── -function BridgeDslHeader() { +// ── bridge DSL header with optional trace badge ───────────────────────────── +function BridgeDslHeader({ + executionTrace, + onClearExecutionTrace, +}: { + executionTrace?: bigint; + onClearExecutionTrace?: () => void; +}) { + const hasTrace = executionTrace != null && executionTrace > 0n; return ( -
- +
+ Bridge DSL + {hasTrace && ( + + trace-id 0x{executionTrace.toString(16)} + {onClearExecutionTrace && ( + + )} + + )}
); } @@ -273,6 +308,67 @@ function SchemaHeader({ ); } +import { getTraversalManifest, decodeExecutionTrace } from "./engine"; + +function getInactiveTraversalLocations( + bridge: string, + operation: string, + executionTrace?: bigint, +): SourceLocation[] { + if (!operation || executionTrace == null || executionTrace === 0n) { + return []; + } + + const manifest = getTraversalManifest(bridge, operation); + if (manifest.length === 0) return []; + + const activeIds = new Set( + decodeExecutionTrace(manifest, executionTrace).map((entry) => entry.id), + ); + + // Group entries by wire (wireIndex). + const wireGroups = new Map(); + for (const entry of manifest) { + let group = wireGroups.get(entry.wireIndex); + if (!group) { + group = []; + wireGroups.set(entry.wireIndex, group); + } + group.push(entry); + } + + const seen = new Set(); + const result: SourceLocation[] = []; + + for (const entries of wireGroups.values()) { + const allDead = entries.every((e) => !activeIds.has(e.id)); + + if (allDead) { + // When all branches of a wire are dead, use the full wire span + // so the entire line (including the target on the left of <-) is dimmed. + const wl = entries[0]?.wireLoc ?? entries[0]?.loc; + if (wl) { + const key = `${wl.startLine}:${wl.startColumn}:${wl.endLine}:${wl.endColumn}`; + if (!seen.has(key)) { + seen.add(key); + result.push(wl); + } + } + } else { + // Only some branches are dead — use individual entry locs. + for (const entry of entries) { + if (activeIds.has(entry.id) || !entry.loc) continue; + const key = `${entry.loc.startLine}:${entry.loc.startColumn}:${entry.loc.endLine}:${entry.loc.endColumn}`; + if (seen.has(key)) continue; + seen.add(key); + result.push(entry.loc); + } + } + } + + return result; +} + // ── panel wrapper ───────────────────────────────────────────────────────────── function PanelBox({ children }: { children: React.ReactNode }) { return ( @@ -323,6 +419,7 @@ export type PlaygroundProps = { bridgeOperations: BridgeOperation[]; availableOutputFields: OutputFieldNode[]; hideGqlSwitch?: boolean; + onClearExecutionTrace?: () => void; }; export function Playground({ @@ -352,6 +449,7 @@ export function Playground({ bridgeOperations, availableOutputFields, hideGqlSwitch, + onClearExecutionTrace, }: PlaygroundProps) { const hLayout = useDefaultLayout({ id: "bridge-playground-h" }); const leftVLayout = useDefaultLayout({ id: "bridge-playground-left-v" }); @@ -360,6 +458,23 @@ export function Playground({ const activeQuery = queries.find((q) => q.id === activeTabId); const isStandalone = mode === "standalone"; + // Determine which operation to use for manifest + const manifestOperation = useMemo(() => { + if (isStandalone && activeQuery?.operation) return activeQuery.operation; + if (bridgeOperations.length > 0) return bridgeOperations[0].label; + return ""; + }, [isStandalone, activeQuery?.operation, bridgeOperations]); + + const inactiveTraversalLocations = useMemo( + () => + getInactiveTraversalLocations( + bridge, + manifestOperation, + displayResult?.executionTrace, + ), + [bridge, manifestOperation, displayResult?.executionTrace], + ); + return ( <> {/* ── Mobile layout: vertical scrollable stack ── */} @@ -395,13 +510,17 @@ export function Playground({ {/* Bridge DSL panel */}
- +
@@ -519,13 +638,17 @@ export function Playground({
)} - +
@@ -563,13 +686,17 @@ export function Playground({ {/* Bridge DSL panel */} - +
diff --git a/packages/playground/src/codemirror/dead-code.ts b/packages/playground/src/codemirror/dead-code.ts new file mode 100644 index 00000000..d428936b --- /dev/null +++ b/packages/playground/src/codemirror/dead-code.ts @@ -0,0 +1,59 @@ +import { RangeSetBuilder, type Extension } from "@codemirror/state"; +import { Decoration, EditorView } from "@codemirror/view"; +import type { SourceLocation } from "@stackables/bridge"; + +function toOffsets(state: EditorView["state"], loc: SourceLocation) { + if (state.doc.lines === 0) return null; + + const startLine = Math.min(Math.max(loc.startLine, 1), state.doc.lines); + const endLine = Math.min(Math.max(loc.endLine, startLine), state.doc.lines); + const startInfo = state.doc.line(startLine); + const endInfo = state.doc.line(endLine); + + const from = + startInfo.from + + Math.min(Math.max(loc.startColumn - 1, 0), startInfo.length); + let to = endInfo.from + Math.min(Math.max(loc.endColumn, 0), endInfo.length); + + if (to <= from) { + to = Math.min(from + 1, state.doc.length); + } + + if (to <= from) return null; + return { from, to }; +} + +export function deadCodeRangesExtension( + locations: SourceLocation[], +): Extension { + if (locations.length === 0) return []; + + const mark = Decoration.mark({ class: "cm-dead-code-range" }); + + return EditorView.decorations.compute([], (state) => { + // Convert to offsets and sort by start position. + const ranges: { from: number; to: number }[] = []; + for (const location of locations) { + const offsets = toOffsets(state, location); + if (offsets) ranges.push(offsets); + } + ranges.sort((a, b) => a.from - b.from || a.to - b.to); + + // Merge overlapping ranges to prevent stacking decorations. + const merged: { from: number; to: number }[] = []; + for (const r of ranges) { + const last = merged[merged.length - 1]; + if (last && r.from <= last.to) { + last.to = Math.max(last.to, r.to); + } else { + merged.push({ from: r.from, to: r.to }); + } + } + + const builder = new RangeSetBuilder(); + for (const { from, to } of merged) { + builder.add(from, to, mark); + } + return builder.finish(); + }); +} diff --git a/packages/playground/src/codemirror/theme.ts b/packages/playground/src/codemirror/theme.ts index 78958830..88678b64 100644 --- a/packages/playground/src/codemirror/theme.ts +++ b/packages/playground/src/codemirror/theme.ts @@ -20,27 +20,31 @@ import { tags } from "@lezer/highlight"; const theme = EditorView.theme( { "&": { - color: "#cbd5e1", // slate-300 + color: "#cbd5e1", // slate-300 backgroundColor: "transparent", fontSize: "13px", - fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + fontFamily: + "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", }, ".cm-content": { - caretColor: "#38bdf8", // sky-400 + caretColor: "#38bdf8", // sky-400 lineHeight: "1.625", padding: "8px 0", }, "&.cm-focused .cm-cursor": { borderLeftColor: "#38bdf8" }, "&.cm-focused .cm-selectionBackground, .cm-selectionBackground": { - backgroundColor: "#334155", // slate-700 + backgroundColor: "#334155", // slate-700 }, "&.cm-focused": { outline: "none" }, ".cm-gutters": { backgroundColor: "transparent", borderRight: "none", - color: "#475569", // slate-600 + color: "#475569", // slate-600 + }, + ".cm-activeLineGutter": { + backgroundColor: "transparent", + color: "#64748b", }, - ".cm-activeLineGutter": { backgroundColor: "transparent", color: "#64748b" }, ".cm-activeLine": { backgroundColor: "rgba(51, 65, 85, 0.3)" }, ".cm-matchingBracket": { backgroundColor: "rgba(56, 189, 248, 0.15)", @@ -51,73 +55,78 @@ const theme = EditorView.theme( padding: "3px 6px 3px 8px", marginLeft: "-1px", fontSize: "12px", - fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", + fontFamily: + "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", }, ".cm-diagnostic-error": { - borderLeftColor: "#f87171", // red-400 - color: "#fca5a5", // red-300 + borderLeftColor: "#f87171", // red-400 + color: "#fca5a5", // red-300 backgroundColor: "#1e1215", }, ".cm-diagnostic-warning": { - borderLeftColor: "#facc15", // yellow-400 - color: "#fde68a", // amber-200 + borderLeftColor: "#facc15", // yellow-400 + color: "#fde68a", // amber-200 backgroundColor: "#1a1a0e", }, ".cm-lint-marker-error": { content: "'●'", color: "#f87171" }, ".cm-lint-marker-warning": { content: "'●'", color: "#facc15" }, ".cm-tooltip-lint": { - backgroundColor: "#0f172a", // slate-900 - border: "1px solid #334155", // slate-700 + backgroundColor: "#0f172a", // slate-900 + border: "1px solid #334155", // slate-700 }, // ── Autocomplete ───────────────────────────────────────────────── ".cm-tooltip-autocomplete": { - backgroundColor: "#0f172a", // slate-900 - border: "1px solid #334155", // slate-700 + backgroundColor: "#0f172a", // slate-900 + border: "1px solid #334155", // slate-700 }, ".cm-tooltip-autocomplete ul li": { - color: "#cbd5e1", // slate-300 + color: "#cbd5e1", // slate-300 }, ".cm-tooltip-autocomplete ul li[aria-selected]": { - backgroundColor: "#1e293b", // slate-800 - color: "#f1f5f9", // slate-100 + backgroundColor: "#1e293b", // slate-800 + color: "#f1f5f9", // slate-100 }, ".cm-completionLabel": { fontSize: "13px", }, ".cm-completionDetail": { - color: "#64748b", // slate-500 + color: "#64748b", // slate-500 fontStyle: "italic", }, + ".cm-dead-code-range": { + color: "#64748b", + opacity: "0.45", + }, }, { dark: true }, ); const highlights = HighlightStyle.define([ // Keywords: bridge, tool, with, const, define, version, as, from, on error - { tag: tags.keyword, color: "#38bdf8" }, // sky-400 + { tag: tags.keyword, color: "#38bdf8" }, // sky-400 // Types: Query, Mutation - { tag: tags.typeName, color: "#34d399" }, // emerald-400 + { tag: tags.typeName, color: "#34d399" }, // emerald-400 // Definitions: field names, tool names, handle aliases { tag: tags.definition(tags.variableName), color: "#fbbf24" }, // amber-400 // General variables / identifiers - { tag: tags.variableName, color: "#cbd5e1" }, // slate-300 + { tag: tags.variableName, color: "#cbd5e1" }, // slate-300 // Built-in handles: input, output, context { tag: tags.standard(tags.variableName), color: "#a78bfa" }, // violet-400 // Properties: .baseUrl, .headers.Authorization - { tag: tags.propertyName, color: "#fdba74" }, // orange-300 + { tag: tags.propertyName, color: "#fdba74" }, // orange-300 // Operators: <-, <-!, =, ||, ??, : - { tag: tags.operator, color: "#f472b6" }, // pink-400 + { tag: tags.operator, color: "#f472b6" }, // pink-400 // Atoms: true, false, null, GET, POST, … - { tag: tags.atom, color: "#c084fc" }, // purple-400 + { tag: tags.atom, color: "#c084fc" }, // purple-400 // Numbers - { tag: tags.number, color: "#fb923c" }, // orange-400 + { tag: tags.number, color: "#fb923c" }, // orange-400 // Strings - { tag: tags.string, color: "#4ade80" }, // green-400 + { tag: tags.string, color: "#4ade80" }, // green-400 { tag: tags.special(tags.string), color: "#86efac" }, // green-300 (url paths) // Comments - { tag: tags.comment, color: "#475569" }, // slate-600 + { tag: tags.comment, color: "#475569" }, // slate-600 // Brackets - { tag: tags.bracket, color: "#64748b" }, // slate-500 + { tag: tags.bracket, color: "#64748b" }, // slate-500 ]); export const playgroundTheme = [theme, syntaxHighlighting(highlights)]; diff --git a/packages/playground/src/components/Editor.tsx b/packages/playground/src/components/Editor.tsx index c59f78b1..3626ee8c 100644 --- a/packages/playground/src/components/Editor.tsx +++ b/packages/playground/src/components/Editor.tsx @@ -7,10 +7,12 @@ import { diagnosticCount, lintGutter } from "@codemirror/lint"; import { json } from "@codemirror/lang-json"; import { graphql, graphqlLanguageSupport, updateSchema } from "cm6-graphql"; import type { GraphQLSchema } from "graphql"; +import type { SourceLocation } from "@stackables/bridge"; import { Paintbrush } from "lucide-react"; import { bridgeLanguage } from "@/codemirror/bridge-lang"; import { bridgeLinter } from "@/codemirror/bridge-lint"; import { bridgeAutocomplete } from "@/codemirror/bridge-completion"; +import { deadCodeRangesExtension } from "@/codemirror/dead-code"; import { graphqlSchemaLinter } from "@/codemirror/graphql-schema-lint"; import { playgroundTheme } from "@/codemirror/theme"; import { cn } from "@/lib/utils"; @@ -43,6 +45,8 @@ type Props = { graphqlSchema?: GraphQLSchema; /** Optional callback to format the code. When provided, shows a format button. */ onFormat?: () => void; + /** Source ranges to render as dimmed dead code. */ + deadCodeLocations?: SourceLocation[]; }; function languageExtension( @@ -72,6 +76,7 @@ export function Editor({ autoHeight = false, graphqlSchema, onFormat, + deadCodeLocations = [], }: Props) { const containerRef = useRef(null); const viewRef = useRef(null); @@ -95,6 +100,7 @@ export function Editor({ // Compartment lets us toggle readOnly after creation (e.g. when result arrives) const readOnlyCompartment = useRef(new Compartment()); + const deadCodeCompartment = useRef(new Compartment()); // Create the editor once useEffect(() => { @@ -112,6 +118,9 @@ export function Editor({ EditorState.readOnly.of(readOnly), EditorView.editable.of(!readOnly), ]), + deadCodeCompartment.current.of( + deadCodeRangesExtension(deadCodeLocations), + ), ], }); @@ -144,6 +153,17 @@ export function Editor({ } }, [value]); + useEffect(() => { + const view = viewRef.current; + if (!view) return; + + view.dispatch({ + effects: deadCodeCompartment.current.reconfigure( + deadCodeRangesExtension(deadCodeLocations), + ), + }); + }, [deadCodeLocations]); + return (
{label && ( diff --git a/packages/playground/src/engine.ts b/packages/playground/src/engine.ts index 83c1eb20..9eed4380 100644 --- a/packages/playground/src/engine.ts +++ b/packages/playground/src/engine.ts @@ -10,6 +10,8 @@ import { executeBridge, formatBridgeError, prettyPrintToSource, + buildTraversalManifest, + decodeExecutionTrace, } from "@stackables/bridge"; export { prettyPrintToSource }; import type { @@ -19,6 +21,7 @@ import type { ToolTrace, Logger, CacheStore, + TraversalEntry, } from "@stackables/bridge"; import { bridgeTransform, @@ -77,6 +80,8 @@ export type RunResult = { errors?: string[]; traces?: ToolTrace[]; logs?: LogEntry[]; + /** Compact bitmask encoding which traversal paths were taken during execution. */ + executionTrace?: bigint; }; export type DiagnosticResult = { @@ -660,8 +665,13 @@ export async function runBridgeStandalone( data: result.data, traces: result.traces.length > 0 ? result.traces : undefined, logs: logs.length > 0 ? logs : undefined, + executionTrace: result.executionTrace, }; } catch (err: unknown) { + const trace = + err && typeof err === "object" && "executionTrace" in err + ? (err as { executionTrace?: bigint }).executionTrace + : undefined; return { errors: [ formatBridgeError(err, { @@ -669,8 +679,50 @@ export async function runBridgeStandalone( filename: document.filename, }), ], + ...(trace != null ? { executionTrace: trace } : {}), }; } finally { _onCacheHit = null; } } + +// ── Traversal manifest helpers ────────────────────────────────────────────── + +export type { TraversalEntry }; + +/** + * Build the static traversal manifest for a bridge operation. + * + * Returns the ordered array of TraversalEntry objects describing every + * possible execution path through the bridge's wires. + */ +export function getTraversalManifest( + bridgeText: string, + operation: string, +): TraversalEntry[] { + try { + const { document } = parseBridgeDiagnostics(bridgeText, { + filename: "playground.bridge", + }); + const [type, field] = operation.split("."); + if (!type || !field) return []; + + const bridge = document.instructions.find( + (i): i is Bridge => + i.kind === "bridge" && i.type === type && i.field === field, + ); + if (!bridge) return []; + + return buildTraversalManifest(bridge); + } catch { + return []; + } +} + +/** + * Decode a runtime execution trace bitmask against a traversal manifest. + * + * Returns the subset of TraversalEntry objects whose bits are set in + * the trace — i.e. the paths that were actually taken during execution. + */ +export { decodeExecutionTrace }; diff --git a/packages/playground/src/usePlaygroundState.ts b/packages/playground/src/usePlaygroundState.ts index ac3070c2..5e45c408 100644 --- a/packages/playground/src/usePlaygroundState.ts +++ b/packages/playground/src/usePlaygroundState.ts @@ -129,6 +129,18 @@ export function usePlaygroundState( ? runningIds.has(displayQueryId) : false; + const clearExecutionTrace = useCallback(() => { + if (!displayQueryId) return; + setResults((prev) => { + const cur = prev[displayQueryId]; + if (!cur) return prev; + return { + ...prev, + [displayQueryId]: { ...cur, executionTrace: undefined }, + }; + }); + }, [displayQueryId]); + const activeQuery = queries.find((q) => q.id === activeTabId); const selectExample = useCallback( @@ -401,6 +413,7 @@ export function usePlaygroundState( hasErrors, isActiveRunning, onRun: handleRun, + onClearExecutionTrace: clearExecutionTrace, graphqlSchema, bridgeOperations, availableOutputFields, From a2f9c9b04eefa756c0b754ae6f7cf1d300675ff5 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Mon, 9 Mar 2026 12:46:59 +0100 Subject: [PATCH 3/4] Rename --- .../bridge-compiler/src/execute-bridge.ts | 8 +- .../bridge-core/src/enumerate-traversals.ts | 2 +- packages/bridge-core/src/execute-bridge.ts | 6 +- .../bridge/test/enumerate-traversals.test.ts | 102 +++++++++++------- .../src/content/docs/advanced/trace-id.mdx | 6 +- packages/playground/src/Playground.tsx | 46 ++++---- packages/playground/src/engine.ts | 10 +- packages/playground/src/usePlaygroundState.ts | 6 +- 8 files changed, 106 insertions(+), 80 deletions(-) diff --git a/packages/bridge-compiler/src/execute-bridge.ts b/packages/bridge-compiler/src/execute-bridge.ts index 408ca1ca..467b2359 100644 --- a/packages/bridge-compiler/src/execute-bridge.ts +++ b/packages/bridge-compiler/src/execute-bridge.ts @@ -84,7 +84,7 @@ export type ExecuteBridgeResult = { data: T; traces: ToolTrace[]; /** Compact bitmask encoding which traversal paths were taken during execution. */ - executionTrace: bigint; + executionTraceId: bigint; }; // ── Cache ─────────────────────────────────────────────────────────────────── @@ -340,5 +340,9 @@ export async function executeBridge( } catch (err) { throw attachBridgeErrorDocumentContext(err, document); } - return { data: data as T, traces: tracer?.traces ?? [], executionTrace: 0n }; + return { + data: data as T, + traces: tracer?.traces ?? [], + executionTraceId: 0n, + }; } diff --git a/packages/bridge-core/src/enumerate-traversals.ts b/packages/bridge-core/src/enumerate-traversals.ts index e1117599..3b3f9402 100644 --- a/packages/bridge-core/src/enumerate-traversals.ts +++ b/packages/bridge-core/src/enumerate-traversals.ts @@ -10,7 +10,7 @@ * (empty-array, primary for .data, nullish fallback for .data) * * The traversal manifest is a static analysis result. At runtime, the - * execution engine produces a compact numeric `executionTrace` (bitmask) + * execution engine produces a compact numeric `executionTraceId` (bitmask) * that records which traversal paths were actually taken. Use * {@link decodeExecutionTrace} to map the bitmask back to entries. */ diff --git a/packages/bridge-core/src/execute-bridge.ts b/packages/bridge-core/src/execute-bridge.ts index ea570942..3f68ce1f 100644 --- a/packages/bridge-core/src/execute-bridge.ts +++ b/packages/bridge-core/src/execute-bridge.ts @@ -79,7 +79,7 @@ export type ExecuteBridgeResult = { data: T; traces: ToolTrace[]; /** Compact bitmask encoding which traversal paths were taken during execution. */ - executionTrace: bigint; + executionTraceId: bigint; }; /** @@ -170,7 +170,7 @@ export async function executeBridge( data = await tree.run(input, options.requestedFields); } catch (err) { if (err && typeof err === "object") { - (err as { executionTrace?: bigint }).executionTrace = + (err as { executionTraceId?: bigint }).executionTraceId = tree.getExecutionTrace(); } throw attachBridgeErrorDocumentContext(err, doc); @@ -179,6 +179,6 @@ export async function executeBridge( return { data: data as T, traces: tree.getTraces(), - executionTrace: tree.getExecutionTrace(), + executionTraceId: tree.getExecutionTrace(), }; } diff --git a/packages/bridge/test/enumerate-traversals.test.ts b/packages/bridge/test/enumerate-traversals.test.ts index f53fce15..99a39659 100644 --- a/packages/bridge/test/enumerate-traversals.test.ts +++ b/packages/bridge/test/enumerate-traversals.test.ts @@ -7,13 +7,15 @@ import { decodeExecutionTrace, executeBridge, } from "@stackables/bridge-core"; -import type { Bridge, TraversalEntry, BridgeDocument } from "@stackables/bridge-core"; +import type { + Bridge, + TraversalEntry, + BridgeDocument, +} from "@stackables/bridge-core"; function getBridge(source: string): Bridge { const doc = parseBridge(source); - const bridge = doc.instructions.find( - (i): i is Bridge => i.kind === "bridge", - ); + const bridge = doc.instructions.find((i): i is Bridge => i.kind === "bridge"); assert.ok(bridge, "expected a bridge instruction"); return bridge; } @@ -71,8 +73,8 @@ bridge Query.demo { o.label <- a.label || b.label }`); const entries = enumerateTraversalIds(bridge); - const labelEntries = entries.filter((e) => - e.target.includes("label") && e.target.length === 1, + const labelEntries = entries.filter( + (e) => e.target.includes("label") && e.target.length === 1, ); assert.equal(labelEntries.length, 2); assert.equal(labelEntries[0].kind, "primary"); @@ -91,8 +93,8 @@ bridge Query.demo { o.label <- api.label ?? "default" }`); const entries = enumerateTraversalIds(bridge); - const labelEntries = entries.filter((e) => - e.target.includes("label") && e.target.length === 1, + const labelEntries = entries.filter( + (e) => e.target.includes("label") && e.target.length === 1, ); assert.equal(labelEntries.length, 2); assert.equal(labelEntries[0].kind, "primary"); @@ -112,8 +114,8 @@ bridge Query.demo { o.label <- a.label || b.label || "fallback" }`); const entries = enumerateTraversalIds(bridge); - const labelEntries = entries.filter((e) => - e.target.includes("label") && e.target.length === 1, + const labelEntries = entries.filter( + (e) => e.target.includes("label") && e.target.length === 1, ); assert.equal(labelEntries.length, 3); assert.equal(labelEntries[0].kind, "primary"); @@ -135,8 +137,8 @@ bridge Query.demo { o.lat <- api.lat catch 0 }`); const entries = enumerateTraversalIds(bridge); - const latEntries = entries.filter((e) => - e.target.includes("lat") && e.target.length === 1, + const latEntries = entries.filter( + (e) => e.target.includes("lat") && e.target.length === 1, ); assert.equal(latEntries.length, 2); assert.equal(latEntries[0].kind, "primary"); @@ -157,8 +159,8 @@ bridge Query.demo { o.result <- a.value || b.value catch i.fallback }`); const entries = enumerateTraversalIds(bridge); - const resultEntries = entries.filter((e) => - e.target.includes("result") && e.target.length === 1, + const resultEntries = entries.filter( + (e) => e.target.includes("result") && e.target.length === 1, ); assert.equal(resultEntries.length, 3); assert.equal(resultEntries[0].kind, "primary"); @@ -245,7 +247,11 @@ bridge Query.demo { const entries = enumerateTraversalIds(bridge); const allIds = ids(entries); const unique = new Set(allIds); - assert.equal(unique.size, allIds.length, `IDs must be unique: ${JSON.stringify(allIds)}`); + assert.equal( + unique.size, + allIds.length, + `IDs must be unique: ${JSON.stringify(allIds)}`, + ); }); // ── TraversalEntry shape ────────────────────────────────────────────────── @@ -284,8 +290,8 @@ bridge Query.demo { o.label <- i.flag ? api.a : api.b }`); const entries = enumerateTraversalIds(bridge); - const labelEntries = entries.filter((e) => - e.target.includes("label") && e.target.length === 1, + const labelEntries = entries.filter( + (e) => e.target.includes("label") && e.target.length === 1, ); assert.ok(labelEntries.length >= 2, "at least then + else"); const then = labelEntries.find((e) => e.kind === "then"); @@ -347,7 +353,11 @@ bridge Query.demo { }`); const manifest = buildTraversalManifest(bridge); for (let i = 0; i < manifest.length; i++) { - assert.equal(manifest[i].bitIndex, i, `entry ${i} should have bitIndex ${i}`); + assert.equal( + manifest[i].bitIndex, + i, + `entry ${i} should have bitIndex ${i}`, + ); } }); }); @@ -379,9 +389,14 @@ bridge Query.demo { o.result <- api.label || "fallback" }`); const manifest = buildTraversalManifest(bridge); - const primary = manifest.find((e) => e.kind === "primary" && e.target.includes("result")); + const primary = manifest.find( + (e) => e.kind === "primary" && e.target.includes("result"), + ); assert.ok(primary); - const result = decodeExecutionTrace(manifest, 1n << BigInt(primary.bitIndex)); + const result = decodeExecutionTrace( + manifest, + 1n << BigInt(primary.bitIndex), + ); assert.equal(result.length, 1); assert.equal(result[0].id, primary.id); }); @@ -428,7 +443,10 @@ bridge Query.demo { const manifest = buildTraversalManifest(bridge); const thenEntry = manifest.find((e) => e.kind === "then"); assert.ok(thenEntry); - const decoded = decodeExecutionTrace(manifest, 1n << BigInt(thenEntry.bitIndex)); + const decoded = decodeExecutionTrace( + manifest, + 1n << BigInt(thenEntry.bitIndex), + ); assert.equal(decoded.length, 1); assert.equal(decoded[0].kind, "then"); }); @@ -441,7 +459,7 @@ function getDoc(source: string): BridgeDocument { return JSON.parse(JSON.stringify(raw)) as BridgeDocument; } -describe("executionTrace: end-to-end", () => { +describe("executionTraceId: end-to-end", () => { test("simple pull wire — primary bits are set", async () => { const doc = getDoc(`version 1.5 bridge Query.demo { @@ -451,21 +469,21 @@ bridge Query.demo { api.q <- i.q o.result <- api.label }`); - const { executionTrace } = await executeBridge({ + const { executionTraceId } = await executeBridge({ document: doc, operation: "Query.demo", input: { q: "test" }, tools: { api: async () => ({ label: "Hello" }) }, }); - assert.ok(executionTrace > 0n, "trace should have bits set"); + assert.ok(executionTraceId > 0n, "trace should have bits set"); // Decode and verify const bridge = doc.instructions.find( (i): i is Bridge => i.kind === "bridge", )!; const manifest = buildTraversalManifest(bridge); - const decoded = decodeExecutionTrace(manifest, executionTrace); + const decoded = decodeExecutionTrace(manifest, executionTraceId); const kinds = decoded.map((e) => e.kind); assert.ok(kinds.includes("primary"), "should include primary paths"); }); @@ -479,7 +497,7 @@ bridge Query.demo { api.q <- i.q o.label <- api.label || "default" }`); - const { executionTrace, data } = await executeBridge({ + const { executionTraceId, data } = await executeBridge({ document: doc, operation: "Query.demo", input: { q: "test" }, @@ -492,7 +510,7 @@ bridge Query.demo { (i): i is Bridge => i.kind === "bridge", )!; const manifest = buildTraversalManifest(bridge); - const decoded = decodeExecutionTrace(manifest, executionTrace); + const decoded = decodeExecutionTrace(manifest, executionTraceId); const kinds = decoded.map((e) => e.kind); assert.ok(kinds.includes("fallback"), "should include fallback path"); }); @@ -506,11 +524,15 @@ bridge Query.demo { api.q <- i.q o.lat <- api.lat catch 0 }`); - const { executionTrace, data } = await executeBridge({ + const { executionTraceId, data } = await executeBridge({ document: doc, operation: "Query.demo", input: { q: "test" }, - tools: { api: async () => { throw new Error("boom"); } }, + tools: { + api: async () => { + throw new Error("boom"); + }, + }, }); assert.equal((data as any).lat, 0); @@ -519,7 +541,7 @@ bridge Query.demo { (i): i is Bridge => i.kind === "bridge", )!; const manifest = buildTraversalManifest(bridge); - const decoded = decodeExecutionTrace(manifest, executionTrace); + const decoded = decodeExecutionTrace(manifest, executionTraceId); const kinds = decoded.map((e) => e.kind); assert.ok(kinds.includes("catch"), "should include catch path"); }); @@ -533,7 +555,7 @@ bridge Query.demo { api.q <- i.q o.label <- i.flag ? api.a : api.b }`); - const { executionTrace } = await executeBridge({ + const { executionTraceId } = await executeBridge({ document: doc, operation: "Query.demo", input: { q: "test", flag: true }, @@ -544,7 +566,7 @@ bridge Query.demo { (i): i is Bridge => i.kind === "bridge", )!; const manifest = buildTraversalManifest(bridge); - const decoded = decodeExecutionTrace(manifest, executionTrace); + const decoded = decodeExecutionTrace(manifest, executionTraceId); const kinds = decoded.map((e) => e.kind); assert.ok(kinds.includes("then"), "should include then path"); assert.ok(!kinds.includes("else"), "should NOT include else path"); @@ -559,7 +581,7 @@ bridge Query.demo { api.q <- i.q o.label <- i.flag ? api.a : api.b }`); - const { executionTrace } = await executeBridge({ + const { executionTraceId } = await executeBridge({ document: doc, operation: "Query.demo", input: { q: "test", flag: false }, @@ -570,7 +592,7 @@ bridge Query.demo { (i): i is Bridge => i.kind === "bridge", )!; const manifest = buildTraversalManifest(bridge); - const decoded = decodeExecutionTrace(manifest, executionTrace); + const decoded = decodeExecutionTrace(manifest, executionTraceId); const kinds = decoded.map((e) => e.kind); assert.ok(kinds.includes("else"), "should include else path"); assert.ok(!kinds.includes("then"), "should NOT include then path"); @@ -584,7 +606,7 @@ bridge Query.demo { api.mode = "fast" o.result <- api.label }`); - const { executionTrace } = await executeBridge({ + const { executionTraceId } = await executeBridge({ document: doc, operation: "Query.demo", input: {}, @@ -595,12 +617,12 @@ bridge Query.demo { (i): i is Bridge => i.kind === "bridge", )!; const manifest = buildTraversalManifest(bridge); - const decoded = decodeExecutionTrace(manifest, executionTrace); + const decoded = decodeExecutionTrace(manifest, executionTraceId); const kinds = decoded.map((e) => e.kind); assert.ok(kinds.includes("const"), "should include const path"); }); - test("executionTrace is a bigint suitable for hex encoding", async () => { + test("executionTraceId is a bigint suitable for hex encoding", async () => { const doc = getDoc(`version 1.5 bridge Query.demo { with api @@ -609,15 +631,15 @@ bridge Query.demo { api.q <- i.q o.result <- api.label }`); - const { executionTrace } = await executeBridge({ + const { executionTraceId } = await executeBridge({ document: doc, operation: "Query.demo", input: { q: "Berlin" }, tools: { api: async () => ({ label: "Berlin" }) }, }); - assert.equal(typeof executionTrace, "bigint"); - const hex = `0x${executionTrace.toString(16)}`; + assert.equal(typeof executionTraceId, "bigint"); + const hex = `0x${executionTraceId.toString(16)}`; assert.ok(hex.startsWith("0x"), "should be hex-encodable"); }); }); diff --git a/packages/docs-site/src/content/docs/advanced/trace-id.mdx b/packages/docs-site/src/content/docs/advanced/trace-id.mdx index 86ed40c6..8835121a 100644 --- a/packages/docs-site/src/content/docs/advanced/trace-id.mdx +++ b/packages/docs-site/src/content/docs/advanced/trace-id.mdx @@ -45,18 +45,18 @@ The Trace ID is returned alongside your standard data payload from the `executeB ```typescript import { executeBridge } from "@stackables/bridge-core"; -const { data, traceId } = await executeBridge({ +const { data, executionTraceId } = await executeBridge({ bridge: Query.pricing, input: { isPro: false } }); // data: { tier: "basic", discount: 5, price: 9.99 } -// traceId: 42 (or "0x2a") +// executionTraceId: 42 (or "0x2a") // Safe to log without scrubbing PII logger.info("Bridge executed", { operation: "Query.pricing", - trace: traceId + trace: executionTraceId }); ``` diff --git a/packages/playground/src/Playground.tsx b/packages/playground/src/Playground.tsx index b158e95d..a228f432 100644 --- a/packages/playground/src/Playground.tsx +++ b/packages/playground/src/Playground.tsx @@ -220,13 +220,13 @@ function QueryTabBar({ // ── bridge DSL header with optional trace badge ───────────────────────────── function BridgeDslHeader({ - executionTrace, - onClearExecutionTrace, + executionTraceId, + onClearExecutionTraceId, }: { - executionTrace?: bigint; - onClearExecutionTrace?: () => void; + executionTraceId?: bigint; + onClearExecutionTraceId?: () => void; }) { - const hasTrace = executionTrace != null && executionTrace > 0n; + const hasTrace = executionTraceId != null && executionTraceId > 0n; return (
@@ -234,14 +234,14 @@ function BridgeDslHeader({ {hasTrace && ( - trace-id 0x{executionTrace.toString(16)} - {onClearExecutionTrace && ( + trace-id 0x{executionTraceId.toString(16)} + {onClearExecutionTraceId && (