From d6ae12612300e3438918e06d849d43684a1f7363 Mon Sep 17 00:00:00 2001
From: burrows99
Date: Thu, 18 Jun 2026 14:53:12 +0100
Subject: [PATCH 1/6] =?UTF-8?q?feat(engine):=20non-pausing=20logpoint=20ca?=
=?UTF-8?q?pture=20=E2=80=94=20breakpoints=20never=20halt=20the=20VM?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Breakpoints are now armed as logpoints (a conditional breakpoint whose
condition captures state and returns false), so a hit ships its stack +
in-scope locals + exprs out via a Runtime binding without ever pausing
execution. The traced app runs at full speed, hot paths are cheap, and
there is no human-style stop-and-wait — built for an agent that reads the
trace and re-aims breakpoints.
Locals are still captured automatically: ScopeExtractor statically reads
the generated source and lists the in-scope names (function-body scopes,
matching the old local/block/catch filter), which the condition gathers
defensively. --expr adds computed/derived values on top. The call stack
arrives as new Error().stack and is source-mapped via SourceMaps.
The TraceEvent shape is unchanged, so Renderer, lineage, the recording's
trace panel, the collector and the schema are all untouched — Chrome video
recording included.
- new: ScopeExtractor (TS-based in-scope name extraction), Logpoint
(condition builder + in-page serializer + payload→TraceEvent capturer)
- Tracer/TabTracer: replace the pause→capture→resume loop with
binding-driven collection; instrumentation pause kept only for binding
before first-run code
- remove: EventCapturer and the dead pause-queue machinery
(waitForStop/hasQueued/interrupt, renderRemoteObject, step over/into/out)
- --max-hits default 25/30 → 100 (cheap to raise without per-hit pause cost)
Validated end-to-end against the node-api (plain JS) and react-app
(sourcemapped TS) fixtures: full locals + exprs + stack + lineage, video
intact, no pauses.
Co-Authored-By: Claude Opus 4.8
---
README.md | 1 +
src/cli/Cli.ts | 8 +-
src/engine/BpBinder.ts | 34 ++++--
src/engine/EventCapturer.ts | 80 --------------
src/engine/Logpoint.ts | 136 +++++++++++++++++++++++
src/engine/ScopeExtractor.ts | 93 ++++++++++++++++
src/engine/SourceMaps.ts | 25 ++++-
src/engine/TabTracer.ts | 68 +++++++-----
src/engine/Tracer.ts | 190 ++++++++++----------------------
src/transport/CdpDriver.ts | 33 ------
src/transport/ProtocolDriver.ts | 6 -
src/transport/cdp.ts | 9 +-
12 files changed, 384 insertions(+), 299 deletions(-)
delete mode 100644 src/engine/EventCapturer.ts
create mode 100644 src/engine/Logpoint.ts
create mode 100644 src/engine/ScopeExtractor.ts
diff --git a/README.md b/README.md
index fd798dc..9b324b7 100644
--- a/README.md
+++ b/README.md
@@ -7,6 +7,7 @@ Execution tracer & analyzer — one JSON envelope across breakpoint traces (Node
Point it at a running program, give it breakpoints + a trigger → a full **execution trace** (every hit in order: call stack, locals, watched expressions, timing) as **one JSON envelope**, identical shape across targets.
+- **Breakpoints never pause the program.** They're armed as non-pausing *logpoints*: each hit captures its stack, every in-scope local (read statically from the source — no naming needed), and any extra `--expr`, then ships it out without halting the VM. The app runs at full speed, hot paths are cheap, and there's no human-style "stop and wait" — built for an agent that reads the trace and re-aims breakpoints, not a human stepping by hand. (Trade-off: capturing *all* locals automatically needs source the runtime can read by name — perfect for Node and dev-mode frontends; a minified production bundle yields mangled local names, though `--expr` and the stack still work.)
- Not "a debugger" — **OpenTelemetry for software execution**: every source (debug protocol, span exporter, shell) normalizes to one `Event`; events are the asset. See [`docs/MIGRATION.md`](docs/MIGRATION.md).
```
diff --git a/src/cli/Cli.ts b/src/cli/Cli.ts
index 8c9c3e8..421df8f 100644
--- a/src/cli/Cli.ts
+++ b/src/cli/Cli.ts
@@ -161,13 +161,13 @@ export class Cli {
.showHelpAfterError("(add --help for usage)");
program.command("dynamic")
- .description("breakpoints + a trigger → a full execution trace. Node (CDP): a --curl trigger. Chrome (CDP): a scripted UI journey (--url/--step) recorded as a screen + trace-panel replay — debug and video together.")
+ .description("breakpoints + a trigger → a full execution trace. Breakpoints are non-pausing logpoints: each hit ships its stack + in-scope locals + exprs without halting the VM, so the app runs at full speed. Node (CDP): a --curl trigger. Chrome (CDP): a scripted UI journey (--url/--step) recorded as a screen + trace-panel replay — debug and video together.")
.option("--node [port]", `Node --inspect target (default; port ${DEFAULT_NODE_PORT})`)
.option("--chrome [port]", "Chrome target: a running browser's --remote-debugging-port, or omit the port to launch a throwaway headless Chrome")
- .option("--bp ", "breakpoint, repeatable: file:line or file@substring", collect, [])
- .option("--expr ", "expression evaluated at every hit, repeatable", collect, [])
+ .option("--bp ", "breakpoint, repeatable: file:line or file@substring (non-pausing; in-scope locals are captured automatically)", collect, [])
+ .option("--expr ", "extra expression captured at every hit, repeatable — for computed/derived values beyond the auto-captured locals (e.g. user.id, cart.length)", collect, [])
.option("--root ", "project root for resolving --bp file paths and source maps (default: cwd) — needed when a file@substring breakpoint or a built app's sources live outside cwd")
- .option("--max-hits ", "stop after this many breakpoint hits (default: node 25, chrome 30)", int)
+ .option("--max-hits ", "stop after this many breakpoint hits (default: 100; non-pausing logpoints, so a hot path is cheap to raise)", int)
.option("--curl ", "trigger for node: a command run once breakpoints are set")
.option("--url ", "chrome trigger shorthand: a page URL to navigate (equivalent to --step goto:)")
.option("--step ", "chrome journey step, repeatable & ordered: goto: · click: · type:= · waitfor: · wait: · newtab · eval: (sel: CSS or text=…)", collect, [])
diff --git a/src/engine/BpBinder.ts b/src/engine/BpBinder.ts
index ffbffa3..c80ce6f 100644
--- a/src/engine/BpBinder.ts
+++ b/src/engine/BpBinder.ts
@@ -1,21 +1,29 @@
import { CdpDriver, log } from "../transport/CdpDriver.js";
import { Cdp } from "../transport/cdp.js";
import { SourceMaps } from "./SourceMaps.js";
+import { ScopeExtractor } from "./ScopeExtractor.js";
+import { buildCondition } from "./Logpoint.js";
import { type ResolvedBp } from "./BreakpointResolver.js";
import { Breakpoint } from "../domain/Breakpoint.js";
/**
- * BpBinder — resolves source `file:line` breakpoints to CDP breakpoints, idempotently and incrementally.
- * `tryBind` may be called repeatedly (once for Node, once per instrumentation pause for Chrome): each
- * breakpoint is *attempted at most once* (so we never register a duplicate), but a breakpoint whose script
- * has not parsed yet is left for a later attempt. `allSettled` is true when every breakpoint has either
- * bound or been attempted — i.e. there is nothing more to gain by pausing on further scripts.
+ * BpBinder — resolves source `file:line` breakpoints to CDP breakpoints, idempotently and incrementally, and
+ * arms each one as a *non-pausing logpoint*: at bind time it reads the generated source, extracts the in-scope
+ * variable names, and sets the breakpoint with a `condition` that captures those locals + the user's exprs and
+ * ships them out without ever halting the VM (see {@link buildCondition}). `tryBind` may be called repeatedly
+ * (once for Node, once per instrumentation pause for Chrome): each breakpoint is *attempted at most once* (so
+ * we never register a duplicate), but one whose script has not parsed yet is left for a later attempt.
+ * `allSettled` is true when every breakpoint has either bound or been attempted.
*/
export class BpBinder {
readonly bpById = new Map();
#state: { bp: ResolvedBp; attempted: boolean; bound: boolean; mapped: boolean; note?: string }[];
+ #exprs: string[];
- constructor(bps: ResolvedBp[]) { this.#state = bps.map((bp) => ({ bp, attempted: false, bound: false, mapped: false })); }
+ constructor(bps: ResolvedBp[], exprs: string[] = []) {
+ this.#state = bps.map((bp) => ({ bp, attempted: false, bound: false, mapped: false }));
+ this.#exprs = exprs;
+ }
allSettled(): boolean { return this.#state.every((s) => s.attempted); }
@@ -25,15 +33,25 @@ export class BpBinder {
const g = await sm.findGenerated(s.bp.file, s.bp.line);
if (!g) { s.note = "no loaded script/source matched (loaded yet? right file/route?)"; continue; }
s.attempted = true;
- const r = await driver.send(Cdp.Debugger.setBreakpointByUrl, { urlRegex: g.urlRegex, lineNumber: g.lineNumber, columnNumber: g.columnNumber });
+ const locals = await this.#scopeNames(driver, g.scriptId, g.lineNumber, g.columnNumber);
+ const condition = buildCondition(`${s.bp.file}:${s.bp.line}`, locals, this.#exprs);
+ const r = await driver.send(Cdp.Debugger.setBreakpointByUrl, { urlRegex: g.urlRegex, lineNumber: g.lineNumber, columnNumber: g.columnNumber, condition });
s.bound = !!(r.locations && r.locations.length);
s.mapped = g.mapped;
s.note = s.bound ? undefined : "breakpoint set but no location resolved yet";
this.bpById.set(r.breakpointId, { file: s.bp.file, line: s.bp.line });
- log(`bp ${s.bp.file}:${s.bp.line} → ${s.bound ? "BOUND" : "pending"}${g.mapped ? " (mapped)" : ""}`);
+ log(`bp ${s.bp.file}:${s.bp.line} → ${s.bound ? "BOUND" : "pending"}${g.mapped ? " (mapped)" : ""} · ${locals.length} locals`);
}
}
+ /** Read the generated source and statically list the variables in scope at the breakpoint. Best-effort. */
+ async #scopeNames(driver: CdpDriver, scriptId: string, line0: number, col0: number): Promise {
+ try {
+ const { scriptSource } = await driver.send(Cdp.Debugger.getScriptSource, { scriptId });
+ return scriptSource ? ScopeExtractor.inScopeNames(scriptSource, line0 + 1, col0) : [];
+ } catch { return []; }
+ }
+
report(): Breakpoint[] {
return this.#state.map((s) => new Breakpoint({ file: s.bp.file, line: s.bp.line, bound: s.bound, mapped: s.mapped, ...(s.note ? { note: s.note } : {}) }));
}
diff --git a/src/engine/EventCapturer.ts b/src/engine/EventCapturer.ts
deleted file mode 100644
index 1652090..0000000
--- a/src/engine/EventCapturer.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import { performance } from "node:perf_hooks";
-
-import { CdpDriver, renderRemoteObject } from "../transport/CdpDriver.js";
-import { Cdp } from "../transport/cdp.js";
-import { SourceMaps } from "./SourceMaps.js";
-import { TraceEvent } from "../domain/TraceEvent.js";
-import { Loc } from "../domain/Loc.js";
-
-/**
- * CdpCtx — the per-trace mutable context the EventCapturer reads while turning CDP pauses into events:
- * the clock origin, the running event list, frame/expr/step budgets, the SourceMaps resolver, the
- * breakpoint-id → source-loc map (grown as breakpoints bind), and the session id.
- */
-export interface CdpCtx {
- t0: number;
- events: TraceEvent[];
- frames: number;
- exprs: string[];
- steps: string[];
- sm: SourceMaps;
- bpById: Map;
- sessionId?: string;
-}
-
-/**
- * EventCapturer — turns a CDP `Debugger.paused` event into a domain TraceEvent: walks the call stack,
- * extracts locals from the scope chain, evaluates watch expressions, and resolves the hit location through
- * source maps; also drives the step (over/into/out) plan at the first hit. The driver is injected (DIP).
- * SRP: pause → TraceEvent(s) only — it owns nothing about the capture loop, triggers, or teardown.
- */
-export class EventCapturer {
- constructor(private readonly driver: CdpDriver) {}
-
- async capture(paused: any, kind: string, ctx: CdpCtx): Promise {
- const driver = this.driver;
- const top = paused.callFrames[0];
- const stack: string[] = [];
- for (const f of paused.callFrames.slice(0, ctx.frames)) {
- const url = driver.scriptUrl(f.location.scriptId) || f.url;
- const loc = await ctx.sm.generatedToSource(f.location.scriptId, f.location.lineNumber, f.location.columnNumber);
- const at = loc ? `${loc.sourceRel}:${loc.line}` : (url ? `${SourceMaps.pathOf(url)}:${f.location.lineNumber + 1}` : "");
- stack.push(`${f.functionName || "(anon)"} (${at})`);
- }
- const locals: Record = {};
- for (const sc of top.scopeChain) {
- if (!["local", "block", "catch"].includes(sc.type) || !sc.object?.objectId) continue;
- const props = await driver.send(Cdp.Runtime.getProperties, { objectId: sc.object.objectId, ownProperties: true, generatePreview: true });
- for (const p of props.result || []) if (!(p.name in locals)) locals[p.name] = renderRemoteObject(p.value);
- }
- const ex: Record = {};
- for (const e of ctx.exprs) {
- try {
- const r = await driver.send(Cdp.Debugger.evaluateOnCallFrame, { callFrameId: top.callFrameId, expression: e, returnByValue: false, generatePreview: true });
- ex[e] = r.exceptionDetails ? `⟂ ${String(r.exceptionDetails.exception?.description || r.exceptionDetails.text || "error").split("\n")[0]}` : renderRemoteObject(r.result);
- } catch (err: any) { ex[e] = `⟂ ${err.message}`; }
- }
- const loc = await ctx.sm.generatedToSource(top.location.scriptId, top.location.lineNumber, top.location.columnNumber);
- const labelBp = (paused.hitBreakpoints || []).map((id: string) => ctx.bpById.get(id)).filter(Boolean)[0];
- const at = loc ? `${loc.sourceRel}:${loc.line}` : (labelBp ? `${labelBp.file}:${labelBp.line}` : stack[0]);
- const cls = top.this?.className && top.this.className !== "Object" ? top.this.className : undefined;
- return new TraceEvent({
- seq: ctx.events.length + 1, t: Math.round(performance.now() - ctx.t0), kind, source: "cdp", sessionId: ctx.sessionId,
- loc: Loc.parse(at), label: top.functionName || "(anonymous)",
- attrs: { ...(cls ? { cls } : {}), stack, locals, ...(ctx.exprs.length ? { exprs: ex } : {}) },
- });
- }
-
- async runSteps(ctx: CdpCtx, timeoutMs: number): Promise {
- if (ctx.events.length !== 1 || !ctx.steps.length) return;
- for (const s of ctx.steps) {
- const cmd = ({ over: Cdp.Debugger.stepOver, into: Cdp.Debugger.stepInto, out: Cdp.Debugger.stepOut } as Record)[s];
- if (!cmd) continue;
- await this.driver.send(cmd);
- let st: any;
- try { st = await this.driver.waitForStop(timeoutMs); } catch { break; }
- if (!st) break;
- ctx.events.push(await this.capture(st, `step:${s}`, ctx));
- }
- }
-}
diff --git a/src/engine/Logpoint.ts b/src/engine/Logpoint.ts
new file mode 100644
index 0000000..3de4d02
--- /dev/null
+++ b/src/engine/Logpoint.ts
@@ -0,0 +1,136 @@
+import { performance } from "node:perf_hooks";
+
+import type { CdpDriver } from "../transport/CdpDriver.js";
+import type { SourceMaps } from "./SourceMaps.js";
+import { TraceEvent } from "../domain/TraceEvent.js";
+import { Loc } from "../domain/Loc.js";
+
+/** Global the logpoint condition calls to ship a captured hit out over CDP (`Runtime.bindingCalled`). */
+export const BINDING_NAME = "__traceCliEmit";
+/** Global serializer the condition hands its gathered values to — installed once per execution context. */
+const SNAP_NAME = "__traceCliSnap";
+
+/**
+ * HELPER_SOURCE — installed once per execution context (via Runtime.evaluate, and per-document for Chrome).
+ * Defines {@link SNAP_NAME}: a depth/breadth/cycle-limited serializer that turns the locals & exprs the
+ * condition gathered into a JSON envelope and ships it through the {@link BINDING_NAME} binding, then returns
+ * `false` so the breakpoint never pauses. Kept dependency-free and ES5-ish so it runs in any V8 context.
+ */
+export const HELPER_SOURCE = `(function(){
+ if (typeof globalThis.${SNAP_NAME} === 'function') return;
+ var MAXSTR = 2000, MAXKEYS = 50, MAXDEPTH = 4, MAXARR = 50;
+ function safe(v, d, seen) {
+ if (v === null) return null;
+ var t = typeof v;
+ if (t === 'undefined') return '[undefined]';
+ if (t === 'number' || t === 'boolean') return v;
+ if (t === 'string') return v.length > MAXSTR ? v.slice(0, MAXSTR) + '…(' + v.length + ')' : v;
+ if (t === 'bigint') return String(v) + 'n';
+ if (t === 'symbol') return v.toString();
+ if (t === 'function') return '[Function' + (v.name ? ': ' + v.name : '') + ']';
+ if (v instanceof Date) return v.toISOString();
+ if (v instanceof RegExp) return String(v);
+ if (v instanceof Error) return v.name + ': ' + v.message;
+ if (d <= 0) return Array.isArray(v) ? '[Array(' + v.length + ')]' : '[Object]';
+ if (seen.indexOf(v) !== -1) return '[Circular]';
+ seen.push(v);
+ try {
+ if (Array.isArray(v)) {
+ var a = [];
+ for (var i = 0; i < v.length && i < MAXARR; i++) a.push(safe(v[i], d - 1, seen));
+ if (v.length > MAXARR) a.push('…+' + (v.length - MAXARR));
+ return a;
+ }
+ if (typeof Map !== 'undefined' && v instanceof Map) {
+ var m = {}, mk = 0;
+ v.forEach(function (val, key) { if (mk++ < MAXKEYS) m[String(key)] = safe(val, d - 1, seen); });
+ m['@type'] = 'Map(' + v.size + ')';
+ return m;
+ }
+ if (typeof Set !== 'undefined' && v instanceof Set) {
+ var s = []; v.forEach(function (val) { if (s.length < MAXARR) s.push(safe(val, d - 1, seen)); });
+ return { '@type': 'Set(' + v.size + ')', values: s };
+ }
+ var o = {}, k = 0, keys = Object.keys(v);
+ for (var j = 0; j < keys.length; j++) {
+ if (k++ >= MAXKEYS) { o['…'] = '+' + (keys.length - MAXKEYS) + ' more'; break; }
+ try { o[keys[j]] = safe(v[keys[j]], d - 1, seen); } catch (e) { o[keys[j]] = '⟂'; }
+ }
+ var ctor = v.constructor && v.constructor.name;
+ if (ctor && ctor !== 'Object') o['@type'] = ctor;
+ return o;
+ } finally { seen.pop(); }
+ }
+ globalThis.${SNAP_NAME} = function (bp, vals, exprs, err) {
+ try {
+ var L = {}; for (var n in vals) L[n] = safe(vals[n], MAXDEPTH, []);
+ var E = {}; for (var e in exprs) E[e] = safe(exprs[e], MAXDEPTH, []);
+ globalThis.${BINDING_NAME}(JSON.stringify({ bp: bp, stack: (err && err.stack) || '', locals: L, exprs: E }));
+ } catch (_e) {}
+ return false;
+ };
+})();`;
+
+/**
+ * Build the breakpoint *condition* that makes a breakpoint behave as a non-pausing logpoint. It gathers the
+ * in-scope locals and the user's exprs *defensively* (each in its own try, so a temporal-dead-zone reference
+ * or a throwing getter never aborts the capture or — crucially — never throws out of the condition, which V8
+ * would treat as "pause"), hands them to the installed serializer with a fresh `new Error()` for the stack,
+ * and evaluates to `false`. The `typeof ... === 'function'` guard means that if the helper isn't installed in
+ * this context yet, the condition is a silent no-op rather than a throw (again: a throw would pause).
+ */
+export function buildCondition(bpKey: string, locals: string[], exprs: string[]): string {
+ const vals = locals.length
+ ? `(function(){var o={};${locals.map((n) => `try{o[${JSON.stringify(n)}]=${n}}catch(_){}`).join("")}return o})()`
+ : "{}";
+ const ex = exprs.length
+ ? `(function(){var o={};${exprs.map((e) => `try{o[${JSON.stringify(e)}]=(${e})}catch(_e){o[${JSON.stringify(e)}]="⟂ "+(_e&&_e.message)}`).join("")}return o})()`
+ : "{}";
+ return `(typeof globalThis.${SNAP_NAME}==='function'&&${SNAP_NAME}(${JSON.stringify(bpKey)},${vals},${ex},new Error()),false)`;
+}
+
+interface LogpointPayload { bp: string; stack: string; locals: Record; exprs: Record; }
+
+const FRAME_RE = /^\s*at\s+(?:async\s+)?(?:(.*?)\s+)?\(?([^()\s]+):(\d+):(\d+)\)?\s*$/;
+
+/**
+ * LogpointCapturer — the non-pausing counterpart to the old EventCapturer: it turns a `bindingCalled`
+ * payload (gathered in-page by the condition) into the same {@link TraceEvent} shape the pausing path
+ * produced, so everything downstream (Renderer, lineage, the recording's trace panel, the collector,
+ * the schema) is untouched. The only difference is provenance — the call stack arrives as a stack string
+ * to source-map, not as CDP `callFrames`.
+ */
+export class LogpointCapturer {
+ constructor(private readonly driver: CdpDriver, private readonly sm: SourceMaps, private readonly t0: number, private readonly frames = 6) {}
+
+ async toEvent(payloadJson: string, seq: number): Promise {
+ let p: LogpointPayload;
+ try { p = JSON.parse(payloadJson); } catch { return null; }
+ if (!p?.bp) return null;
+ const loc = Loc.parse(p.bp);
+ const stack = await this.#resolveStack(p.stack);
+ const label = stack[0]?.split(" (")[0] || loc?.file || p.bp;
+ const exprKeys = Object.keys(p.exprs ?? {});
+ return new TraceEvent({
+ seq, t: Math.round(performance.now() - this.t0), kind: "breakpoint", source: "cdp",
+ loc, label,
+ attrs: { stack, locals: p.locals ?? {}, ...(exprKeys.length ? { exprs: p.exprs } : {}) },
+ });
+ }
+
+ /** Parse `new Error().stack`, drop synthetic/internal frames (the injected condition, node internals), map the rest. */
+ async #resolveStack(raw: string): Promise {
+ const out: string[] = [];
+ for (const line of (raw || "").split("\n")) {
+ if (out.length >= this.frames) break;
+ const m = FRAME_RE.exec(line);
+ if (!m) continue;
+ const [, fn, url, ln, col] = m;
+ if (!url || !ln || !col) continue;
+ const loc = await this.sm.frameToSource(url, parseInt(ln, 10), parseInt(col, 10));
+ if (!loc) continue; // synthetic condition-eval frame or a node internal — not user code
+ out.push(`${fn || "(anon)"} (${loc.sourceRel}:${loc.line})`);
+ }
+ return out;
+ }
+}
diff --git a/src/engine/ScopeExtractor.ts b/src/engine/ScopeExtractor.ts
new file mode 100644
index 0000000..1ca4aa9
--- /dev/null
+++ b/src/engine/ScopeExtractor.ts
@@ -0,0 +1,93 @@
+import { createRequire } from "node:module";
+
+const require = createRequire(import.meta.url);
+// typescript ships CommonJS; load it through createRequire so it works under NodeNext ESM (same as SourceMaps).
+const ts: typeof import("typescript") = require("typescript");
+
+/**
+ * ScopeExtractor — recovers the in-scope variable names at a breakpoint *statically*, from the generated
+ * source the runtime actually executes. This is what lets a non-pausing logpoint capture "all locals"
+ * automatically: a paused frame can enumerate its scope chain, but a logpoint condition can only reference
+ * names it was given — so we parse the enclosing function and hand it the list.
+ *
+ * Names returned are everything visible at the target offset: parameters of every enclosing function, plus
+ * variable/function/class/loop/catch bindings declared *before* the breakpoint inside an enclosing scope.
+ * (Block-scoped `let`/`const` declared after the line are omitted — they'd be in the temporal dead zone and
+ * throw on reference.) Destructuring patterns are flattened to their leaf identifiers. Minified code yields
+ * mangled names (`a`,`e`,`t`) — correct, just not useful; that's the one degraded case.
+ */
+export class ScopeExtractor {
+ /** In-scope identifier names at (line1, column0) of `source`. Best-effort: returns [] on a parse failure. */
+ static inScopeNames(source: string, line1: number, column0: number): string[] {
+ try {
+ const sf = ts.createSourceFile("bp.js", source, ts.ScriptTarget.Latest, /*setParentNodes*/ true);
+ const target = Math.max(0, Math.min(source.length, ts.getPositionOfLineAndCharacter(sf, line1 - 1, column0)));
+ const node = ScopeExtractor.#nodeAt(sf, target);
+ if (!node) return [];
+ const ancestors = new Set();
+ for (let n: import("typescript").Node | undefined = node; n; n = n.parent) ancestors.add(n);
+
+ const names = new Set();
+ const visit = (n: import("typescript").Node): void => {
+ // A parameter is in scope for its whole function body — include it whenever that function encloses
+ // the target, regardless of position.
+ if (ts.isParameter(n) && n.parent && ancestors.has(n.parent)) ScopeExtractor.#collectBindingNames(n.name, names);
+ else if (ScopeExtractor.#isDeclaration(n) && n.getStart(sf) < target && ScopeExtractor.#enclosingScopeInAncestors(n, ancestors)) {
+ ScopeExtractor.#collectDeclNames(n, names);
+ }
+ n.forEachChild(visit);
+ };
+ visit(sf);
+ names.delete("arguments");
+ return [...names];
+ } catch {
+ return [];
+ }
+ }
+
+ /** The deepest node whose span contains `pos`. */
+ static #nodeAt(sf: import("typescript").Node, pos: number): import("typescript").Node | null {
+ let found: import("typescript").Node | null = null;
+ const walk = (n: import("typescript").Node): void => {
+ if (pos >= n.getStart(sf as import("typescript").SourceFile) && pos <= n.getEnd()) {
+ found = n;
+ n.forEachChild(walk);
+ }
+ };
+ sf.forEachChild(walk);
+ return found;
+ }
+
+ static #isDeclaration(n: import("typescript").Node): boolean {
+ return ts.isVariableDeclaration(n) || ts.isFunctionDeclaration(n) || ts.isClassDeclaration(n) || ts.isBindingElement(n) || ts.isCatchClause(n);
+ }
+
+ /**
+ * True if this declaration's owning scope is an enclosing *function* on the path to the target — i.e. it's
+ * one of the target's own function-body locals, not a sibling-function local. Module/global-scope
+ * declarations are excluded (they'd bury the real locals under every top-level import/const/function);
+ * this mirrors the old pausing engine, which only surfaced `local`/`block`/`catch` scopes.
+ */
+ static #enclosingScopeInAncestors(n: import("typescript").Node, ancestors: Set): boolean {
+ for (let p: import("typescript").Node | undefined = n.parent; p; p = p.parent) {
+ if (ts.isFunctionLike(p)) return ancestors.has(p);
+ if (ts.isSourceFile(p)) return false; // module/global scope — exclude
+ }
+ return false;
+ }
+
+ static #collectDeclNames(n: import("typescript").Node, out: Set): void {
+ if (ts.isCatchClause(n)) { if (n.variableDeclaration) ScopeExtractor.#collectBindingNames(n.variableDeclaration.name, out); return; }
+ if ((ts.isFunctionDeclaration(n) || ts.isClassDeclaration(n)) && n.name) { out.add(n.name.text); return; }
+ if (ts.isVariableDeclaration(n) || ts.isBindingElement(n)) ScopeExtractor.#collectBindingNames(n.name, out);
+ }
+
+ /** Flatten a binding name — a plain identifier, or an object/array destructuring pattern — to leaf names. */
+ static #collectBindingNames(name: import("typescript").BindingName, out: Set): void {
+ if (ts.isIdentifier(name)) { out.add(name.text); return; }
+ for (const el of name.elements) {
+ if (ts.isOmittedExpression(el)) continue;
+ ScopeExtractor.#collectBindingNames(el.name, out);
+ }
+ }
+}
diff --git a/src/engine/SourceMaps.ts b/src/engine/SourceMaps.ts
index 7cf2be6..c73266b 100644
--- a/src/engine/SourceMaps.ts
+++ b/src/engine/SourceMaps.ts
@@ -13,6 +13,7 @@ export interface GeneratedLocation {
lineNumber: number;
columnNumber: number;
scriptUrl: string;
+ scriptId: string;
mapped: boolean;
}
@@ -93,6 +94,24 @@ export class SourceMaps {
this.#consumers.clear();
}
+ /**
+ * Resolve a runtime stack frame (a URL + generated position, as found in `new Error().stack`) back to a
+ * `file:line` source location. Used by the non-pausing logpoint path, where the call stack arrives as a
+ * stack string rather than CDP `callFrames`. Returns null when the URL matches no parsed script (e.g. a
+ * node internal or the injected condition wrapper), so the caller can drop that frame.
+ */
+ async frameToSource(url: string, line1: number, col0: number): Promise<{ sourceRel: string; line: number } | null> {
+ const path = SourceMaps.pathOf(url);
+ for (const [scriptId, s] of this.driver.scripts()) {
+ if (!s.url) continue;
+ if (s.url === url || SourceMaps.pathOf(s.url) === path) {
+ const mapped = await this.generatedToSource(scriptId, line1 - 1, col0);
+ if (mapped) return mapped;
+ }
+ }
+ return null;
+ }
+
/** Map a generated frame position back to { sourceRel, line } for display. */
async generatedToSource(scriptId: string, line0: number, col: number): Promise<{ sourceRel: string; line: number } | null> {
const url = this.driver.script(scriptId)?.url;
@@ -112,7 +131,7 @@ export class SourceMaps {
let g = c.generatedPositionFor({ source: src, line, column: 0, bias: SourceMapConsumer.LEAST_UPPER_BOUND });
if (g.line == null) g = c.generatedPositionFor({ source: src, line, column: 0, bias: SourceMapConsumer.GREATEST_LOWER_BOUND });
if (g.line == null) return null;
- return { urlRegex: SourceMaps.urlRegexFor(s.url!), lineNumber: g.line - 1, columnNumber: g.column || 0, scriptUrl: s.url!, mapped: true };
+ return { urlRegex: SourceMaps.urlRegexFor(s.url!), lineNumber: g.line - 1, columnNumber: g.column || 0, scriptUrl: s.url!, scriptId, mapped: true };
}
/** Resolve source `file:line` to a CDP breakpoint location — mapped scripts FIRST, then direct (plain JS). */
@@ -121,9 +140,9 @@ export class SourceMaps {
const all = [...this.driver.scripts()];
const primary = all.filter(([, s]) => s.url && SourceMaps.#baseNoExt(s.url) === bn);
for (const [scriptId, s] of primary) { const r = await this.#tryMapScript(scriptId, s, file, line); if (r) return r; }
- for (const [, s] of this.driver.scripts()) {
+ for (const [scriptId, s] of this.driver.scripts()) {
if (s.url && SourceMaps.suffixMatch(s.url, file)) {
- return { urlRegex: SourceMaps.urlRegexFor(s.url), lineNumber: line - 1, columnNumber: 0, scriptUrl: s.url, mapped: false };
+ return { urlRegex: SourceMaps.urlRegexFor(s.url), lineNumber: line - 1, columnNumber: 0, scriptUrl: s.url, scriptId, mapped: false };
}
}
const seen = new Set(primary.map(([id]) => id));
diff --git a/src/engine/TabTracer.ts b/src/engine/TabTracer.ts
index d8e445d..a917643 100644
--- a/src/engine/TabTracer.ts
+++ b/src/engine/TabTracer.ts
@@ -4,15 +4,17 @@ import { CdpDriver } from "../transport/CdpDriver.js";
import { Cdp } from "../transport/cdp.js";
import { SourceMaps } from "./SourceMaps.js";
import { BpBinder } from "./BpBinder.js";
-import { EventCapturer, type CdpCtx } from "./EventCapturer.js";
+import { BINDING_NAME, HELPER_SOURCE, LogpointCapturer } from "./Logpoint.js";
import { Breakpoint } from "../domain/Breakpoint.js";
import type { TraceConfig, TracedHit } from "./JourneyRunner.js";
/**
- * TabTracer — breakpoint tracing for ONE page target. Arms the tab the way the Tracer does (an optional
- * instrumentation pause so a breakpoint binds before a freshly-opened tab's first run), captures every pause
- * as a TraceEvent stamped with wall-clock time, and re-binds after navigations as new chunks parse. Hits land
- * in the shared `hits` array, so the journey-wide `maxHits` cap and the cross-tab report both see one stream.
+ * TabTracer — non-pausing breakpoint tracing for ONE page target. It arms logpoints (a breakpoint whose
+ * condition captures stack/locals/exprs and ships them out without halting the VM), so the page runs at full
+ * speed and the journey is never frozen mid-gesture. The only real pause it still uses is the optional
+ * `beforeScriptExecution` instrumentation pause — purely to *bind* a breakpoint before a freshly-opened tab's
+ * first-run code executes; logpoint hits themselves never reach {@link #onPause}. Hits land in the shared
+ * `hits` array, so the journey-wide `maxHits` cap and the cross-tab report both see one stream.
*/
export class TabTracer {
#driver: CdpDriver;
@@ -20,8 +22,9 @@ export class TabTracer {
#hits: TracedHit[];
#binder: BpBinder;
#sm: SourceMaps;
- #capturer: EventCapturer;
- #ctx: CdpCtx;
+ #capturer: LogpointCapturer;
+ #events: import("../domain/TraceEvent.js").TraceEvent[] = [];
+ #chain: Promise = Promise.resolve();
#instrId: string | null = null;
#loaded = false;
@@ -29,21 +32,25 @@ export class TabTracer {
this.#driver = driver;
this.#cfg = cfg;
this.#hits = hits;
- this.#binder = new BpBinder(cfg.bps);
+ this.#binder = new BpBinder(cfg.bps, cfg.exprs);
this.#sm = new SourceMaps(driver, cfg.root);
- this.#capturer = new EventCapturer(driver);
- this.#ctx = { t0: performance.now(), events: [], frames: cfg.frames, exprs: cfg.exprs, steps: [], sm: this.#sm, bpById: this.#binder.bpById };
+ this.#capturer = new LogpointCapturer(driver, this.#sm, performance.now(), cfg.frames);
}
/**
- * Enable the debugger, optionally instrument first-run (needed for a freshly-opened tab whose code runs once
- * on load), wire the pause handler, and bind what we can now. `instrument` is false for an already-live tab
- * (e.g. Pulse, where the handler fires later on click) — we just bind and re-bind after each navigation.
+ * Enable the debugger, install the logpoint transport (the emit binding + the per-document serializer),
+ * optionally instrument first-run so a breakpoint binds before on-load code runs, wire the binding +
+ * instrumentation handlers, and bind what we can now. `instrument` is false for an already-live tab.
*/
async arm(instrument: boolean): Promise {
this.#driver.on(Cdp.Page.loadEventFired, () => { this.#loaded = true; });
await this.#driver.send(Cdp.Debugger.enable).catch(() => {});
await this.#driver.send(Cdp.Debugger.setPauseOnExceptions, { state: "none" }).catch(() => {});
+ await this.#driver.send(Cdp.Runtime.addBinding, { name: BINDING_NAME }).catch(() => {});
+ await this.#driver.send(Cdp.Runtime.evaluate, { expression: HELPER_SOURCE }).catch(() => {});
+ // A navigation builds a fresh context where the serializer global is gone — re-install it per document.
+ await this.#driver.send(Cdp.Page.addScriptToEvaluateOnNewDocument, { source: HELPER_SOURCE }).catch(() => {});
+ this.#driver.on(Cdp.Runtime.bindingCalled, (e: any) => { if (e?.name === BINDING_NAME) this.#onHit(e.payload); });
if (instrument) {
const r = await this.#driver.send(Cdp.Debugger.setInstrumentationBreakpoint, { instrumentation: "beforeScriptExecution" }).catch(() => null);
this.#instrId = r?.breakpointId ?? null;
@@ -62,25 +69,30 @@ export class TabTracer {
return this.#binder.report();
}
+ /** A logpoint fired. Serialize conversions (the stack resolve is async) so seq + ordering stay stable. */
+ #onHit(payload: string): void {
+ if (this.#hits.length >= this.#cfg.maxHits) return;
+ this.#chain = this.#chain.then(async () => {
+ if (this.#hits.length >= this.#cfg.maxHits) return;
+ const ev = await this.#capturer.toEvent(payload, this.#events.length + 1).catch(() => null);
+ if (!ev) return;
+ this.#events.push(ev);
+ this.#hits.push({ ev, t: Date.now() });
+ this.#cfg.onEvent?.(this.#hits.map((h) => h.ev));
+ });
+ }
+
+ /** Only the instrumentation pause reaches here now (logpoints don't pause); bind, then drop it once settled. */
async #onPause(paused: any): Promise {
try {
+ if (paused.reason !== "instrumentation") { await this.#driver.send(Cdp.Debugger.resume).catch(() => {}); return; }
const cap = this.#hits.length < this.#cfg.maxHits;
- if (paused.reason === "instrumentation") {
- // A script is about to run for the first time — bind any breakpoints it carries, then drop the
- // instrumentation pause once everything's bound (or the page loaded, or we've hit the cap).
- if (cap && !this.#binder.allSettled()) await this.#binder.tryBind(this.#driver, this.#sm);
- if ((this.#binder.allSettled() || this.#loaded || !cap) && this.#instrId) {
- const id = this.#instrId; this.#instrId = null;
- await this.#driver.send(Cdp.Debugger.removeBreakpoint, { breakpointId: id }).catch(() => {});
- }
- } else if (cap) {
- const ev = await this.#capturer.capture(paused, "breakpoint", this.#ctx);
- this.#ctx.events.push(ev);
- this.#hits.push({ ev, t: Date.now() });
- // Stream the cross-tab event list so far (same shape traceChrome returns) for live collector updates.
- this.#cfg.onEvent?.(this.#hits.map((h) => h.ev));
+ if (cap && !this.#binder.allSettled()) await this.#binder.tryBind(this.#driver, this.#sm);
+ if ((this.#binder.allSettled() || this.#loaded || !cap) && this.#instrId) {
+ const id = this.#instrId; this.#instrId = null;
+ await this.#driver.send(Cdp.Debugger.removeBreakpoint, { breakpointId: id }).catch(() => {});
}
- } catch { /* keep the journey moving even if a capture fails */ }
+ } catch { /* keep the journey moving */ }
await this.#driver.send(Cdp.Debugger.resume).catch(() => {});
}
}
diff --git a/src/engine/Tracer.ts b/src/engine/Tracer.ts
index 6875f70..d6803fd 100644
--- a/src/engine/Tracer.ts
+++ b/src/engine/Tracer.ts
@@ -3,9 +3,9 @@ import { performance } from "node:perf_hooks";
import { CdpDriver, log } from "../transport/CdpDriver.js";
import { Cdp } from "../transport/cdp.js";
import { SourceMaps } from "./SourceMaps.js";
-import { BreakpointResolver } from "./BreakpointResolver.js";
import { BpBinder } from "./BpBinder.js";
-import { EventCapturer, type CdpCtx } from "./EventCapturer.js";
+import { BreakpointResolver } from "./BreakpointResolver.js";
+import { BINDING_NAME, HELPER_SOURCE, LogpointCapturer } from "./Logpoint.js";
import { CurlTrigger, type CurlResult } from "./CurlTrigger.js";
import { Screencaster } from "./Screencaster.js";
import { JourneyRunner, type StepResult, type TraceConfig } from "./JourneyRunner.js";
@@ -56,70 +56,21 @@ export interface TraceOptions {
sessionId?: string;
args?: Record;
/**
- * Progress hook, fired once per captured event with the full set of events captured *so far* (the same
- * growing array, shared across tabs for Chrome). Lets the command layer stream partial traces to a
- * collector while the run is still in flight, instead of only emitting the finished envelope.
+ * Progress hook, fired once per captured event with the full set of events captured *so far*. Lets the
+ * command layer stream partial traces to a collector while the run is still in flight.
*/
onEvent?: (events: TraceEvent[]) => void;
}
-/**
- * A CapturePlan is the *only* thing that differs between the Node and Chrome targets. Everything
- * structural — connect, enable, bind-before-trigger, the pause/capture loop, cleanup — lives in
- * Tracer.#capture and is shared. So a change to capture/binding/teardown affects both targets at once;
- * a change to arming or triggering touches just the one target that needs it.
- */
-interface CapturePlan {
- result: CaptureResult;
- /** discover the CDP websocket for this target. */
- resolveWs(): Promise;
- /** target-specific protocol domains beyond Runtime+Debugger (Chrome: Page+Network). */
- enableExtra(driver: CdpDriver): Promise;
- /** wire event listeners (Chrome: console/network/load). */
- attach(driver: CdpDriver): void;
- /**
- * Make breakpoints bindable *before* the traced code runs. Node: the process is already up with its
- * scripts parsed, so we just let them settle. Chrome: a fresh page hasn't parsed anything yet, so we
- * arm a `beforeScriptExecution` instrumentation pause — the engine then binds as each script appears
- * during the trigger navigation, before that script executes.
- */
- arm(driver: CdpDriver): Promise;
- /** true for targets whose binding is interleaved with the trigger via instrumentation pauses. */
- instrumentation: boolean;
- /** the page finished loading (Chrome) — used to stop instrumentation pumping and to shorten waits. */
- loaded(): boolean;
- /** fire the trigger that exercises the code (Node: curl; Chrome: navigate). Non-blocking. */
- fire(driver: CdpDriver, ctx: CdpCtx): void;
- /** per-pause wait budget. */
- timeout(): number;
- /** wait budget for step plans at the first hit. */
- stepTimeoutMs: number;
- /** a `null` pause means the wait was interrupted; Node breaks (curl is done), Chrome keeps going. */
- stopOnInterrupt: boolean;
- /** stop after capturing a hit (Node: once the trigger settled and nothing is queued). */
- shouldStop(driver: CdpDriver): boolean;
- /** drop the instrumentation pause once binding is settled (Chrome). */
- removeInstr(driver: CdpDriver): Promise;
- /** await any outstanding trigger work (Node: the curl promise). */
- drain(): Promise;
- /** post-capture work (Chrome: final url + screenshot, stop screencast). */
- finish(driver: CdpDriver): Promise;
- /** notified of each captured breakpoint event (Chrome --record: stamps it with wall-clock time). */
- onCapture?(ev: TraceEvent): void;
-}
-
/**
* Tracer — the trigger+capture engine, one method per target, each returning a CaptureResult so the command
- * layer treats targets symmetrically. Depends on the CdpDriver (ProtocolDriver) via DIP. `traceNode` binds
- * into an already-running `--inspect` process and fires one curl through the shared `#capture` pump loop.
- * `traceChrome` drives a scripted UI journey across tabs and records it — a fundamentally different control
- * flow (multi-step, async, tab-following), so it delegates to JourneyRunner rather than the one-shot pump;
- * the breakpoint primitives (BpBinder, EventCapturer, SourceMaps) are shared across both. Produces domain
- * entities (TraceEvent, Breakpoint) — the command layer assembles them into a Trace.
+ * layer treats targets symmetrically. Breakpoints are armed as **non-pausing logpoints** (see {@link BpBinder}):
+ * a hit ships its captured stack/locals/exprs out through a CDP binding without ever halting the VM, so the
+ * traced app runs at full speed and a hot path is no longer capped by per-hit pause cost. `traceNode` binds
+ * into a running `--inspect` process and fires one curl; `traceChrome` drives a scripted UI journey across tabs
+ * and records it (delegating to JourneyRunner). The binding/binding-capture primitives are shared across both.
*/
export class Tracer {
- // ---- shared CDP helpers ----------------------------------------------------------------------
-
async #settleScripts(driver: CdpDriver, ms = 1500): Promise {
let prev = -1, stable = 0;
for (let i = 0; i < ms / 100; i++) {
@@ -130,104 +81,81 @@ export class Tracer {
}
}
- // ---- the one capture loop, shared by every target -------------------------------------------
+ /** Install the logpoint transport on a freshly-enabled context: the emit binding + the in-page serializer. */
+ static async installLogpointHelper(driver: CdpDriver): Promise {
+ await driver.send(Cdp.Runtime.addBinding, { name: BINDING_NAME }).catch(() => {});
+ await driver.send(Cdp.Runtime.evaluate, { expression: HELPER_SOURCE }).catch(() => {});
+ }
+
+ // ---- Node target -----------------------------------------------------------------------------
- async #capture(opts: TraceOptions, plan: CapturePlan): Promise {
- const { breakpoints = [], root, exprs = [], steps = [], frames = 6, maxHits = 25, attachTimeoutMs = DEFAULT_ATTACH_TIMEOUT_MS, sessionId } = opts;
- const bps = BreakpointResolver.resolveAll(breakpoints, root);
- const result = plan.result;
- const driver = await CdpDriver.connect(opts.wsUrl || (await plan.resolveWs()), attachTimeoutMs);
+ async traceNode(opts: TraceOptions): Promise {
+ const {
+ port = DEFAULT_NODE_PORT, curl, urlMatch, titleMatch, breakpoints = [], root, exprs = [],
+ frames = 6, maxHits = 100, attachTimeoutMs = DEFAULT_ATTACH_TIMEOUT_MS, reqTimeoutMs = 60000,
+ } = opts;
+ const result: CaptureResult = { target: TargetKind.Node, trigger: curl ?? null, breakpoints: [], events: [] };
+ const driver = await CdpDriver.connect(opts.wsUrl || (await CdpDriver.resolveWsUrl(port, { kind: TargetKind.Node, urlMatch, titleMatch })), attachTimeoutMs);
const sm = new SourceMaps(driver, root);
- const ctx: CdpCtx = { t0: performance.now(), events: result.events, frames, exprs, steps, sm, bpById: new Map(), sessionId };
- const binder = new BpBinder(bps);
- const capturer = new EventCapturer(driver);
- plan.attach(driver);
+ const binder = new BpBinder(BreakpointResolver.resolveAll(breakpoints, root), exprs);
+
+ const t0 = performance.now();
+ const capturer = new LogpointCapturer(driver, sm, t0, frames);
+ const pending: string[] = [];
+ driver.on(Cdp.Runtime.bindingCalled, (e: any) => { if (e?.name === BINDING_NAME) pending.push(e.payload); });
+
try {
await driver.send(Cdp.Runtime.enable);
await driver.send(Cdp.Debugger.enable);
await driver.send(Cdp.Debugger.setPauseOnExceptions, { state: "none" });
- await plan.enableExtra(driver);
- await plan.arm(driver);
- await binder.tryBind(driver, sm); // Node binds here; Chrome binds during instrumentation pumping
- ctx.bpById = binder.bpById;
-
- ctx.t0 = performance.now();
- plan.fire(driver, ctx);
+ await Tracer.installLogpointHelper(driver);
+ await this.#settleScripts(driver);
+ await binder.tryBind(driver, sm);
- while (result.events.length < maxHits) {
- let paused: any;
- try { paused = await driver.waitForStop(plan.timeout()); } catch { break; }
- if (paused === null) { if (plan.stopOnInterrupt) break; else continue; }
- // Chrome: a script is about to run for the first time — bind any breakpoints it carries, then resume.
- if (plan.instrumentation && paused.reason === "instrumentation") {
- if (!binder.allSettled()) { await binder.tryBind(driver, sm); ctx.bpById = binder.bpById; }
- if (binder.allSettled() || plan.loaded()) await plan.removeInstr(driver);
- await driver.send(Cdp.Debugger.resume).catch(() => {});
- continue;
- }
- const ev = await capturer.capture(paused, "breakpoint", ctx);
+ let triggerDone = !curl;
+ let response: CurlResult | undefined;
+ if (curl) {
+ log(`fired: ${curl.length > 90 ? curl.slice(0, 90) + "…" : curl}`);
+ void CurlTrigger.run(curl, reqTimeoutMs).then((r) => { response = r; }).finally(() => { triggerDone = true; });
+ }
+ // Drain: hits are emitted synchronously during request handling, but bindingCalled arrives async — wait
+ // for the trigger to settle, then until the payload count goes quiet (or a hard ceiling).
+ const deadline = performance.now() + reqTimeoutMs;
+ let quiet = 0, last = -1;
+ while (performance.now() < deadline) {
+ await sleep(50);
+ const done = triggerDone && pending.length === last;
+ if (done) { if (++quiet >= 3) break; } else quiet = 0;
+ last = pending.length;
+ }
+ result.response = response;
+ // Convert the gathered payloads into events (source-map the stacks), respecting maxHits.
+ for (const payload of pending.slice(0, maxHits)) {
+ const ev = await capturer.toEvent(payload, result.events.length + 1);
+ if (!ev) continue;
result.events.push(ev);
- plan.onCapture?.(ev);
opts.onEvent?.(result.events);
- await capturer.runSteps(ctx, plan.stepTimeoutMs);
- await driver.send(Cdp.Debugger.resume).catch(() => {});
- if (plan.shouldStop(driver)) break;
}
- await plan.drain();
- await plan.finish(driver);
} catch (e: any) {
result.fatal = String(e?.stack || e?.message || e); log("FATAL", result.fatal.split("\n")[0]);
} finally {
result.breakpoints = binder.report();
for (const miss of binder.unbound()) log(`bp ${miss} → not bound (line not on this path / wrong route?)`);
- await plan.removeInstr(driver).catch(() => {});
- for (const id of ctx.bpById.keys()) await driver.send(Cdp.Debugger.removeBreakpoint, { breakpointId: id }).catch(() => {});
- await driver.send(Cdp.Debugger.resume).catch(() => {});
driver.close(); sm.dispose();
}
return result;
}
- // ---- targets: each builds a CapturePlan, then hands off to the shared loop --------------------
-
- async traceNode(opts: TraceOptions): Promise {
- const { port = DEFAULT_NODE_PORT, curl, urlMatch, titleMatch, timeoutMs = 30000, reqTimeoutMs = 60000 } = opts;
- const result: CaptureResult = { target: TargetKind.Node, trigger: curl ?? null, breakpoints: [], events: [] };
- let triggerDone = !curl;
- let triggerPromise: Promise = Promise.resolve();
- const plan: CapturePlan = {
- result,
- resolveWs: () => CdpDriver.resolveWsUrl(port, { kind: TargetKind.Node, urlMatch, titleMatch }),
- enableExtra: async () => {},
- attach: () => {},
- arm: async (driver) => { await this.#settleScripts(driver); },
- instrumentation: false,
- loaded: () => false,
- fire: (driver, ctx) => {
- if (!curl) return;
- ctx.t0 = performance.now();
- log(`fired: ${curl.length > 90 ? curl.slice(0, 90) + "…" : curl}`);
- triggerPromise = CurlTrigger.run(curl, reqTimeoutMs).then((r) => { result.response = r; }).finally(() => { triggerDone = true; driver.interrupt(); });
- },
- timeout: () => timeoutMs,
- stepTimeoutMs: timeoutMs,
- stopOnInterrupt: true,
- shouldStop: (driver) => triggerDone && !driver.hasQueued(),
- removeInstr: async () => {},
- drain: async () => { await triggerPromise.catch(() => {}); },
- finish: async () => {},
- };
- return this.#capture(opts, plan);
- }
+ // ---- Chrome target ---------------------------------------------------------------------------
/**
- * Chrome target: drive the scripted UI journey (`opts.steps`) with breakpoints armed on every tab and the
+ * Chrome target: drive the scripted UI journey (`opts.steps`) with logpoints armed on every tab and the
* screencast running throughout, so one run yields the trace events AND the frames/hits the recorder lays
* into a screen + trace-panel replay. Multi-step + tab-following needs JourneyRunner's async, sequential
- * driver — not the single-trigger `#capture` pump — but the binding/capture primitives are the same.
+ * driver; the binding/capture primitives are the same as Node.
*/
async traceChrome(opts: TraceOptions): Promise {
- const { port = DEFAULT_CHROME_PORT, steps = [], breakpoints = [], root, exprs = [], frames = 6, maxHits = 30, urlMatch } = opts;
+ const { port = DEFAULT_CHROME_PORT, steps = [], breakpoints = [], root, exprs = [], frames = 6, maxHits = 100, urlMatch } = opts;
const parsed = steps.map((s) => JourneyRunner.parseStep(s));
const cast = new Screencaster();
const config: TraceConfig = { bps: BreakpointResolver.resolveAll(breakpoints, root), root, exprs, frames, maxHits, onEvent: opts.onEvent };
@@ -235,8 +163,6 @@ export class Tracer {
let stepResults: StepResult[] = [];
let fatal: string | undefined;
try {
- // A journey that opens with a navigation needs the first tab instrumented, so breakpoints bind before
- // that page's scripts run (on-mount code executes once and is otherwise missed). parseStep guarantees order.
await runner.start(urlMatch, parsed[0]?.action === "goto");
stepResults = await runner.run(parsed);
} catch (e: any) {
diff --git a/src/transport/CdpDriver.ts b/src/transport/CdpDriver.ts
index 4ad62b9..816fba3 100644
--- a/src/transport/CdpDriver.ts
+++ b/src/transport/CdpDriver.ts
@@ -21,16 +21,10 @@ export class CdpDriver implements ProtocolDriver {
readonly source = "cdp" as const;
#client: any;
#scripts = new Map();
- #pausedQueue: any[] = [];
- #pausedWaiter: ((p: any) => void) | null = null;
private constructor(client: any) {
this.#client = client;
client.on(Cdp.Debugger.scriptParsed, (p: ScriptInfo) => { if (p?.url) this.#scripts.set(p.scriptId, p); });
- client.on(Cdp.Debugger.paused, (p: any) => {
- if (this.#pausedWaiter) { const w = this.#pausedWaiter; this.#pausedWaiter = null; w(p); }
- else this.#pausedQueue.push(p);
- });
}
static async connect(wsUrl: string, timeoutMs = DEFAULT_ATTACH_TIMEOUT_MS): Promise {
@@ -44,16 +38,6 @@ export class CdpDriver implements ProtocolDriver {
send(method: string, params: Record = {}): Promise { return this.#client.send(method, params); }
on(event: string, cb: (p: any) => void): void { this.#client.on(event, cb); }
-
- waitForStop(ms: number): Promise {
- return new Promise((res, rej) => {
- if (this.#pausedQueue.length) return res(this.#pausedQueue.shift());
- const t = setTimeout(() => { this.#pausedWaiter = null; rej(new Error("timeout")); }, ms);
- this.#pausedWaiter = (p) => { clearTimeout(t); res(p); };
- });
- }
- hasQueued(): boolean { return this.#pausedQueue.length > 0; }
- interrupt(): void { if (this.#pausedWaiter) { const w = this.#pausedWaiter; this.#pausedWaiter = null; w(null); } }
close(): void { try { this.#client.close(); } catch { /* ignore */ } }
// --- CDP-specific: the scriptParsed cache feeds source-map resolution ---
@@ -106,20 +90,3 @@ export class CdpDriver implements ProtocolDriver {
return t.webSocketDebuggerUrl as string;
}
}
-
-/** renderRemoteObject — a compact, human-readable value for a CDP RemoteObject. */
-export function renderRemoteObject(ro: any): unknown {
- if (!ro) return undefined;
- if ("value" in ro) return ro.value;
- if (ro.unserializableValue) return ro.unserializableValue;
- if (ro.type === "undefined") return undefined;
- if (ro.subtype === "null") return null;
- if (ro.type === "function") return `[Function${ro.className && ro.className !== "Function" ? ": " + ro.className : ""}]`;
- if (ro.subtype === "promise") return `[Promise <${ro.description || "pending"}>]`;
- const label = ro.className || ro.subtype || ro.type;
- if (ro.preview?.properties) {
- const props = ro.preview.properties.slice(0, 6).map((p: any) => `${p.name}: ${p.value}`).join(", ");
- return `${label} { ${props}${ro.preview.overflow ? ", …" : ""} }`;
- }
- return ro.description ? `${label} (${String(ro.description).slice(0, 80)})` : `[${label}]`;
-}
diff --git a/src/transport/ProtocolDriver.ts b/src/transport/ProtocolDriver.ts
index e44ff41..7c79b2e 100644
--- a/src/transport/ProtocolDriver.ts
+++ b/src/transport/ProtocolDriver.ts
@@ -9,12 +9,6 @@ export interface ProtocolDriver {
send(method: string, params?: Record): Promise;
/** Subscribe to a protocol event. */
on(event: string, cb: (params: any) => void): void;
- /** Resolve the next pause/stop, or `null` if interrupted, rejecting on timeout. */
- waitForStop(ms: number): Promise;
- /** Whether a pause/stop is already queued. */
- hasQueued(): boolean;
- /** Unblock a pending waitForStop with `null` (used when the trigger finishes). */
- interrupt(): void;
/** Tear down the connection. */
close(): void;
}
diff --git a/src/transport/cdp.ts b/src/transport/cdp.ts
index 9246b2b..002d2c3 100644
--- a/src/transport/cdp.ts
+++ b/src/transport/cdp.ts
@@ -8,8 +8,9 @@ export const Cdp = {
Runtime: {
enable: "Runtime.enable",
evaluate: "Runtime.evaluate",
- getProperties: "Runtime.getProperties",
+ addBinding: "Runtime.addBinding", // expose a global fn whose calls surface as bindingCalled
consoleAPICalled: "Runtime.consoleAPICalled", // (event)
+ bindingCalled: "Runtime.bindingCalled", // (event) — a logpoint emitted a captured hit
exceptionThrown: "Runtime.exceptionThrown", // (event)
},
Debugger: {
@@ -18,16 +19,14 @@ export const Cdp = {
setPauseOnExceptions: "Debugger.setPauseOnExceptions",
setInstrumentationBreakpoint: "Debugger.setInstrumentationBreakpoint",
setBreakpointByUrl: "Debugger.setBreakpointByUrl",
+ getScriptSource: "Debugger.getScriptSource", // generated source → in-scope name extraction
removeBreakpoint: "Debugger.removeBreakpoint",
- evaluateOnCallFrame: "Debugger.evaluateOnCallFrame",
- stepOver: "Debugger.stepOver",
- stepInto: "Debugger.stepInto",
- stepOut: "Debugger.stepOut",
paused: "Debugger.paused", // (event)
scriptParsed: "Debugger.scriptParsed", // (event)
},
Page: {
enable: "Page.enable",
+ addScriptToEvaluateOnNewDocument: "Page.addScriptToEvaluateOnNewDocument", // re-inject the logpoint helper per document
navigate: "Page.navigate",
captureScreenshot: "Page.captureScreenshot",
bringToFront: "Page.bringToFront",
From bad4dcb91580dd7e0ca33699dab4c17c03b15396 Mon Sep 17 00:00:00 2001
From: burrows99
Date: Thu, 18 Jun 2026 15:11:11 +0100
Subject: [PATCH 2/6] chore(engine): drop dead trace options + node
safety-resume
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Sweep of unused surface left after the logpoint migration:
- remove TraceOptions.url/shot/record and DynamicRequest.record — all
declared but never read (--url becomes a goto step; recording is gated
by isChrome, not a flag)
- traceNode: add a safety Debugger.paused→resume so a stray `debugger;`
in the traced app can't stall the drain loop
Note: the beforeScriptExecution instrumentation pause was evaluated for
removal and kept — it's the bind-before-first-run mechanism, not pause
bloat. Removing it dropped on-mount capture from 3 hits to 0 in the
react-app fixture (logpoint hits never pause regardless).
Co-Authored-By: Claude Opus 4.8
---
src/cli/commands/DynamicCommand.ts | 1 -
src/engine/Tracer.ts | 6 +++---
2 files changed, 3 insertions(+), 4 deletions(-)
diff --git a/src/cli/commands/DynamicCommand.ts b/src/cli/commands/DynamicCommand.ts
index a35ead2..49204dc 100644
--- a/src/cli/commands/DynamicCommand.ts
+++ b/src/cli/commands/DynamicCommand.ts
@@ -22,7 +22,6 @@ export type DynamicTargetKind = TargetKind;
export interface DynamicRequest extends TraceOptions {
target: DynamicTargetKind;
launch?: boolean; // chrome: spawn a throwaway headless Chrome instead of attaching to `port`
- record?: boolean; // chrome: render + upload a debug-replay video (on by default)
recordOut?: string; // explicit output path (else a temp file)
/**
* Live progress sink: called with a partial Trace as soon as the run starts (0 events) and again on every
diff --git a/src/engine/Tracer.ts b/src/engine/Tracer.ts
index d6803fd..00cef04 100644
--- a/src/engine/Tracer.ts
+++ b/src/engine/Tracer.ts
@@ -48,9 +48,6 @@ export interface TraceOptions {
attachTimeoutMs?: number;
reqTimeoutMs?: number;
curl?: string;
- url?: string;
- shot?: string;
- record?: boolean;
urlMatch?: string;
titleMatch?: string;
sessionId?: string;
@@ -103,6 +100,8 @@ export class Tracer {
const capturer = new LogpointCapturer(driver, sm, t0, frames);
const pending: string[] = [];
driver.on(Cdp.Runtime.bindingCalled, (e: any) => { if (e?.name === BINDING_NAME) pending.push(e.payload); });
+ // Logpoints never pause; this safety resume keeps a stray `debugger;` in the traced app from hanging the run.
+ driver.on(Cdp.Debugger.paused, () => { void driver.send(Cdp.Debugger.resume).catch(() => {}); });
try {
await driver.send(Cdp.Runtime.enable);
@@ -163,6 +162,7 @@ export class Tracer {
let stepResults: StepResult[] = [];
let fatal: string | undefined;
try {
+ // A leading `goto` navigates a fresh tab, so instrument it to bind before first-run/on-mount code runs.
await runner.start(urlMatch, parsed[0]?.action === "goto");
stepResults = await runner.run(parsed);
} catch (e: any) {
From 1e58f08db237da5884a49e2ece285fde7b44e496 Mon Sep 17 00:00:00 2001
From: burrows99
Date: Thu, 18 Jun 2026 15:18:17 +0100
Subject: [PATCH 3/6] docs(engine): name the sole VM halt "THE ONE PAUSE",
end-to-end
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The bind-before-first-run instrumentation pause was scattered as a bare
`instrument` boolean + a CDP call buried in #onPause, with no signpost —
easy to mistake for leftover pause-tracer code. Make it unmissable:
- rename instrument/instrumentFirst → bindBeforeFirstRun everywhere
(TabTracer.arm, JourneyRunner #connect/start/#waitNewTab, Tracer)
- extract TabTracer.#armFirstRunBindPause(); rename #instrId → #firstRunPauseId
- one canonical doc block on TabTracer + a greppable "THE ONE PAUSE" marker
at every touchpoint (incl. README), stating it's bind-only, never on a
hit, self-dropping, and load-bearing (3→0 on-mount hits without it)
No behavior change — Chrome on-mount still 3 hits, 37 tests pass.
Co-Authored-By: Claude Opus 4.8
---
README.md | 1 +
src/engine/JourneyRunner.ts | 24 +++++++-------
src/engine/TabTracer.ts | 64 +++++++++++++++++++++++++------------
src/engine/Tracer.ts | 6 ++--
4 files changed, 60 insertions(+), 35 deletions(-)
diff --git a/README.md b/README.md
index 9b324b7..f57708c 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,7 @@ Execution tracer & analyzer — one JSON envelope across breakpoint traces (Node
Point it at a running program, give it breakpoints + a trigger → a full **execution trace** (every hit in order: call stack, locals, watched expressions, timing) as **one JSON envelope**, identical shape across targets.
- **Breakpoints never pause the program.** They're armed as non-pausing *logpoints*: each hit captures its stack, every in-scope local (read statically from the source — no naming needed), and any extra `--expr`, then ships it out without halting the VM. The app runs at full speed, hot paths are cheap, and there's no human-style "stop and wait" — built for an agent that reads the trace and re-aims breakpoints, not a human stepping by hand. (Trade-off: capturing *all* locals automatically needs source the runtime can read by name — perfect for Node and dev-mode frontends; a minified production bundle yields mangled local names, though `--expr` and the stack still work.)
+ - *The one exception — "THE ONE PAUSE":* a Chrome run that opens by navigating a fresh tab briefly halts **once, during setup**, to bind a breakpoint before the page's first-run/on-mount code executes (CDP `beforeScriptExecution`). It never halts on a hit and drops itself as soon as binding settles. It lives in `TabTracer` (grep `THE ONE PAUSE`). Removing it loses on-mount capture entirely — measured 3 → 0 hits.
- Not "a debugger" — **OpenTelemetry for software execution**: every source (debug protocol, span exporter, shell) normalizes to one `Event`; events are the asset. See [`docs/MIGRATION.md`](docs/MIGRATION.md).
```
diff --git a/src/engine/JourneyRunner.ts b/src/engine/JourneyRunner.ts
index 5256182..a26329c 100644
--- a/src/engine/JourneyRunner.ts
+++ b/src/engine/JourneyRunner.ts
@@ -80,7 +80,7 @@ export class JourneyRunner {
}
/** Connect to a target, enable its domains, and (when tracing) attach a TabTracer. */
- async #connect(target: any, opts: { trace?: boolean; instrument?: boolean } = {}): Promise {
+ async #connect(target: any, opts: { trace?: boolean; bindBeforeFirstRun?: boolean } = {}): Promise {
const d = await CdpDriver.connect(target.webSocketDebuggerUrl);
await d.send(Cdp.Page.enable).catch(() => {});
await d.send(Cdp.Runtime.enable).catch(() => {});
@@ -96,7 +96,7 @@ export class JourneyRunner {
if (opts.trace && this.#trace) {
const tracer = new TabTracer(d, this.#trace, this.traced);
this.#tracers.set(d, tracer);
- await tracer.arm(!!opts.instrument);
+ await tracer.arm(!!opts.bindBeforeFirstRun);
}
return d;
}
@@ -109,13 +109,13 @@ export class JourneyRunner {
}
/**
- * Attach to an existing page (or one matching `urlMatch`) and begin recording it. `instrumentFirst` arms a
- * `beforeScriptExecution` pause on this tab so breakpoints bind *before* the upcoming navigation's scripts
- * run — set it when the journey opens with a `goto`, so first-run / on-mount code (e.g. a SPA computing a
- * value during initial render) is caught instead of missed. Left false for attach-then-click flows, where
- * an instrumentation pause could disturb the user-gesture timing the handler depends on.
+ * Attach to an existing page (or one matching `urlMatch`) and begin recording it. `bindBeforeFirstRun` arms
+ * THE ONE PAUSE (see {@link TabTracer}) so breakpoints bind *before* the upcoming navigation's scripts run —
+ * set it when the journey opens with a `goto`, so first-run / on-mount code (e.g. a SPA computing a value
+ * during initial render) is caught instead of missed. Left false for an attach-then-click flow, where the
+ * tab is already live and its handlers fire later on a click.
*/
- async start(urlMatch?: string, instrumentFirst = false): Promise {
+ async start(urlMatch?: string, bindBeforeFirstRun = false): Promise {
let pages = await this.#pages();
if (!pages.length) {
// The debug Chrome is up but tabless (a prior run / the impersonation popup closed its tabs). Open a
@@ -129,20 +129,18 @@ export class JourneyRunner {
}
const target = (urlMatch && pages.find((p) => (p.url || "").includes(urlMatch))) || pages[0];
for (const p of pages) this.#known.add(p.id); // everything open now is "known"; only future tabs count as new
- // A leading `goto` navigates this tab, so instrument it to bind before first-run/on-mount code; an
- // attach-then-click launcher tab (e.g. Pulse) stays uninstrumented — its handlers fire later, on click.
- const d = await this.#connect(target, { trace: true, instrument: instrumentFirst });
+ const d = await this.#connect(target, { trace: true, bindBeforeFirstRun });
this.#t0 = Date.now();
await this.#switchTo(d);
}
- /** Poll for a freshly-opened tab (e.g. the impersonation popup), attach + instrument it, and follow it. */
+ /** Poll for a freshly-opened tab (e.g. the impersonation popup), attach (binding before its first run), and follow it. */
async #waitNewTab(timeoutMs = 12000): Promise {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const fresh = (await this.#pages()).find((p) => !this.#known.has(p.id));
if (fresh) {
- const d = await this.#connect(fresh, { trace: true, instrument: true }); // opened app tab — catch its first-run code
+ const d = await this.#connect(fresh, { trace: true, bindBeforeFirstRun: true }); // opened app tab — bind before its first-run code
await sleep(300);
await this.#switchTo(d);
return true;
diff --git a/src/engine/TabTracer.ts b/src/engine/TabTracer.ts
index a917643..69b60aa 100644
--- a/src/engine/TabTracer.ts
+++ b/src/engine/TabTracer.ts
@@ -6,15 +6,24 @@ import { SourceMaps } from "./SourceMaps.js";
import { BpBinder } from "./BpBinder.js";
import { BINDING_NAME, HELPER_SOURCE, LogpointCapturer } from "./Logpoint.js";
import { Breakpoint } from "../domain/Breakpoint.js";
+import type { TraceEvent } from "../domain/TraceEvent.js";
import type { TraceConfig, TracedHit } from "./JourneyRunner.js";
/**
- * TabTracer — non-pausing breakpoint tracing for ONE page target. It arms logpoints (a breakpoint whose
- * condition captures stack/locals/exprs and ships them out without halting the VM), so the page runs at full
- * speed and the journey is never frozen mid-gesture. The only real pause it still uses is the optional
- * `beforeScriptExecution` instrumentation pause — purely to *bind* a breakpoint before a freshly-opened tab's
- * first-run code executes; logpoint hits themselves never reach {@link #onPause}. Hits land in the shared
- * `hits` array, so the journey-wide `maxHits` cap and the cross-tab report both see one stream.
+ * TabTracer — non-pausing breakpoint tracing for ONE page target.
+ *
+ * ┌─ ⏸️ THE ONE PAUSE ────────────────────────────────────────────────────────────────────────────────────┐
+ * │ trace-cli NEVER pauses on a breakpoint hit. Breakpoints are logpoints (see Logpoint.ts): a hit captures │
+ * │ its stack/locals/exprs and continues, so the page is never frozen. There is exactly ONE place anything │
+ * │ halts the VM — the `beforeScriptExecution` instrumentation pause armed by {@link #armFirstRunBindPause}, │
+ * │ and only when `bindBeforeFirstRun` is set. Its sole job is to BIND a breakpoint before a freshly- │
+ * │ navigated tab runs its first-run / on-mount code; it fires during binding, never on a hit, and removes │
+ * │ itself the moment binding settles. It is not optional: dropping it took on-mount capture from 3 hits to │
+ * │ 0 on the react-app fixture, because a source-mapped breakpoint cannot be placed before the script runs │
+ * │ without halting at parse time. If you ever observe a pause anywhere ELSE, that's a bug — not by design. │
+ * └────────────────────────────────────────────────────────────────────────────────────────────────────────┘
+ *
+ * Hits land in the shared `hits` array, so the journey-wide `maxHits` cap and the cross-tab report see one stream.
*/
export class TabTracer {
#driver: CdpDriver;
@@ -23,9 +32,10 @@ export class TabTracer {
#binder: BpBinder;
#sm: SourceMaps;
#capturer: LogpointCapturer;
- #events: import("../domain/TraceEvent.js").TraceEvent[] = [];
+ #events: TraceEvent[] = [];
#chain: Promise = Promise.resolve();
- #instrId: string | null = null;
+ /** id of THE ONE PAUSE (the bind-before-first-run instrumentation breakpoint), or null when not armed/dropped. */
+ #firstRunPauseId: string | null = null;
#loaded = false;
constructor(driver: CdpDriver, cfg: TraceConfig, hits: TracedHit[]) {
@@ -38,11 +48,12 @@ export class TabTracer {
}
/**
- * Enable the debugger, install the logpoint transport (the emit binding + the per-document serializer),
- * optionally instrument first-run so a breakpoint binds before on-load code runs, wire the binding +
- * instrumentation handlers, and bind what we can now. `instrument` is false for an already-live tab.
+ * Enable the debugger, install the logpoint transport (the emit binding + the per-document serializer), wire
+ * the hit + pause handlers, and bind what's already parsed. `bindBeforeFirstRun` arms THE ONE PAUSE (see the
+ * class doc) so a breakpoint binds before a freshly-navigated tab's on-mount code — set it for a leading
+ * `goto`; leave it off for an already-live tab whose handlers fire later (on a click).
*/
- async arm(instrument: boolean): Promise {
+ async arm(bindBeforeFirstRun: boolean): Promise {
this.#driver.on(Cdp.Page.loadEventFired, () => { this.#loaded = true; });
await this.#driver.send(Cdp.Debugger.enable).catch(() => {});
await this.#driver.send(Cdp.Debugger.setPauseOnExceptions, { state: "none" }).catch(() => {});
@@ -51,14 +62,22 @@ export class TabTracer {
// A navigation builds a fresh context where the serializer global is gone — re-install it per document.
await this.#driver.send(Cdp.Page.addScriptToEvaluateOnNewDocument, { source: HELPER_SOURCE }).catch(() => {});
this.#driver.on(Cdp.Runtime.bindingCalled, (e: any) => { if (e?.name === BINDING_NAME) this.#onHit(e.payload); });
- if (instrument) {
- const r = await this.#driver.send(Cdp.Debugger.setInstrumentationBreakpoint, { instrumentation: "beforeScriptExecution" }).catch(() => null);
- this.#instrId = r?.breakpointId ?? null;
- }
+ if (bindBeforeFirstRun) await this.#armFirstRunBindPause();
this.#driver.on(Cdp.Debugger.paused, (p: any) => { void this.#onPause(p); });
await this.#binder.tryBind(this.#driver, this.#sm);
}
+ /**
+ * ⏸️ THE ONE PAUSE (see class doc). Arm a `beforeScriptExecution` instrumentation breakpoint so the engine
+ * halts just before each new script runs, long enough to bind any breakpoint that script carries before its
+ * first-run code executes. This is the only VM halt in the whole tracer; {@link #onPause} drops it once
+ * binding settles. Logpoint hits do NOT come through here.
+ */
+ async #armFirstRunBindPause(): Promise {
+ const r = await this.#driver.send(Cdp.Debugger.setInstrumentationBreakpoint, { instrumentation: "beforeScriptExecution" }).catch(() => null);
+ this.#firstRunPauseId = r?.breakpointId ?? null;
+ }
+
/** Re-bind after a navigation — newly-parsed chunks may carry a previously-unbound breakpoint. */
async reBind(): Promise {
await this.#binder.tryBind(this.#driver, this.#sm).catch(() => {});
@@ -82,15 +101,20 @@ export class TabTracer {
});
}
- /** Only the instrumentation pause reaches here now (logpoints don't pause); bind, then drop it once settled. */
+ /**
+ * The handler for THE ONE PAUSE (see class doc). Only a `reason: "instrumentation"` halt does real work —
+ * bind the just-parsed scripts, then drop the pause once everything's bound (or the page finished loading,
+ * or we hit the cap). ANY other pause is not ours (e.g. a `debugger;` statement in the traced app); resume
+ * immediately so a logpoint trace can never freeze. Breakpoint *hits* never reach here — they don't pause.
+ */
async #onPause(paused: any): Promise {
try {
if (paused.reason !== "instrumentation") { await this.#driver.send(Cdp.Debugger.resume).catch(() => {}); return; }
const cap = this.#hits.length < this.#cfg.maxHits;
if (cap && !this.#binder.allSettled()) await this.#binder.tryBind(this.#driver, this.#sm);
- if ((this.#binder.allSettled() || this.#loaded || !cap) && this.#instrId) {
- const id = this.#instrId; this.#instrId = null;
- await this.#driver.send(Cdp.Debugger.removeBreakpoint, { breakpointId: id }).catch(() => {});
+ if ((this.#binder.allSettled() || this.#loaded || !cap) && this.#firstRunPauseId) {
+ const id = this.#firstRunPauseId; this.#firstRunPauseId = null;
+ await this.#driver.send(Cdp.Debugger.removeBreakpoint, { breakpointId: id }).catch(() => {}); // drop THE ONE PAUSE
}
} catch { /* keep the journey moving */ }
await this.#driver.send(Cdp.Debugger.resume).catch(() => {});
diff --git a/src/engine/Tracer.ts b/src/engine/Tracer.ts
index 00cef04..3a981a1 100644
--- a/src/engine/Tracer.ts
+++ b/src/engine/Tracer.ts
@@ -162,8 +162,10 @@ export class Tracer {
let stepResults: StepResult[] = [];
let fatal: string | undefined;
try {
- // A leading `goto` navigates a fresh tab, so instrument it to bind before first-run/on-mount code runs.
- await runner.start(urlMatch, parsed[0]?.action === "goto");
+ // Arm THE ONE PAUSE (bind-before-first-run, see TabTracer) only when the journey opens by navigating a
+ // fresh tab — a leading `goto` — so on-mount code is caught. Attach-then-click flows don't need it.
+ const bindBeforeFirstRun = parsed[0]?.action === "goto";
+ await runner.start(urlMatch, bindBeforeFirstRun);
stepResults = await runner.run(parsed);
} catch (e: any) {
fatal = String(e?.message ?? e);
From cc0e4c7f6ae9528551eec944179d8ccdbf462ac8 Mon Sep 17 00:00:00 2001
From: burrows99
Date: Thu, 18 Jun 2026 16:41:26 +0100
Subject: [PATCH 4/6] feat: implement ShellAnalysisCommand base class for tool
invocation
- Added ShellAnalysisCommand as an abstract base class for running external analysis tools and normalizing their output into a Trace.
- Introduced ToolInvocation and AnalysisOutcome interfaces to standardize tool invocation parameters and results.
- Updated SymbolsCommand to extend ShellAnalysisCommand, encapsulating the logic for invoking tree-sitter and interpreting its output.
- Refactored runTool function to use spawn instead of execFile for better handling of large outputs, capturing stdout and stderr to temporary files.
- Created graphView module for rendering call graphs in both text and HTML formats, separating concerns from GraphCommand.
- Added shared empty/error guard in TraceCommand for consistent rendering of error messages across commands.
---
src/cli/Cli.ts | 21 +-
src/cli/commands/ComplexityCommand.ts | 67 ++--
src/cli/commands/DepsCommand.ts | 93 ++++--
src/cli/commands/GraphCommand.ts | 57 +---
src/cli/commands/ShellAnalysisCommand.ts | 79 +++++
src/cli/commands/SymbolsCommand.ts | 77 +++--
src/cli/commands/TraceCommand.ts | 12 +
src/cli/commands/graphView.ts | 376 +++++++++++++++++++++++
src/shared/runTool.ts | 67 ++--
9 files changed, 669 insertions(+), 180 deletions(-)
create mode 100644 src/cli/commands/ShellAnalysisCommand.ts
create mode 100644 src/cli/commands/graphView.ts
diff --git a/src/cli/Cli.ts b/src/cli/Cli.ts
index 421df8f..9e9d776 100644
--- a/src/cli/Cli.ts
+++ b/src/cli/Cli.ts
@@ -1,5 +1,8 @@
import { Command, CommanderError } from "commander";
import { writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { randomUUID } from "node:crypto";
import { DynamicCommand, type DynamicTargetKind } from "./commands/DynamicCommand.js";
import { GraphCommand } from "./commands/GraphCommand.js";
@@ -119,6 +122,13 @@ export class Cli {
});
emit(trace, () => cmd.render(trace), o);
+ // --html [path] → also write the interactive call-graph diagram (force-directed nodes + edges). Bare flag →
+ // a temp file; the path is logged to stderr (like --json ) so stdout stays the pure envelope/human channel.
+ if (o.html != null) {
+ const htmlPath = typeof o.html === "string" ? o.html : join(tmpdir(), `trace-graph-${randomUUID()}.html`);
+ writeFileSync(htmlPath, cmd.renderHtml(trace));
+ log.info("graph HTML written", { path: htmlPath });
+ }
const collector = process.env.TRACE_COLLECTOR_URL;
if (collector) await Collector.emit(collector, trace.toJSON());
process.exit(trace.hasErrors() ? 1 : 0);
@@ -135,7 +145,13 @@ export class Cli {
async #runDeps(o: any): Promise {
if (!o.entry) usage("static deps needs --entry ");
const cmd = new DepsCommand();
- const trace = await cmd.run({ entry: o.entry, root: o.root, args: { entry: o.entry, ...(o.root ? { root: o.root } : {}) } });
+ const trace = await cmd.run({
+ entry: o.entry,
+ root: o.root,
+ extensions: o.ext,
+ tsConfig: o.tsconfig,
+ args: { entry: o.entry, ...(o.root ? { root: o.root } : {}) },
+ });
await this.#finish(trace, () => cmd.render(trace), o);
}
@@ -187,6 +203,7 @@ export class Cli {
.option("--root ", "project root / LSP workspace (default: auto — nearest tsconfig/package.json/.git above the entry)")
.option("--server ", "override the LSP server (default: auto by file extension; bundled typescript-language-server for TS/JS, e.g. \"gopls\", \"pyright --stdio\")")
.option("--depth ", "max call depth expanded from the entry", int, 6)
+ .option("--html [path]", "also write an interactive call-graph diagram — nodes & edges, force-directed (to a file if a path is given, else a temp file)")
.option("--json [path]", "envelope as JSON: to a file if a path is given, else to stdout")
.action((o) => this.#runGraph(o));
@@ -194,6 +211,8 @@ export class Cli {
.description("module-import graph (+ circular-dependency groups) via madge")
.requiredOption("--entry ", "file or directory whose import graph to build")
.option("--root ", "working directory for madge (default: cwd)")
+ .option("--ext ", "comma-separated file extensions to scan (default: ts,tsx,js,jsx,mjs,cjs)")
+ .option("--tsconfig ", "tsconfig for path-alias resolution (default: auto-detected near root/entry)")
.option("--json [path]", "envelope as JSON: to a file if a path is given, else to stdout")
.action((o) => this.#runDeps(o));
diff --git a/src/cli/commands/ComplexityCommand.ts b/src/cli/commands/ComplexityCommand.ts
index 79fe3d9..e4aac2b 100644
--- a/src/cli/commands/ComplexityCommand.ts
+++ b/src/cli/commands/ComplexityCommand.ts
@@ -1,10 +1,7 @@
import { Trace, TraceData } from "../../domain/Trace.js";
import { Diagnostic } from "../../domain/Diagnostic.js";
-import { logger } from "../../shared/logger.js";
-import { runTool } from "../../shared/runTool.js";
-import { TraceCommand } from "./TraceCommand.js";
-
-const log = logger.child({ component: "complexity" });
+import type { ToolRun } from "../../shared/runTool.js";
+import { ShellAnalysisCommand, type AnalysisOutcome, type ToolInvocation } from "./ShellAnalysisCommand.js";
const CCN_WARN = 15; // lizard's default cyclomatic-complexity threshold
@@ -20,35 +17,34 @@ export interface ComplexityReport { functions: FnSymbol[]; stats: { functions: n
/**
* ComplexityCommand — the `static complexity` analysis: per-function cyclomatic complexity via `lizard --csv`.
- * Each function becomes a schema `Symbol` carrying `metrics` (ccn/nloc/params/tokens) under `data.complexity`.
- * Note lizard exits non-zero when functions breach its thresholds, so we parse stdout regardless of exit code
- * and only hard-fail when the process never started (e.g. lizard not installed).
+ * A {@link ShellAnalysisCommand}: the base owns the run/envelope/failure skeleton; this class supplies the
+ * lizard call and the CSV → Symbol normalization. Each function becomes a schema `Symbol` carrying `metrics`
+ * (ccn/nloc/params/tokens) under `data.complexity`. lizard exits non-zero merely to flag threshold breaches, so
+ * {@link nonZeroIsFailure} is false — we parse stdout regardless of exit code and only hard-fail when the
+ * process never started (e.g. lizard not installed).
*/
-export class ComplexityCommand extends TraceCommand {
- async run(req: ComplexityRequest): Promise {
- const startedAtMs = this.started();
- const diagnostics: Diagnostic[] = [];
- let data = new TraceData({});
+export class ComplexityCommand extends ShellAnalysisCommand {
+ protected readonly tool = "lizard";
+ protected readonly command = "complexity.lizard";
+ protected readonly errorCode = "COMPLEXITY_FAILED";
+ protected readonly component = "complexity";
+ protected override nonZeroIsFailure(): boolean { return false; }
- const res = await runTool("lizard", ["--csv", req.path], { cwd: req.root ?? process.cwd() });
- if (res.code === null) {
- // The process never produced an exit code — not installed, or timed out.
- diagnostics.push(Diagnostic.error("COMPLEXITY_FAILED", res.error ?? "lizard did not run"));
- log.error("lizard failed", { path: req.path, err: res.error });
- } else {
- const functions = ComplexityCommand.parseCsv(res.stdout);
- if (!functions.length && !res.ok) {
- diagnostics.push(Diagnostic.error("COMPLEXITY_FAILED", res.error ?? `lizard exited ${res.code} with no parseable output`));
- } else {
- const report = ComplexityCommand.summarize(functions);
- data = new TraceData({ complexity: report });
- if (report.stats.overThreshold) {
- diagnostics.push(Diagnostic.warn("COMPLEXITY_HIGH", `${report.stats.overThreshold} function(s) over CCN ${CCN_WARN} (max ${report.stats.maxCcn})`));
- }
- }
- }
+ protected invocation(req: ComplexityRequest): ToolInvocation {
+ return { argv: ["--csv", req.path], cwd: req.root ?? process.cwd() };
+ }
- return this.envelope({ command: "complexity.lizard", data, diagnostics, args: req.args ?? {}, startedAtMs });
+ protected interpret(res: ToolRun): AnalysisOutcome {
+ const functions = ComplexityCommand.parseCsv(res.stdout);
+ if (!functions.length && !res.ok) {
+ // Non-zero exit with nothing parseable — a real error (bad path / unsupported language), not findings.
+ return { diagnostics: [Diagnostic.error(this.errorCode, res.error ?? `lizard exited ${res.code} with no parseable output`)] };
+ }
+ const report = ComplexityCommand.summarize(functions);
+ const diagnostics = report.stats.overThreshold
+ ? [Diagnostic.warn("COMPLEXITY_HIGH", `${report.stats.overThreshold} function(s) over CCN ${CCN_WARN} (max ${report.stats.maxCcn})`)]
+ : [];
+ return { data: new TraceData({ complexity: report }), diagnostics };
}
/**
@@ -92,11 +88,10 @@ export class ComplexityCommand extends TraceCommand {
/** Human view: functions sorted by CCN (worst first), threshold breaches flagged. */
render(trace: Trace): string {
- const r = trace.data.complexity as ComplexityReport | undefined;
- if (!r || !r.functions?.length) {
- const err = trace.diagnostics.find((d) => d.level === "error");
- return err ? `complexity — failed: ${err.message}` : "complexity — no functions found";
- }
+ const maybe = trace.data.complexity as ComplexityReport | undefined;
+ const guard = this.emptyRender(trace, !!maybe?.functions?.length, "complexity", "no functions found");
+ if (guard !== undefined) return guard;
+ const r = maybe!;
const ccn = (f: FnSymbol) => f.metrics.find((m) => m.name === "ccn")?.value ?? 0;
const sorted = [...r.functions].sort((a, b) => ccn(b) - ccn(a));
const lines = [`complexity — ${r.stats.functions} functions · max CCN ${r.stats.maxCcn} · avg ${r.stats.avgCcn}` + (r.stats.overThreshold ? ` · ${r.stats.overThreshold} over ${CCN_WARN}` : ""), ""];
diff --git a/src/cli/commands/DepsCommand.ts b/src/cli/commands/DepsCommand.ts
index 2a57be4..ec520a7 100644
--- a/src/cli/commands/DepsCommand.ts
+++ b/src/cli/commands/DepsCommand.ts
@@ -1,14 +1,20 @@
+import { existsSync } from "node:fs";
+import { dirname, isAbsolute, join, parse, resolve } from "node:path";
+
import { Trace, TraceData } from "../../domain/Trace.js";
import { Diagnostic } from "../../domain/Diagnostic.js";
-import { logger } from "../../shared/logger.js";
-import { runTool } from "../../shared/runTool.js";
-import { TraceCommand } from "./TraceCommand.js";
+import type { ToolRun } from "../../shared/runTool.js";
+import { ShellAnalysisCommand, type AnalysisOutcome, type ToolInvocation } from "./ShellAnalysisCommand.js";
-const log = logger.child({ component: "deps" });
+// madge defaults to scanning js/jsx only — on a TS/NestJS repo that finds zero modules. Cover the common
+// source extensions by default so `deps` works on TS, TSX and ESM/CJS projects without extra flags.
+const DEFAULT_EXTENSIONS = "ts,tsx,js,jsx,mjs,cjs";
export interface DepsRequest {
entry: string; // a file or directory to analyze
root?: string; // cwd for madge (default: process.cwd())
+ extensions?: string; // comma-separated file extensions madge should scan (default: DEFAULT_EXTENSIONS)
+ tsConfig?: string; // tsconfig for path-alias resolution (default: auto-detected near root/entry)
args?: Record;
}
@@ -17,34 +23,35 @@ interface DepEdge { from: string; to: string; kind: string; }
export interface DepGraph { entry?: string; nodes: DepNode[]; edges: DepEdge[]; stats: { modules: number; edges: number; circular: number }; }
/**
- * DepsCommand — the `static deps` analysis: a module-import graph via `madge --json`. Mirrors GraphCommand
- * (the call graph): own the use-case + the envelope, shell out to the analyzer, and a tool/parse failure
- * becomes an error diagnostic on a still-well-formed Trace. The payload conforms to the schema `Graph` $def
- * (nodes/edges) under `data.deps`, distinct from `data.graph` (which is the *call* graph).
+ * DepsCommand — the `static deps` analysis: a module-import graph via `madge --json`. A {@link
+ * ShellAnalysisCommand}: the base owns the run/envelope/failure skeleton; this class supplies the madge call
+ * ({@link invocation}) and the JSON → Graph normalization ({@link interpret}). The payload conforms to the
+ * schema `Graph` $def (nodes/edges) under `data.deps`, distinct from `data.graph` (which is the *call* graph).
*/
-export class DepsCommand extends TraceCommand {
- async run(req: DepsRequest): Promise {
- const startedAtMs = this.started();
- const diagnostics: Diagnostic[] = [];
- let data = new TraceData({});
+export class DepsCommand extends ShellAnalysisCommand {
+ protected readonly tool = "madge";
+ protected readonly command = "deps.madge";
+ protected readonly errorCode = "DEPS_FAILED";
+ protected readonly component = "deps";
- const res = await runTool("madge", ["--json", req.entry], { cwd: req.root ?? process.cwd() });
- if (!res.ok) {
- diagnostics.push(Diagnostic.error("DEPS_FAILED", res.error ?? `madge exited ${res.code}`));
- log.error("madge failed", { entry: req.entry, err: res.error });
- } else {
- try {
- const deps = DepsCommand.toGraph(JSON.parse(res.stdout) as Record, req.entry);
- data = new TraceData({ deps });
- if (deps.stats.circular) {
- diagnostics.push(Diagnostic.warn("DEPS_CIRCULAR", `${deps.stats.circular} circular dependency group(s) — run with madge --circular for the chains`));
- }
- } catch (e: any) {
- diagnostics.push(Diagnostic.error("DEPS_FAILED", `could not parse madge output: ${String(e?.message ?? e).split("\n")[0]}`));
- }
- }
+ protected invocation(req: DepsRequest): ToolInvocation {
+ const cwd = req.root ?? process.cwd();
+ const extensions = req.extensions ?? DEFAULT_EXTENSIONS;
+ // A tsconfig lets madge resolve path aliases (e.g. `@/foo`); auto-detect one so the common case needs no flag.
+ const tsConfig = req.tsConfig ?? findTsConfig(cwd, req.entry);
+ const argv = ["--json", "--extensions", extensions, ...(tsConfig ? ["--ts-config", tsConfig] : []), req.entry];
+ return { argv, cwd, args: { ...(req.args ?? {}), extensions, ...(tsConfig ? { tsConfig } : {}) } };
+ }
- return this.envelope({ command: "deps.madge", data, diagnostics, args: req.args ?? {}, startedAtMs });
+ protected interpret(res: ToolRun, req: DepsRequest): AnalysisOutcome {
+ let map: Record;
+ try { map = JSON.parse(res.stdout) as Record; }
+ catch (e: any) { throw new Error(`could not parse madge output: ${String(e?.message ?? e).split("\n")[0]}`); }
+ const deps = DepsCommand.toGraph(map, req.entry);
+ const diagnostics = deps.stats.circular
+ ? [Diagnostic.warn("DEPS_CIRCULAR", `${deps.stats.circular} circular dependency group(s) — run with madge --circular for the chains`)]
+ : [];
+ return { data: new TraceData({ deps }), diagnostics };
}
/** Normalize madge's `{ module: [imports] }` JSON into the schema Graph shape + a circular-group count (SCCs > 1). */
@@ -57,11 +64,10 @@ export class DepsCommand extends TraceCommand {
/** Human view: one block per module with its imports, cycles flagged in the header. */
render(trace: Trace): string {
- const g = trace.data.deps as DepGraph | undefined;
- if (!g || !g.nodes?.length) {
- const err = trace.diagnostics.find((d) => d.level === "error");
- return err ? `deps — failed: ${err.message}` : "deps — no modules";
- }
+ const maybe = trace.data.deps as DepGraph | undefined;
+ const guard = this.emptyRender(trace, !!maybe?.nodes?.length, "deps", "no modules");
+ if (guard !== undefined) return guard; // no payload → guard is the rendered line; else the graph is present
+ const g = maybe!;
const adj = new Map();
for (const e of g.edges) (adj.get(e.from) ?? adj.set(e.from, []).get(e.from)!).push(e.to);
const lines = [
@@ -77,6 +83,25 @@ export class DepsCommand extends TraceCommand {
}
}
+/**
+ * Find the nearest `tsconfig.json` for madge's `--ts-config` (path-alias resolution): check the madge cwd
+ * first, then walk up from the entry's directory to the filesystem root. Returns undefined when none exists
+ * (a plain JS project) — madge then runs without alias resolution, which is correct for non-TS code.
+ */
+function findTsConfig(cwd: string, entry: string): string | undefined {
+ const atCwd = join(cwd, "tsconfig.json");
+ if (existsSync(atCwd)) return atCwd;
+ let dir = isAbsolute(entry) ? entry : resolve(cwd, entry);
+ // entry may be a file or a directory; start the walk from its containing directory either way.
+ if (existsSync(dir) && !existsSync(join(dir, "tsconfig.json"))) dir = dirname(dir);
+ const stop = parse(dir).root;
+ for (let d = dir; ; d = dirname(d)) {
+ const candidate = join(d, "tsconfig.json");
+ if (existsSync(candidate)) return candidate;
+ if (d === stop) return undefined;
+ }
+}
+
/** Count strongly-connected components of size > 1 (Tarjan) — each is a circular-import group. */
function countCircularGroups(map: Record): number {
const index = new Map();
diff --git a/src/cli/commands/GraphCommand.ts b/src/cli/commands/GraphCommand.ts
index 84053d9..5299725 100644
--- a/src/cli/commands/GraphCommand.ts
+++ b/src/cli/commands/GraphCommand.ts
@@ -5,8 +5,9 @@ import { Diagnostic } from "../../domain/Diagnostic.js";
import { logger } from "../../shared/logger.js";
import { findProjectRoot } from "../../shared/projectRoot.js";
import { createCodeGraphProvider } from "../../codegraph/createCodeGraphProvider.js";
-import type { CodeGraph, EntryRef, GraphEdge, GraphNode } from "../../codegraph/CodeGraphProvider.js";
+import type { CodeGraph, EntryRef } from "../../codegraph/CodeGraphProvider.js";
import { TraceCommand } from "./TraceCommand.js";
+import { renderGraphHtml, renderGraphTree } from "./graphView.js";
const log = logger.child({ component: "graph" });
const MAX_NODES = 2000; // internal safety cap on graph size; --depth is the user-facing size knob
@@ -71,54 +72,12 @@ export class GraphCommand extends TraceCommand {
/** Human view: the call graph unrolled into a flow tree, with shared callees, cycles and externals marked. */
render(trace: Trace): string {
const graph = trace.data.graph as CodeGraph | undefined;
- if (!graph || !graph.nodes?.length) {
- const err = trace.diagnostics.find((d) => d.level === "error");
- return err ? `graph — failed: ${err.message}` : "graph — no nodes";
- }
-
- const byId = new Map(graph.nodes.map((n) => [n.id, n]));
- const adj = new Map();
- for (const e of graph.edges) (adj.get(e.from) ?? adj.set(e.from, []).get(e.from)!).push(e);
-
- const root = byId.get(graph.entry);
- const head = [
- `graph — ${root?.label ?? graph.entry} (${root?.loc.file}:${root?.loc.line}) via ${graph.provider}`,
- ` ${graph.stats.nodes} nodes · ${graph.stats.edges} edges · depth≤${graph.stats.maxDepth}` +
- (graph.stats.external ? ` · ${graph.stats.external} external` : "") +
- (graph.stats.truncated ? " · truncated" : ""),
- "",
- ];
-
- const lines: string[] = [];
- const onPath = new Set();
- const emitted = new Set();
-
- const label = (n: GraphNode, weight?: number): string => {
- const w = weight && weight > 1 ? ` ×${weight}` : "";
- if (n.scope !== "local") return `${n.label} ⊗ ${n.scope}${w}`;
- return `${n.label} ${n.loc.file}:${n.loc.line}${w}`;
- };
-
- const walk = (id: string, prefix: string, connector: string, weight: number | undefined): void => {
- const n = byId.get(id);
- if (!n) return;
- const cycle = onPath.has(id);
- const kids = adj.get(id) ?? [];
- const shared = emitted.has(id) && kids.length > 0;
- const tag = cycle ? " ↻ cycle" : shared ? " → shared" : "";
- lines.push(`${prefix}${connector}${label(n, weight)}${tag}`);
- if (cycle || shared) return; // back-edge / already-expanded: reference only, don't recurse
- emitted.add(id);
- onPath.add(id);
- const childPrefix = connector ? prefix + (connector.startsWith("└") ? " " : "│ ") : prefix;
- kids.forEach((e, i) => {
- const last = i === kids.length - 1;
- walk(e.to, childPrefix, last ? "└─ " : "├─ ", e.weight);
- });
- onPath.delete(id);
- };
+ const guard = this.emptyRender(trace, !!graph?.nodes?.length, "graph", "no nodes");
+ return guard !== undefined ? guard : renderGraphTree(graph!);
+ }
- walk(graph.entry, "", "", undefined);
- return head.concat(lines).join("\n");
+ /** HTML view: the same call graph as an interactive node-and-edge diagram (see {@link renderGraphHtml}). */
+ renderHtml(trace: Trace): string {
+ return renderGraphHtml(trace);
}
}
diff --git a/src/cli/commands/ShellAnalysisCommand.ts b/src/cli/commands/ShellAnalysisCommand.ts
new file mode 100644
index 0000000..abc30bc
--- /dev/null
+++ b/src/cli/commands/ShellAnalysisCommand.ts
@@ -0,0 +1,79 @@
+import { Trace, TraceData } from "../../domain/Trace.js";
+import { Diagnostic } from "../../domain/Diagnostic.js";
+import { logger } from "../../shared/logger.js";
+import { runTool, type ToolRun } from "../../shared/runTool.js";
+import { TraceCommand } from "./TraceCommand.js";
+
+/** The tool call one analysis run makes: argv + working dir, plus any extra `meta.args` to record beyond req.args. */
+export interface ToolInvocation {
+ argv: string[];
+ cwd: string;
+ args?: Record;
+}
+
+/** What `interpret` yields from a non-fatal run: a data payload and/or diagnostics (warnings or a soft failure). */
+export interface AnalysisOutcome {
+ data?: TraceData;
+ diagnostics?: Diagnostic[];
+}
+
+/**
+ * ShellAnalysisCommand — the Template-Method base shared by every "shell out to an analyzer, normalize its
+ * stdout into a Trace" command (deps · complexity · symbols; the call graph has its own provider seam). The
+ * base owns the run skeleton these three repeated verbatim — start stamp, spawn the tool, decide
+ * fatal-vs-findings, turn any throw into a single error diagnostic, and stamp the envelope — so a subclass
+ * declares its identity (tool/command/errorCode/component) and supplies only the two parts that differ:
+ * the tool call ({@link invocation}) and how to read its output ({@link interpret}). A tool that is missing,
+ * times out, or emits unparseable output becomes a `` error on a still-well-formed Trace, honouring
+ * the same "an agent always gets a Trace" contract the dynamic/graph commands keep.
+ */
+export abstract class ShellAnalysisCommand }> extends TraceCommand {
+ /** Binary to spawn, e.g. `"madge"`. */ protected abstract readonly tool: string;
+ /** Envelope command id, e.g. `"deps.madge"`. */ protected abstract readonly command: string;
+ /** Error-diagnostic code raised on failure. */ protected abstract readonly errorCode: string;
+ /** Logger component label, e.g. `"deps"`. */ protected abstract readonly component: string;
+
+ /** The tool call for this request: argv, cwd, and any meta.args to record. */
+ protected abstract invocation(req: Req): ToolInvocation;
+
+ /** Normalize a non-fatal run's output into a payload (+ optional diagnostics). Throw on unparseable output. */
+ protected abstract interpret(res: ToolRun, req: Req): AnalysisOutcome;
+
+ /**
+ * Whether a non-zero exit is a hard failure. Default `true` (madge succeeds with exit 0). Tools that exit
+ * non-zero merely to signal findings — lizard (threshold breaches), tree-sitter (no grammar / parse errors)
+ * — override to `false`, so only a process that never produced an exit code (`code === null`) is fatal and
+ * everything else flows to {@link interpret}.
+ */
+ protected nonZeroIsFailure(): boolean {
+ return true;
+ }
+
+ async run(req: Req): Promise {
+ const startedAtMs = this.started();
+ const diagnostics: Diagnostic[] = [];
+ let data = new TraceData({});
+ let args = req.args ?? {};
+
+ try {
+ const inv = this.invocation(req);
+ if (inv.args) args = inv.args;
+ const res = await runTool(this.tool, inv.argv, { cwd: inv.cwd });
+ const fatal = this.nonZeroIsFailure() ? !res.ok : res.code === null;
+ if (fatal) {
+ const msg = res.error ?? (this.nonZeroIsFailure() ? `${this.tool} exited ${res.code}` : `${this.tool} did not run`);
+ diagnostics.push(Diagnostic.error(this.errorCode, msg));
+ logger.child({ component: this.component }).error(`${this.tool} failed`, { err: res.error });
+ } else {
+ const out = this.interpret(res, req);
+ if (out.data) data = out.data;
+ if (out.diagnostics?.length) diagnostics.push(...out.diagnostics);
+ }
+ } catch (e: any) {
+ // A throw from invocation/interpret (e.g. unparseable output, unreadable file) → one error diagnostic.
+ diagnostics.push(Diagnostic.error(this.errorCode, String(e?.message ?? e).split("\n")[0]));
+ }
+
+ return this.envelope({ command: this.command, data, diagnostics, args, startedAtMs });
+ }
+}
diff --git a/src/cli/commands/SymbolsCommand.ts b/src/cli/commands/SymbolsCommand.ts
index b852707..2da709d 100644
--- a/src/cli/commands/SymbolsCommand.ts
+++ b/src/cli/commands/SymbolsCommand.ts
@@ -3,11 +3,8 @@ import { isAbsolute, resolve } from "node:path";
import { Trace, TraceData } from "../../domain/Trace.js";
import { Diagnostic } from "../../domain/Diagnostic.js";
-import { logger } from "../../shared/logger.js";
-import { runTool } from "../../shared/runTool.js";
-import { TraceCommand } from "./TraceCommand.js";
-
-const log = logger.child({ component: "symbols" });
+import type { ToolRun } from "../../shared/runTool.js";
+import { ShellAnalysisCommand, type AnalysisOutcome, type ToolInvocation } from "./ShellAnalysisCommand.js";
export interface SymbolsRequest {
file: string; // a single source file
@@ -33,42 +30,39 @@ const NAME_LINE = /name:\s*\((?:identifier|type_identifier|property_identifier|f
/**
* SymbolsCommand — the `static symbols` analysis: top-level definitions in a file via `tree-sitter parse`.
- * tree-sitter emits node *types* + positions but not source text, so we read the file and slice each
- * definition's name-identifier span to recover its name. Each becomes a schema `Symbol` under `data.symbols`.
- * Best-effort + grammar-dependent: when tree-sitter (or the language grammar) is absent, it degrades to a
- * SYMBOLS_FAILED diagnostic on a well-formed Trace.
+ * A {@link ShellAnalysisCommand}: the base owns the run/envelope/failure skeleton; this class supplies the
+ * tree-sitter call and the S-expression → Symbol normalization. tree-sitter emits node *types* + positions but
+ * not source text, so {@link interpret} re-reads the file and slices each definition's name-identifier span to
+ * recover its name. tree-sitter exits non-zero on a missing grammar / parse error rather than a hard crash, so
+ * {@link nonZeroIsFailure} is false and only a process that never ran (`code === null`) is fatal; an unreadable
+ * file or a non-zero exit with no parseable output degrades to a SYMBOLS_FAILED diagnostic on a well-formed Trace.
*/
-export class SymbolsCommand extends TraceCommand {
- async run(req: SymbolsRequest): Promise {
- const startedAtMs = this.started();
- const diagnostics: Diagnostic[] = [];
- let data = new TraceData({});
+export class SymbolsCommand extends ShellAnalysisCommand {
+ protected readonly tool = "tree-sitter";
+ protected readonly command = "symbols.tree-sitter";
+ protected readonly errorCode = "SYMBOLS_FAILED";
+ protected readonly component = "symbols";
+ protected override nonZeroIsFailure(): boolean { return false; }
- const root = req.root ?? process.cwd();
- const abs = isAbsolute(req.file) ? req.file : resolve(root, req.file);
- let source: string;
- try {
- source = readFileSync(abs, "utf8");
- } catch (e: any) {
- diagnostics.push(Diagnostic.error("SYMBOLS_FAILED", `cannot read ${req.file}: ${String(e?.message ?? e).split("\n")[0]}`));
- return this.envelope({ command: "symbols.tree-sitter", data, diagnostics, args: req.args ?? {}, startedAtMs });
- }
+ /** Resolve the request's file against its root (or cwd). */
+ #abs(req: SymbolsRequest): string {
+ return isAbsolute(req.file) ? req.file : resolve(req.root ?? process.cwd(), req.file);
+ }
- const res = await runTool("tree-sitter", ["parse", abs], { cwd: root });
- if (res.code === null) {
- diagnostics.push(Diagnostic.error("SYMBOLS_FAILED", res.error ?? "tree-sitter did not run"));
- log.error("tree-sitter failed", { file: req.file, err: res.error });
- } else {
- const symbols = SymbolsCommand.parseSexp(res.stdout, source, req.file);
- if (!symbols.length && !res.ok) {
- // No symbols and a non-zero exit usually means "no grammar for this file type" or a parse error.
- diagnostics.push(Diagnostic.error("SYMBOLS_FAILED", res.error || res.stderr.split("\n")[0] || `tree-sitter exited ${res.code}`));
- } else {
- data = new TraceData({ symbols: { file: req.file, symbols } as SymbolReport });
- }
- }
+ protected invocation(req: SymbolsRequest): ToolInvocation {
+ return { argv: ["parse", this.#abs(req)], cwd: req.root ?? process.cwd() };
+ }
- return this.envelope({ command: "symbols.tree-sitter", data, diagnostics, args: req.args ?? {}, startedAtMs });
+ protected interpret(res: ToolRun, req: SymbolsRequest): AnalysisOutcome {
+ let source: string;
+ try { source = readFileSync(this.#abs(req), "utf8"); }
+ catch (e: any) { throw new Error(`cannot read ${req.file}: ${String(e?.message ?? e).split("\n")[0]}`); }
+ const symbols = SymbolsCommand.parseSexp(res.stdout, source, req.file);
+ if (!symbols.length && !res.ok) {
+ // No symbols and a non-zero exit usually means "no grammar for this file type" or a parse error.
+ return { diagnostics: [Diagnostic.error(this.errorCode, res.error || res.stderr.split("\n")[0] || `tree-sitter exited ${res.code}`)] };
+ }
+ return { data: new TraceData({ symbols: { file: req.file, symbols } as SymbolReport }) };
}
/**
@@ -101,11 +95,10 @@ export class SymbolsCommand extends TraceCommand {
/** Human view: definitions grouped by kind, in source order. */
render(trace: Trace): string {
- const r = trace.data.symbols as SymbolReport | undefined;
- if (!r || !r.symbols?.length) {
- const err = trace.diagnostics.find((d) => d.level === "error");
- return err ? `symbols — failed: ${err.message}` : "symbols — no definitions found";
- }
+ const maybe = trace.data.symbols as SymbolReport | undefined;
+ const guard = this.emptyRender(trace, !!maybe?.symbols?.length, "symbols", "no definitions found");
+ if (guard !== undefined) return guard;
+ const r = maybe!;
const lines = [`symbols — ${r.symbols.length} definitions in ${r.file}`, ""];
for (const s of r.symbols) lines.push(` ${s.kind.padEnd(10)} ${s.name} :${s.loc.line}`);
return lines.join("\n");
diff --git a/src/cli/commands/TraceCommand.ts b/src/cli/commands/TraceCommand.ts
index 5fa51f7..fcc7eef 100644
--- a/src/cli/commands/TraceCommand.ts
+++ b/src/cli/commands/TraceCommand.ts
@@ -54,6 +54,18 @@ export abstract class TraceCommand extends CliCommand — failed: "` if a diagnostic is an error,
+ * else `"
+${body}${extra?.script ? `\n` : ""}
+ — the graph view passes the SVG styles + force-layout
+ * JS this way, while the empty/error page uses neither.
+ */
+function htmlDoc(title: string, body: string, extra?: { style?: string; script?: string }): string {
+ const t = title.replace(/&/g, "&").replace(//g, ">");
+ return `
+
+