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..467b2359 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. */ + executionTraceId: bigint; }; // ── Cache ─────────────────────────────────────────────────────────────────── @@ -338,5 +340,9 @@ 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 ?? [], + executionTraceId: 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 new file mode 100644 index 00000000..3b3f9402 --- /dev/null +++ b/packages/bridge-core/src/enumerate-traversals.ts @@ -0,0 +1,514 @@ +/** + * 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) + * + * The traversal manifest is a static analysis result. At runtime, the + * 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. + */ + +import type { + Bridge, + Wire, + WireFallback, + NodeRef, + ControlFlowInstruction, + SourceLocation, +} 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"; + /** 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 ───────────────────────────────────────────────────────────────── + +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); +} + +// ── 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[], + 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({ + id: `${base}/fallback:${i}`, + wireIndex, + target, + 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), + }); + } +} + +function addCatchEntry( + entries: TraversalEntry[], + base: string, + wireIndex: number, + target: string[], + w: Wire, + hmap: Map, +): void { + if (hasCatch(w)) { + 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), + }); + } +} + +// ── 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. + * + * `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). + const targetCounts = new Map(); + + for (let i = 0; i < bridge.wires.length; i++) { + const w = bridge.wires[i]; + const target = effectiveTarget(w); + 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", + bitIndex: -1, + loc: w.loc, + wireLoc: w.loc, + description: `= ${w.value}`, + }); + 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", + bitIndex: -1, + loc: primaryLoc(w), + wireLoc: w.loc, + description: refLabel(w.from, hmap), + }); + addFallbackEntries(entries, base, i, target, w, hmap); + addCatchEntry(entries, base, i, target, w, hmap); + } + continue; + } + + // ── Conditional (ternary) wire ────────────────────────────────── + if ("cond" in 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) ─────────────────────────── + if ("condAnd" in 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; + 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 iterName = bridge.arrayIterators[key]; + const target = key ? key.split(".") : []; + const label = key || "(root)"; + const id = `${label}/empty-array`; + entries.push({ + id, + // 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..3f68ce1f 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. */ + executionTraceId: 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 { executionTraceId?: bigint }).executionTraceId = + tree.getExecutionTrace(); + } throw attachBridgeErrorDocumentContext(err, doc); } - return { data: data as T, traces: tree.getTraces() }; + return { + data: data as T, + traces: tree.getTraces(), + executionTraceId: tree.getExecutionTrace(), + }; } diff --git a/packages/bridge-core/src/index.ts b/packages/bridge-core/src/index.ts index db41eb9a..46fdaf71 100644 --- a/packages/bridge-core/src/index.ts +++ b/packages/bridge-core/src/index.ts @@ -82,6 +82,16 @@ export type { WireFallback, } from "./types.ts"; +// ── Traversal enumeration ─────────────────────────────────────────────────── + +export { + enumerateTraversalIds, + buildTraversalManifest, + decodeExecutionTrace, + buildTraceBitsMap, +} from "./enumerate-traversals.ts"; +export type { TraversalEntry, TraceWireBits } from "./enumerate-traversals.ts"; + // ── Utilities ─────────────────────────────────────────────────────────────── export { parsePath } from "./utils.ts"; 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 new file mode 100644 index 00000000..99a39659 --- /dev/null +++ b/packages/bridge/test/enumerate-traversals.test.ts @@ -0,0 +1,645 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { parseBridge } from "@stackables/bridge-parser"; +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); + 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})`, + ); + }); +}); + +// ── 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("executionTraceId: 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 { executionTraceId } = await executeBridge({ + document: doc, + operation: "Query.demo", + input: { q: "test" }, + tools: { api: async () => ({ label: "Hello" }) }, + }); + + 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, executionTraceId); + 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 { executionTraceId, 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, executionTraceId); + 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 { executionTraceId, 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, executionTraceId); + 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 { executionTraceId } = 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, 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"); + }); + + 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 { executionTraceId } = 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, 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"); + }); + + 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 { executionTraceId } = 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, executionTraceId); + const kinds = decoded.map((e) => e.kind); + assert.ok(kinds.includes("const"), "should include const path"); + }); + + test("executionTraceId 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 { executionTraceId } = await executeBridge({ + document: doc, + operation: "Query.demo", + input: { q: "Berlin" }, + tools: { api: async () => ({ label: "Berlin" }) }, + }); + + assert.equal(typeof executionTraceId, "bigint"); + const hex = `0x${executionTraceId.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/astro.config.mjs b/packages/docs-site/astro.config.mjs index c22cb9bb..b6a1b334 100644 --- a/packages/docs-site/astro.config.mjs +++ b/packages/docs-site/astro.config.mjs @@ -42,14 +42,17 @@ export default defineConfig({ starlight({ title: "The Bridge", customCss: ["./src/styles/custom.css"], + components: { + SocialIcons: "./src/components/SocialIcons.astro", + }, logo: { src: "./src/assets/logo.svg", }, social: [ { - label: "Blog", - icon: "rss", - href: "/blog", + label: "Playground", + icon: "rocket", + href: "/playground", }, { icon: "github", diff --git a/packages/docs-site/src/components/SocialIcons.astro b/packages/docs-site/src/components/SocialIcons.astro new file mode 100644 index 00000000..f4551af8 --- /dev/null +++ b/packages/docs-site/src/components/SocialIcons.astro @@ -0,0 +1,54 @@ +--- +import config from "virtual:starlight/user-config"; +import { Icon } from "@astrojs/starlight/components"; + +const socialLinks = config.social || []; +const blogLink = socialLinks.find( + ({ label, href }) => label.toLowerCase() === "blog" || href === "/blog", +); +const iconLinks = socialLinks.filter( + ({ label, href }) => label.toLowerCase() !== "blog" && href !== "/blog", +); +const blogHref = blogLink?.href || "/blog"; +--- + +{ + (blogLink || iconLinks.length > 0) && ( + <> + + BLOG + + {iconLinks.map(({ label, href, icon }) => ( + + {label} + + + ))} + + ) +} + + 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..8835121a --- /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, executionTraceId } = await executeBridge({ + bridge: Query.pricing, + input: { isPro: false } +}); + +// data: { tier: "basic", discount: 5, price: 9.99 } +// executionTraceId: 42 (or "0x2a") + +// Safe to log without scrubbing PII +logger.info("Bridge executed", { + operation: "Query.pricing", + trace: executionTraceId +}); + +``` + +### 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..a228f432 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({ + executionTraceId, + onClearExecutionTraceId, +}: { + executionTraceId?: bigint; + onClearExecutionTraceId?: () => void; +}) { + const hasTrace = executionTraceId != null && executionTraceId > 0n; return ( -
- +
+ Bridge DSL + {hasTrace && ( + + trace-id 0x{executionTraceId.toString(16)} + {onClearExecutionTraceId && ( + + )} + + )}
); } @@ -273,6 +308,67 @@ function SchemaHeader({ ); } +import { getTraversalManifest, decodeExecutionTrace } from "./engine"; + +function getInactiveTraversalLocations( + bridge: string, + operation: string, + executionTraceId?: bigint, +): SourceLocation[] { + if (!operation || executionTraceId == null || executionTraceId === 0n) { + return []; + } + + const manifest = getTraversalManifest(bridge, operation); + if (manifest.length === 0) return []; + + const activeIds = new Set( + decodeExecutionTrace(manifest, executionTraceId).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; + onClearExecutionTraceId?: () => void; }; export function Playground({ @@ -352,6 +449,7 @@ export function Playground({ bridgeOperations, availableOutputFields, hideGqlSwitch, + onClearExecutionTraceId, }: 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?.executionTraceId, + ), + [bridge, manifestOperation, displayResult?.executionTraceId], + ); + 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..db386ee4 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. */ + executionTraceId?: 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, + executionTraceId: result.executionTraceId, }; } catch (err: unknown) { + const trace = + err && typeof err === "object" && "executionTraceId" in err + ? (err as { executionTraceId?: bigint }).executionTraceId + : undefined; return { errors: [ formatBridgeError(err, { @@ -669,8 +679,50 @@ export async function runBridgeStandalone( filename: document.filename, }), ], + ...(trace != null ? { executionTraceId: 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..5d15d83c 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 clearExecutionTraceId = useCallback(() => { + if (!displayQueryId) return; + setResults((prev) => { + const cur = prev[displayQueryId]; + if (!cur) return prev; + return { + ...prev, + [displayQueryId]: { ...cur, executionTraceId: undefined }, + }; + }); + }, [displayQueryId]); + const activeQuery = queries.find((q) => q.id === activeTabId); const selectExample = useCallback( @@ -401,6 +413,7 @@ export function usePlaygroundState( hasErrors, isActiveRunning, onRun: handleRun, + onClearExecutionTraceId: clearExecutionTraceId, graphqlSchema, bridgeOperations, availableOutputFields,