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 `"