diff --git a/README.md b/README.md
index fd798dc..ac2d1ba 100644
--- a/README.md
+++ b/README.md
@@ -7,11 +7,13 @@ 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 `--expression`, 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 `--expression` 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).
```
-trace-cli dynamic ← breakpoints + a trigger → a full trace (Node curl · Chrome scripted journey + video, via CDP)
-trace-cli static ← code structure without running it: graph (LSP call hierarchy) · deps · complexity · symbols
+trace-cli run ← breakpoints + a trigger → a full trace (Node curl · Chrome scripted journey + video, via CDP)
+trace-cli graph|deps|complexity|symbols ← code structure without running it (graph via LSP call hierarchy · deps · complexity · symbols)
trace-cli serve ← collector + realtime UI: show ALL traces live (Langfuse-style)
trace-cli doctor ← which backing tools are installed
trace-cli schema ← the output JSON Schema (the contract)
@@ -23,6 +25,7 @@ trace-cli schema ← the output JSON Schema (the contract)
## Table of Contents
- [Install](#install)
+- [Native-first runtime model](#native-first-runtime-model)
- [Usage](#usage)
- [Realtime UI and Docker](#realtime-ui-and-docker)
- [Static analysis](#static-analysis)
@@ -43,6 +46,14 @@ trace-cli schema ← the output JSON Schema (the contract)
- **As a CLI / library (npm)** — `npm i -g trace-cli` for the global `trace-cli` command, or `npm i trace-cli` to import the classes. Requires **Node ≥ 18**.
- Check backing tools (chrome, ffmpeg, language servers, …) with `trace-cli doctor`.
+## Native-first runtime model
+
+- `trace-cli` is **native-first**. The CLI is meant to run directly on the host where your debug target is reachable.
+- The CLI is fully usable without Docker: `run`, `graph`, `deps`, `complexity`, `symbols`, `doctor`, and `schema` all run natively.
+- `trace-cli serve` is an **optional collector service** for ingest + UI.
+- Docker Compose exists to run supporting services for that collector mode (collector UI/API, Postgres session store, optional S3-compatible object store).
+- Practical rule: run tracing commands natively; use Compose only when you want centralized capture/history/replay UI.
+
## Usage
One engine, one protocol driver — **CDP** for the JS family (Node `--inspect` and Chrome).
@@ -51,23 +62,23 @@ One engine, one protocol driver — **CDP** for the JS family (Node `--inspect`
```bash
# Node (CDP): attach to a --inspect port, fire a curl, trace the request
-trace-cli dynamic --node 9229 \
+trace-cli run --node 9229 \
--curl 'curl -s http://localhost:3000/v1/dashboard' \
- --bp src/dashboard/dashboard.service.ts:149 \
- --expr 'user.id'
+ --breakpoint src/dashboard/dashboard.service.ts:149 \
+ --expression 'user.id'
# Chrome (CDP): attach to a --remote-debugging-port and drive a scripted UI journey, recording a screen + trace-panel video
-trace-cli dynamic --chrome 9222 --bp src/pages/Thing.tsx:42 \
+trace-cli run --chrome 9222 --breakpoint src/pages/Thing.tsx:42 \
--url http://localhost:3000/login --step 'type:#email=me@example.com' --step 'click:text=Sign in'
-# --url alone is the single-navigation shorthand (one goto: step); --out sets the recording path
+# --url alone is the single-navigation shorthand (one goto: step); --output sets the recording path
# …or omit the port — the CLI launches a throwaway headless Chrome itself, traces, records, and tears it down
-trace-cli dynamic --chrome --url http://localhost:5173/route --bp src/pages/Thing.tsx:42
+trace-cli run --chrome --url http://localhost:5173/route --breakpoint src/pages/Thing.tsx:42
```
-- **Flags (both targets):** `--bp ` (repeatable) · `--expr ''` (repeatable, evaluated per hit) · `--json [path]` (to a file, or bare `--json` → stdout).
-- **Trigger:** Node → `--curl`; Chrome → `--url` (one navigation) and/or `--step` — an ordered UI journey (`goto`/`click`/`type`/`waitfor`/`wait`/`newtab`/`eval`, validated against a fixed vocabulary); `--out ` sets the recording path.
-- **Chrome requires ≥1 `--bp`** — debug + video are produced together. `--chrome ` attaches to a browser you launched (a real, logged-in session); bare `--chrome` launches a throwaway headless Chrome.
+- **Flags (both targets):** `--breakpoint ` (repeatable) · `--expression ''` (repeatable, evaluated per hit) · `--json [path]` (to a file, or bare `--json` → stdout).
+- **Trigger:** Node → `--curl`; Chrome → `--url` (one navigation) and/or `--step` — an ordered UI journey (`goto`/`click`/`type`/`waitfor`/`wait`/`newtab`/`eval`, validated against a fixed vocabulary); `--output ` sets the recording path.
+- **Chrome requires ≥1 `--breakpoint`** — debug + video are produced together. `--chrome ` attaches to a browser you launched (a real, logged-in session); bare `--chrome` launches a throwaway headless Chrome.
- **Chrome always records** a debug-replay video (motion screencast + the live trace panel: stack/locals/watch) → uploaded to S3 if `S3_ENDPOINT` is set (`data.recording.url`), else kept as a local path.
- **I/O & exit:** `stdout` = the trace; `stderr` = structured logs (`TRACE_LOG_LEVEL=debug|info|warn|error|silent`, `TRACE_LOG_FORMAT=json|pretty`); exit `0` ok · `1` runtime · `2` usage.
- Inputs **and** the emitted envelope are validated (class-validator) before anything runs or ships. Other knobs (hit cap, stack depth, source root, attach timeout) use sane defaults — kept off the flag surface.
@@ -97,21 +108,22 @@ const restored = Trace.fromPlain(envelope); // rehydrate a stored envelope into
## Realtime UI and Docker
+- This section is optional infrastructure. It is for collecting and visualizing traces, not for running the CLI itself.
- `trace-cli serve` = **collector + realtime web UI** (Langfuse-style): a live session list over SSE + a per-trace timeline (stack, locals, watched expressions, response).
- Point any trace at it with `TRACE_COLLECTOR_URL` → every run POSTs its envelope.
-- Sessions persist in **Postgres** — `DATABASE_URL` (or `POSTGRES_URL`, or `--db `); the schema is auto-created on first use, no migrations.
+- Sessions persist in **Postgres** — `DATABASE_URL` (or `POSTGRES_URL`, or `--database-url `); the schema is auto-created on first use, no migrations.
```bash
# locally (point at any Postgres; the trace_sessions table is created automatically)
export DATABASE_URL=postgres://user:pass@localhost:5432/trace
trace-cli serve --port 4747 # → http://localhost:4747
-TRACE_COLLECTOR_URL=http://localhost:4747 trace-cli dynamic --node 9229 --bp app.js:42 --curl '…'
+TRACE_COLLECTOR_URL=http://localhost:4747 trace-cli run --node 9229 --breakpoint app.js:42 --curl '…'
-# as a Docker service: collector + UI + Postgres (session store) + a mock-aws (S3) for recordings
+# optional Docker services for collector mode: UI/API + Postgres + mock-aws (S3)
docker compose up --build # → http://localhost:4747 (UI), :5432 (Postgres), :9000/:9001 (S3)
-# then, from the host where your debug target is reachable:
+# then run the CLI natively from the host where your debug target is reachable:
export S3_ENDPOINT=http://localhost:9000
-TRACE_COLLECTOR_URL=http://localhost:4747 trace-cli dynamic --chrome 9222 --url http://localhost:3000 --bp src/App.tsx:9
+TRACE_COLLECTOR_URL=http://localhost:4747 trace-cli run --chrome 9222 --url http://localhost:3000 --breakpoint src/App.tsx:9
```
- Each envelope = one `trace_sessions` row (full envelope as JSONB + a precomputed summary).
@@ -129,12 +141,12 @@ npm run dev:ui # Next.js dev → http://localhost:3000 (reads :4
Code structure **without running anything** — the same envelope, no live target.
-- `trace-cli static graph` — call graph / flow tree ("what calls what") for a function or route, via a **language server over LSP** (`prepareCallHierarchy` + `callHierarchy/outgoingCalls`) — the IDE *Show Call Hierarchy* engine, so it's type-accurate (DI-injected services, interface→impl, cross-file imports), not a regex guess.
+- `trace-cli graph` — call graph / flow tree ("what calls what") for a function or route, via a **language server over LSP** (`prepareCallHierarchy` + `callHierarchy/outgoingCalls`) — the IDE *Show Call Hierarchy* engine, so it's type-accurate (DI-injected services, interface→impl, cross-file imports), not a regex guess.
```bash
# the common case — just the entry; root + language server are auto-detected
-trace-cli static graph --entry src/auth/auth.service.ts:42:9
-trace-cli static graph --entry src/auth/auth.service.ts@exchangeToken # …or by symbol
+trace-cli graph --entry src/auth/auth.service.ts:42:9
+trace-cli graph --entry src/auth/auth.service.ts@exchangeToken # …or by symbol
```
- **Only required input:** the entry (`file:line`, `file:line:col`, or `file@symbol`). **Root** auto-found (nearest `tsconfig.json`/`package.json`/`.git`); **LSP server** chosen by file extension (TS/JS bundled). `--depth ` bounds it; `--server ` / `--root ` override.
@@ -156,9 +168,9 @@ trace-cli static graph --entry src/auth/auth.service.ts@exchangeToken # …o
**Sibling analyses** share the same envelope, each shelling out to its analyzer (degrading to a clear error diagnostic when the tool isn't installed — `trace-cli doctor` shows what's present):
```bash
-trace-cli static deps --entry src/index.ts # module-import graph + circular-dependency groups (madge)
-trace-cli static complexity src # per-function cyclomatic complexity (lizard)
-trace-cli static symbols src/app.ts # a file's definition outline (functions/classes/…) (tree-sitter)
+trace-cli deps --entry src/index.ts # module-import graph + circular-dependency groups (madge)
+trace-cli complexity src # per-function cyclomatic complexity (lizard)
+trace-cli symbols src/app.ts # a file's definition outline (functions/classes/…) (tree-sitter)
```
## The trace envelope
@@ -168,7 +180,7 @@ trace-cli static symbols src/app.ts # a file's definition outline (func
```jsonc
{
- "tool": "trace", "version": "0.3.0", "command": "dynamic.node", "ok": true,
+ "tool": "trace", "version": "0.3.0", "command": "run.node", "ok": true,
"meta": { "at": "…", "sessionId": "…", "durationMs": 142 },
"target": { "kind": "node", "source": "cdp", "trigger": "curl …" },
"data": {
@@ -207,20 +219,20 @@ Sample servers under `test/servers/` — a Node order-API and a React checkout U
```bash
# Node (CDP)
PORT=3100 node --inspect=9230 test/servers/node-api/server.js &
-trace-cli dynamic --node 9230 --curl 'curl -s "http://127.0.0.1:3100/checkout?cart=widget:2,gadget:1&coupon=SAVE10®ion=US"' \
- --bp "test/servers/node-api/server.js@subtotal += it.lineTotal" --expr subtotal --expr 'it.sku'
+trace-cli run --node 9230 --curl 'curl -s "http://127.0.0.1:3100/checkout?cart=widget:2,gadget:1&coupon=SAVE10®ion=US"' \
+ --breakpoint "test/servers/node-api/server.js@subtotal += it.lineTotal" --expression subtotal --expression 'it.sku'
# React (Chrome / CDP) — frontend through Vite source maps; bare --chrome → the CLI launches headless Chrome itself
cd test/servers/react-app && npm install && npm run dev & # serves :5180
-trace-cli dynamic --chrome --url http://localhost:5180 \
- --bp "test/servers/react-app/src/price.ts@sum = sum + parseInt" --expr sum
+trace-cli run --chrome --url http://localhost:5180 \
+ --breakpoint "test/servers/react-app/src/price.ts@sum = sum + parseInt" --expression sum
```
- Both emit the same envelope shape; prefix either with `TRACE_COLLECTOR_URL=http://localhost:4747` to watch them land live in the `trace-cli serve` UI.
## Roadmap
-- **Built:** backend pillar (Node · Chrome over CDP, attach *or* auto-launch, scripted journeys + debug-replay video) · static pillar (`trace-cli static`: `graph` via LSP call hierarchy, `deps`/madge, `complexity`/lizard, `symbols`/tree-sitter) · collector/UI · Docker.
+- **Built:** backend pillar (`trace-cli run`: Node · Chrome over CDP, attach *or* auto-launch, scripted journeys + debug-replay video) · static pillar (`graph` via LSP call hierarchy, `deps`/madge, `complexity`/lizard, `symbols`/tree-sitter) · collector/UI · Docker.
- **Next** (same envelope): **DAP languages** (Python, Go, Java, C/C++) for *dynamic* tracing via a second `ProtocolDriver` · `trace-cli exec` (OTel spans) · `trace-cli web` (Playwright) · `trace-cli correlate` (the cross-tier `traceparent` handshake).
- See [`docs/MIGRATION.md`](docs/MIGRATION.md).
diff --git a/docker-compose.yml b/docker-compose.yml
index 5f9423e..c908e00 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,4 +1,5 @@
-# Run the trace collector + realtime UI, Postgres (session store), and a local S3 (mock-aws) for recordings:
+# Optional collector stack only: realtime UI/API collector, Postgres (session store), and local S3 (mock-aws) for recordings.
+# The `trace` CLI itself is native-first and should run on the host where debug targets are reachable.
# docker compose up --build
# open http://localhost:4747 # the trace UI (sessions persisted in Postgres)
# open http://localhost:9001 # mock-aws (MinIO) console — minioadmin / minioadmin
diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md
index e12856d..b395ec7 100644
--- a/docs/MIGRATION.md
+++ b/docs/MIGRATION.md
@@ -40,11 +40,11 @@ shell around it, not rewrite it.
## 2. Target CLI surface
Root command gains subcommands. The old flat `trace --port/--chrome` interface was removed in 0.3.0 — there is
-no back-compat shim; every trace runs through `trace-cli dynamic --node|--chrome`.
+no back-compat shim; every trace runs through `trace-cli run --node|--chrome`.
```
-trace dynamic ... # today's engine: breakpoints + trigger → hits (Node or Chrome)
-trace static ... # deps | complexity | symbols | search (no execution)
+trace run ... # today's engine: breakpoints + trigger → hits (Node or Chrome)
+trace graph|deps|complexity|symbols # call graph · deps · complexity · symbols (no execution)
trace exec -- # run a command under otel-cli, capture spans
trace spans query ... # query an OTel store (otel-desktop-viewer / DuckDB)
trace web -- # run a Playwright script with --trace on, normalize trace.zip
@@ -52,9 +52,9 @@ trace correlate ... # cross-tier frontend↔backend span graph
trace doctor # report which backing tools are installed (+ versions)
trace schema # print the JSON Schema (the contract)
-# REMOVED in 0.3.0 — the flat interface no longer exists. Use `trace-cli dynamic …` instead:
-# trace-cli dynamic --node 9229 --curl '…' --bp file:line …
-# trace-cli dynamic --chrome 9222 --url … --bp file:line … (Chrome auto-records the replay video)
+# REMOVED in 0.3.0 — the flat interface no longer exists. Use `trace-cli run …` instead:
+# trace-cli run --node 9229 --curl '…' --breakpoint file:line …
+# trace-cli run --chrome 9222 --url … --breakpoint file:line … (Chrome auto-records the replay video)
```
`stdout` = the JSON envelope (or the human render). `stderr` = structured logs
@@ -65,12 +65,12 @@ trace schema # print the JSON Schema (the contract)
| Subcommand | Backing tool | Native output | Normalized into (§4) |
|---|---|---|---|
-| `static deps` | `madge` (JS/TS), else `tree-sitter`+`rg` (any lang) | JSON adjacency | `Graph` |
-| `static complexity` | `lizard` | CSV/XML | `Symbol[]` + `Metric[]` |
-| `static symbols` | `tree-sitter` (+ grammar) | AST nodes | `Symbol[]` |
-| `static search` | `ripgrep --json` | JSONL | `Match[]` (`Loc` + text) |
-| `dynamic` (Node) | **our CDP engine** | (already structured) | `Event[]` + `response` |
-| `dynamic` (Chrome) | **our CDP engine** | (already structured) | `Event[]` + `console`/`network` |
+| `deps` | `madge` (JS/TS), else `tree-sitter`+`rg` (any lang) | JSON adjacency | `Graph` |
+| `complexity` | `lizard` | CSV/XML | `Symbol[]` + `Metric[]` |
+| `symbols` | `tree-sitter` (+ grammar) | AST nodes | `Symbol[]` |
+| `search` | `ripgrep --json` | JSONL | `Match[]` (`Loc` + text) |
+| `run` (Node) | **our CDP engine** | (already structured) | `Event[]` + `response` |
+| `run` (Chrome) | **our CDP engine** | (already structured) | `Event[]` + `console`/`network` |
| `exec` | `otel-cli exec` | OTLP spans | `Event[]` + span `Graph` |
| `spans query` | `otel-desktop-viewer` DuckDB | rows | `Event[]` |
| `web` | Playwright `--trace on` | `trace.zip`→`trace.json` | `Event[]` (actions/net/console) |
@@ -183,9 +183,9 @@ recorder/human-render path carries zero migration risk.
| Phase | Deliverable | Risk |
|---|---|---|
| **0 — Contract** | `schema/trace.schema.json` + `envelope.js` + validator + golden fixtures. No behavior change. | low |
-| **1 — `dynamic`** | Move engine → `engine/`; add `trace-cli dynamic` wrapping `traceNode`/`traceChrome`; hard-cut the flat `trace --port/--chrome` interface; update `index.js`, skill, plugin. | low |
+| **1 — `run`** | Move engine → `engine/`; add `trace-cli run` wrapping `traceNode`/`traceChrome`; hard-cut the flat `trace --port/--chrome` interface; update `index.js`, skill, plugin. | low |
| **2 — `doctor` + adapters scaffold** | `trace doctor`; `adapters/` with `detect()` for each tool; normalize stubs. | low |
-| **3 — Static pillar** | `static search`(rg) → `static complexity`(lizard) → `static symbols`(tree-sitter) → `static deps`(madge/ts). Each small & independent. | low–med |
+| **3 — Static pillar** | `search`(rg) → `complexity`(lizard) → `symbols`(tree-sitter) → `deps`(madge/ts). Each small & independent. | low–med |
| **4 — Runtime spans** | `trace exec` via `otel-cli`; optional `spans query`. | med |
| **5 — Frontend web** | `trace web` via Playwright trace.zip parsing. | med (format) |
| **6 — Correlation** | `trace correlate` — the `traceparent` handshake. | **high** |
@@ -198,7 +198,7 @@ Phases 3–6 are independent; ship in any order or drop any pillar without block
## 6. Honest callouts / risks (carried from the design notes + added)
- **No backward compatibility.** 0.3.0 hard-cut the flat `trace --port/--chrome` interface; the plugin + skill
- ship `trace-cli dynamic` only.
+ ship `trace-cli run` only.
- **Language-agnostic deps:** `madge` is JS/TS only; `pydeps`/`go-callvis` are per-language. Recommend
`tree-sitter` + `ripgrep` as the *universal* fallback and treat per-language dep tools as optional adapters.
- **Playwright `trace.json` is not a stable public API.** Pin the Playwright version, parse behind an adapter
@@ -215,14 +215,14 @@ Phases 3–6 are independent; ship in any order or drop any pillar without block
## 7. Open decisions to confirm before Phase 1
1. **Back-compat strategy:** *(resolved)* hard-cut — the flat `trace --port/--chrome` interface was removed in
- 0.3.0; `trace-cli dynamic …` is the only entry point.
+ 0.3.0; `trace-cli run …` is the only entry point.
2. **Backing-tool packaging:** bring-your-own-binary + `doctor` *(recommended, keeps install light &
language-agnostic)*, vs. `optionalDependencies`, vs. hard `dependencies`?
3. **v1 pillar scope:** which pillars are in the first milestone? (e.g. Static + keep CDP now; OTel/Playwright/
correlate later?)
4. **Schema validation:** ship a real JSON Schema + `ajv` runtime validation in tests *(recommended)*, or a
documented shape only (no validator dep)?
-5. **Naming:** `trace dynamic` for the CDP engine — or a different verb (`trace run` / `trace debug`)?
+5. **Naming:** *(resolved)* the CDP engine command is `trace run` — chosen over the earlier `dynamic` working name (and `debug`). The four static analyses are top-level too (`trace graph|deps|complexity|symbols`), no `static` parent.
---
@@ -279,8 +279,8 @@ stream to stdout/`--json`; these slot in behind the same schema when scale deman
- ✅ **Protocol-pluggable engine:** CDP driver (`cdp.js`, Node/Chrome) **over `chrome-remote-interface`** +
DAP driver (`dap.js`) **over the official `DebugClient`** (Python/debugpy; any DAP adapter). We own
discovery + RemoteObject/variable rendering; the libraries own the wire. One trigger+capture loop (`trace.js`).
-- ✅ **CLI hard-cut:** `trace dynamic --node|--chrome|--python`, `trace doctor`, `trace schema`. Old flat
- `trace --port` interface removed (→ `trace dynamic --node`).
+- ✅ **CLI hard-cut:** `trace run --node|--chrome|--python`, `trace doctor`, `trace schema`. Old flat
+ `trace --port` interface removed (→ `trace run --node`).
- ✅ **Test servers:** `test/servers/{node-api,python-api}` with identical business logic — the SAME trace
(stack, locals, watched exprs) verified across Node (CDP) and Python (DAP), same envelope shape.
- ✅ All 15 tests green (`npm test`).
@@ -292,7 +292,7 @@ stream to stdout/`--json`; these slot in behind the same schema when scale deman
**Remaining (the rest of "the full thing"):**
- ⏳ More DAP languages via the same driver: Go (`dlv dap`), Java (`java-debug`), C/C++/Rust (`lldb-dap`).
-- ⏳ Static pillar: `trace static search|complexity|symbols|deps` (ripgrep present; lizard/tree-sitter/madge).
+- ⏳ Static pillar: `trace search|complexity|symbols|deps` (ripgrep present; lizard/tree-sitter/madge).
- ⏳ `trace exec` (otel-cli spans; needs Go), `trace web` (Playwright), `trace correlate` (cross-tier).
- ⏳ Release polish: README, skill, `.claude-plugin` manifests.
```
diff --git a/skills/trace/SKILL.md b/skills/trace/SKILL.md
index 6156c27..6996074 100644
--- a/skills/trace/SKILL.md
+++ b/skills/trace/SKILL.md
@@ -1,6 +1,6 @@
---
name: trace
-description: Gets a full execution trace through a running app via the `trace-cli` CLI — sets breakpoints at file:line, fires a trigger (a curl for a Node `--inspect` backend, or a scripted UI journey of ordered steps for a Chrome `--remote-debugging-port` target, attached or auto-launched headless), and reads back every hit with its call stack, locals, watched expressions and timing as one JSON envelope; a Chrome run also records a screen + trace-panel video. Node/Chrome over CDP. Also `trace-cli static` runs static analysis with no running app — call graph (LSP call hierarchy), module deps, complexity, symbols. Use for: trace this request/route, what runs when I hit /endpoint, record this UI flow, show the call graph / what calls what, module or circular dependencies, cyclomatic complexity, symbol outline of a file, step through a function, why is this value X here, set a breakpoint and show the trace. Vendor-neutral: pass the port, trigger and breakpoints — nothing is hardcoded.
+description: Gets a full execution trace through a running app via the `trace-cli` CLI — sets breakpoints at file:line, fires a trigger (a curl for a Node `--inspect` backend, or a scripted UI journey of ordered steps for a Chrome `--remote-debugging-port` target, attached or auto-launched headless), and reads back every hit with its call stack, locals, watched expressions and timing as one JSON envelope; a Chrome run also records a screen + trace-panel video. Node/Chrome over CDP. Also `trace-cli graph`/`deps`/`complexity`/`symbols` run static analysis with no running app — call graph (LSP call hierarchy), module deps, complexity, symbols. Use for: trace this request/route, what runs when I hit /endpoint, record this UI flow, show the call graph / what calls what, module or circular dependencies, cyclomatic complexity, symbol outline of a file, step through a function, why is this value X here, set a breakpoint and show the trace. Vendor-neutral: pass the port, trigger and breakpoints — nothing is hardcoded.
allowed-tools: Bash(node:*), Bash(trace-cli:*), Read
---
@@ -8,7 +8,7 @@ allowed-tools: Bash(node:*), Bash(trace-cli:*), Read
- Attaches to a running debug target, sets breakpoints, fires a trigger, prints the full execution trace in one shot. One engine, one protocol driver — **CDP** for Node and Chrome. You read the trace; you never drive the debugger by hand.
- **Chrome can auto-launch:** `--chrome ` attaches to a browser you started (a real, logged-in session); bare `--chrome` (no port) launches a throwaway headless Chrome, traces, records, and tears it down — a frontend trace needs only the app running.
-- **`trace-cli static` needs no running app:** `graph` is a call graph (flow tree) via **LSP call hierarchy** — map what a route/function calls, and find breakpoint coordinates before a dynamic trace. TS/JS bundled; other languages via `--server` (`gopls` · `pyright --stdio` · `rust-analyzer` · `clangd`, must expose `callHierarchyProvider`). The group also has `deps`/`complexity`/`symbols` — run `trace-cli static --help`.
+- **Static analysis needs no running app:** `trace-cli graph` is a call graph (flow tree) via **LSP call hierarchy** — map what a route/function calls, and find breakpoint coordinates before a runtime trace. TS/JS bundled; other languages via `--server` (`gopls` · `pyright --stdio` · `rust-analyzer` · `clangd`, must expose `callHierarchyProvider`). The other analyses are `deps`/`complexity`/`symbols` — run `trace-cli --help`.
## Invoking (do this first)
@@ -28,7 +28,7 @@ trace-cli() { node "${CLAUDE_PLUGIN_ROOT}/bin/trace" "$@"; }
```bash
trace-cli manifest # structured JSON: every command, flag (defaults/choices/env vars) & argument — the input contract
trace-cli --help # the list of subcommands
-trace-cli --help # how to run one command, e.g. `trace-cli dynamic --help`
+trace-cli --help # how to run one command, e.g. `trace-cli run --help`
trace-cli schema # the output JSON Schema every trace conforms to — the output contract
trace-cli doctor # which backing tools are installed (node, chrome, ffmpeg, …)
```
diff --git a/src/analysis/LineageAnalyzer.ts b/src/analysis/LineageAnalyzer.ts
index b66404f..d589b70 100644
--- a/src/analysis/LineageAnalyzer.ts
+++ b/src/analysis/LineageAnalyzer.ts
@@ -1,7 +1,7 @@
import { Lineage, LineagePoint, type LineageKind } from "../domain/Lineage.js";
import type { TraceEvent } from "../domain/TraceEvent.js";
-const norm = (v: unknown): string => { try { return JSON.stringify(v); } catch { return String(v); } };
+const norm = (value: unknown): string => { try { return JSON.stringify(value); } catch { return String(value); } };
/**
* LineageAnalyzer — the normalization tier. Derives mutation lineage (value-over-time) from the event
@@ -12,38 +12,38 @@ export class LineageAnalyzer {
static compute(events: TraceEvent[] = []): Lineage[] {
const tracks = new Map();
- for (const e of events) {
- const a = (e.attrs ?? {}) as { exprs?: Record; locals?: Record };
+ for (const event of events) {
+ const attributes = (event.attributes ?? {}) as { exprs?: Record; locals?: Record };
const seen = new Set();
const record = (name: string, value: unknown, kind: LineageKind) => {
if (seen.has(name)) return;
seen.add(name);
- let tr = tracks.get(name);
- if (!tr) { tr = { name, kind, series: [] }; tracks.set(name, tr); }
- const prev = tr.series.length ? tr.series[tr.series.length - 1].value : undefined;
- const changed = tr.series.length > 0 && norm(value) !== norm(prev);
- tr.series.push(new LineagePoint({ seq: e.seq, t: e.t, loc: e.loc, value, changed }));
+ let track = tracks.get(name);
+ if (!track) { track = { name, kind, series: [] }; tracks.set(name, track); }
+ const previous = track.series.length ? track.series[track.series.length - 1].value : undefined;
+ const changed = track.series.length > 0 && norm(value) !== norm(previous);
+ track.series.push(new LineagePoint({ sequence: event.sequence, time: event.time, location: event.location, value, changed }));
};
- for (const [k, v] of Object.entries(a.exprs ?? {})) record(k, v, "expr");
- for (const [k, v] of Object.entries(a.locals ?? {})) record(k, v, "local");
+ for (const [name, value] of Object.entries(attributes.exprs ?? {})) record(name, value, "expr");
+ for (const [name, value] of Object.entries(attributes.locals ?? {})) record(name, value, "local");
}
- const out: Lineage[] = [];
- for (const tr of tracks.values()) {
- const changes = tr.series.filter((s) => s.changed).length;
- if (tr.series.length > 1 && changes > 0) {
- out.push(new Lineage({ name: tr.name, kind: tr.kind, occurrences: tr.series.length, changes, series: tr.series }));
+ const lineages: Lineage[] = [];
+ for (const track of tracks.values()) {
+ const changes = track.series.filter((point) => point.changed).length;
+ if (track.series.length > 1 && changes > 0) {
+ lineages.push(new Lineage({ name: track.name, kind: track.kind, occurrences: track.series.length, changes, series: track.series }));
}
}
- out.sort((x, y) => (x.kind === y.kind ? y.changes - x.changes : x.kind === "expr" ? -1 : 1));
- return out;
+ lineages.sort((first, second) => (first.kind === second.kind ? second.changes - first.changes : first.kind === "expr" ? -1 : 1));
+ return lineages;
}
/** "0 → 9.99 → 14.49" — compact value path (transitions only). */
static summary(track: Lineage, max = 8): string {
- const vals: unknown[] = [];
- for (const s of track.series) if (s.changed || vals.length === 0) vals.push(s.value);
- const shown = vals.slice(0, max).map((v) => (typeof v === "string" ? v : norm(v)));
- return shown.join(" → ") + (vals.length > max ? " → …" : "");
+ const transitions: unknown[] = [];
+ for (const point of track.series) if (point.changed || transitions.length === 0) transitions.push(point.value);
+ const shown = transitions.slice(0, max).map((value) => (typeof value === "string" ? value : norm(value)));
+ return shown.join(" → ") + (transitions.length > max ? " → …" : "");
}
}
diff --git a/src/cli/Cli.ts b/src/cli/Cli.ts
index 8c9c3e8..d4193fd 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";
@@ -19,34 +22,62 @@ import { TargetKind } from "../domain/Target.js";
import { Diagnostic } from "../domain/Diagnostic.js";
import { DEFAULT_NODE_PORT, DEFAULT_COLLECTOR_PORT } from "../shared/defaults.js";
import { DynamicInput, GraphInput, validateSteps } from "./CommandInputs.js";
-import { EntryRef } from "../codegraph/CodeGraphProvider.js";
+import { EntryReference } from "../codegraph/CodeGraphProvider.js";
import { logger } from "../shared/logger.js";
+import { Code } from "../shared/codes.js";
import type { Trace } from "../domain/Trace.js";
const log = logger.child({ component: "cli" });
-const int = (v: string) => parseInt(v, 10);
-const collect = (v: string, acc: string[]) => { acc.push(v); return acc; };
-const usage = (msg: string): never => { process.stderr.write(`trace-cli: ${msg}\n`); process.exit(2); };
-
-function pickTarget(o: any): { target: DynamicTargetKind; port: number; launch: boolean } {
- if (o.chrome != null) {
- const launch = o.chrome === true; // bare `--chrome` (no port) → launch a throwaway headless Chrome
- return { target: TargetKind.Chrome, port: launch ? 0 : int(o.chrome), launch };
+const parseIntArg = (value: string) => parseInt(value, 10);
+const collect = (value: string, accumulator: string[]) => { accumulator.push(value); return accumulator; };
+const usage = (message: string): never => { process.stderr.write(`trace-cli: ${message}\n`); process.exit(2); };
+
+function pickTarget(options: any): { target: DynamicTargetKind; port: number; launch: boolean } {
+ if (options.chrome != null) {
+ const launch = options.chrome === true; // bare `--chrome` (no port) → launch a throwaway headless Chrome
+ return { target: TargetKind.Chrome, port: launch ? 0 : parseIntArg(options.chrome), launch };
}
- return { target: TargetKind.Node, port: o.node === undefined || o.node === true ? DEFAULT_NODE_PORT : int(o.node), launch: false };
+ return { target: TargetKind.Node, port: options.node === undefined || options.node === true ? DEFAULT_NODE_PORT : parseIntArg(options.node), launch: false };
+}
+
+/**
+ * condense — trim the JSON envelope to high-signal fields for token-tight agent consumption (the `--concise`
+ * flag). Per breakpoint hit, the locals object (the firehose) collapses to its key names and the call stack
+ * caps at the top frames, each with a count so nothing looks complete-but-truncated; watched `--expression` values
+ * and the location/label/timing are kept verbatim. Mutates only the plain `json` (not the rich Trace the human
+ * renderer reads), and no-ops on envelopes without breakpoint events (the static analyses). Re-run `--detailed`
+ * for everything. The trimmed envelope still satisfies the schema (`attributes` is an open object).
+ */
+const CONCISE_STACK_FRAMES = 2;
+export function condense(json: Record): Record {
+ const events = (json.data as any)?.events;
+ if (!Array.isArray(events)) return json;
+ for (const event of events) {
+ const attributes = event?.attributes;
+ if (!attributes || typeof attributes !== "object") continue;
+ if (attributes.locals && typeof attributes.locals === "object") {
+ attributes.localsKeys = Object.keys(attributes.locals); // values dropped; names kept so the agent knows what to re-fetch
+ delete attributes.locals;
+ }
+ if (Array.isArray(attributes.stack) && attributes.stack.length > CONCISE_STACK_FRAMES) {
+ attributes.stackDepth = attributes.stack.length;
+ attributes.stack = attributes.stack.slice(0, CONCISE_STACK_FRAMES);
+ }
+ }
+ return json;
}
/** emit policy: bare --json → JSON to stdout; --json → file (stdout stays human); else human. */
-function emit(trace: Trace, humanFn: () => string, o: any): void {
+function emit(trace: Trace, renderHuman: () => string, options: any): void {
// Enforce the envelope contract before it leaves the process: structural violations become error
// diagnostics (and flip `ok`/exit code) instead of shipping a silently-malformed Trace.
- for (const problem of trace.validate()) trace.diagnostics.push(Diagnostic.error("E_SCHEMA", problem));
+ for (const problem of trace.validate()) trace.diagnostics.push(Diagnostic.error(Code.SCHEMA, problem));
trace.ok = !trace.hasErrors();
- const json = trace.toJSON();
- const toFile = typeof o.json === "string";
- if (toFile) writeFileSync(o.json, JSON.stringify(json, null, 2));
- process.stdout.write((o.json === true ? JSON.stringify(json, null, 2) : humanFn()) + "\n");
- if (toFile) log.info("envelope written", { path: o.json });
+ const json = options.concise ? condense(trace.toJSON()) : trace.toJSON();
+ const writeToFile = typeof options.json === "string";
+ if (writeToFile) writeFileSync(options.json, JSON.stringify(json, null, 2));
+ process.stdout.write((options.json === true ? JSON.stringify(json, null, 2) : renderHuman()) + "\n");
+ if (writeToFile) log.info("envelope written", { path: options.json });
}
/**
@@ -56,19 +87,20 @@ function emit(trace: Trace, humanFn: () => string, o: any): void {
export class Cli {
#dynamic = new DynamicCommand(new Tracer(), new S3ArtifactStore());
- async #runDynamic(o: any): Promise {
- if (o.chrome != null && o.node != null) usage("pick one target: --node or --chrome, not both");
- const { target, port, launch } = pickTarget(o);
+ async #runDynamic(options: any): Promise {
+ if (options.chrome != null && options.node != null) usage("pick one target: --node or --chrome, not both");
+ if (options.concise && options.detailed) usage("pick one envelope verbosity: --concise or --detailed, not both");
+ const { target, port, launch } = pickTarget(options);
const isChrome = target === TargetKind.Chrome;
- if (!o.bp.length) usage("dynamic needs at least one --bp (file:line or file@substring)");
+ if (!options.breakpoint.length) usage("run needs at least one --breakpoint (file:line or file@substring)");
// Chrome trigger = an ordered UI journey; --url is shorthand for a leading `goto:`. Node trigger = a curl.
- const steps: string[] = isChrome ? [...(o.url ? [`goto:${o.url}`] : []), ...o.step] : [];
+ const steps: string[] = isChrome ? [...(options.url ? [`goto:${options.url}`] : []), ...options.step] : [];
if (isChrome && !steps.length) usage("chrome target needs --url or at least one --step");
- if (isChrome && o.curl) usage("--curl is a node-only trigger (chrome uses --url/--step)");
- if (!isChrome && o.step.length) usage("--step is a chrome-only trigger (node uses --curl)");
- if (!isChrome && !o.curl) usage(`${target} target needs --curl`);
+ if (isChrome && options.curl) usage("--curl is a node-only trigger (chrome uses --url/--step)");
+ if (!isChrome && options.step.length) usage("--step is a chrome-only trigger (node uses --curl)");
+ if (!isChrome && !options.curl) usage(`${target} target needs --curl`);
- const input = new DynamicInput({ target, port, launch, breakpoints: o.bp, exprs: o.expr, steps, curl: o.curl });
+ const input = new DynamicInput({ target, port, launch, breakpoints: options.breakpoint, exprs: options.expression, steps, curl: options.curl });
const badInput = input.validate();
if (badInput.length) usage(`invalid input — ${badInput.join("; ")}`);
@@ -79,78 +111,101 @@ export class Cli {
// Redact secrets before they reach the envelope's meta.args: a `type:` step carries typed text (passwords),
// an `eval:` step an arbitrary script body.
- const safeStep = (s: string) => s.startsWith("type:") ? s.replace(/=.*/s, "=***") : s.startsWith("eval:") ? "eval:***" : s;
+ const redactStep = (step: string) => step.startsWith("type:") ? step.replace(/=.*/s, "=***") : step.startsWith("eval:") ? "eval:***" : step;
// --emit (or TRACE_COLLECTOR_URL) → stream to the collector. Emits are serialized through one promise
// chain so a slow POST can't land a stale (smaller) envelope after a newer one; each ingest upserts the
// session row (keyed on sessionId) and re-broadcasts over SSE, so the dashboard updates live as it runs.
- const collector = o.emit ?? process.env.TRACE_COLLECTOR_URL;
- let chain: Promise = Promise.resolve();
- const pump = collector ? (env: unknown) => { chain = chain.then(() => Collector.emit(collector, env).catch(() => false)); } : undefined;
+ const collector = options.emit ?? process.env.TRACE_COLLECTOR_URL;
+ let emitChain: Promise = Promise.resolve();
+ const emitToCollector = collector ? (envelope: unknown) => { emitChain = emitChain.then(() => Collector.emit(collector, envelope).catch(() => false)); } : undefined;
const { trace } = await this.#dynamic.run({
target, port, launch,
- breakpoints: o.bp, exprs: o.expr,
- steps, curl: o.curl,
- root: o.root, maxHits: o.maxHits,
- recordOut: o.out,
- args: { target, ...(launch ? { launch: true } : { port }), bp: o.bp, ...(o.root ? { root: o.root } : {}), ...(o.maxHits ? { maxHits: o.maxHits } : {}), ...(steps.length ? { steps: steps.map(safeStep) } : {}), ...(o.curl ? { curl: o.curl } : {}) },
- ...(pump ? { onProgress: (t: Trace) => pump(t.toJSON()) } : {}),
+ breakpoints: options.breakpoint, exprs: options.expression,
+ steps, curl: options.curl,
+ root: options.root, maxHits: options.maxHits,
+ recordOut: options.output,
+ args: { target, ...(launch ? { launch: true } : { port }), breakpoints: options.breakpoint, ...(options.root ? { root: options.root } : {}), ...(options.maxHits ? { maxHits: options.maxHits } : {}), ...(steps.length ? { steps: steps.map(redactStep) } : {}), ...(options.curl ? { curl: options.curl } : {}) },
+ ...(emitToCollector ? { onProgress: (intermediateTrace: Trace) => emitToCollector(intermediateTrace.toJSON()) } : {}),
});
- emit(trace, () => this.#dynamic.render(trace), o);
- if (pump) { pump(trace.toJSON()); await chain; } // final, complete envelope; then flush all pending emits
+ emit(trace, () => this.#dynamic.render(trace), options);
+ if (emitToCollector) { emitToCollector(trace.toJSON()); await emitChain; } // final, complete envelope; then flush all pending emits
process.exit(trace.hasErrors() ? 1 : 0);
}
- async #runGraph(o: any): Promise {
- const entry = EntryRef.parse(o.entry);
- const input = new GraphInput({ file: entry.file, line: entry.line, col: entry.col, symbol: entry.symbol, depth: o.depth });
+ async #runGraph(options: any): Promise {
+ const entry = EntryReference.parse(options.entry);
+ const input = new GraphInput({ file: entry.file, line: entry.line, column: entry.column, symbol: entry.symbol, depth: options.depth });
const badInput = input.validate();
if (badInput.length) usage(`invalid input — ${badInput.join("; ")}`);
- const cmd = new GraphCommand();
- const trace = await cmd.run({
+ const command = new GraphCommand();
+ const trace = await command.run({
entry,
- root: o.root, // optional — GraphCommand auto-detects the project root from the entry when absent
- maxDepth: o.depth,
- server: o.server,
- args: { entry: o.entry, ...(o.root ? { root: o.root } : {}), ...(o.server ? { server: o.server } : {}), depth: o.depth },
+ root: options.root, // optional — GraphCommand auto-detects the project root from the entry when absent
+ maxDepth: options.depth,
+ server: options.server,
+ args: { entry: options.entry, ...(options.root ? { root: options.root } : {}), ...(options.server ? { server: options.server } : {}), depth: options.depth },
});
- emit(trace, () => cmd.render(trace), o);
+ emit(trace, () => command.render(trace), options);
+ // --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 (options.html != null) {
+ const htmlPath = typeof options.html === "string" ? options.html : join(tmpdir(), `trace-graph-${randomUUID()}.html`);
+ writeFileSync(htmlPath, command.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);
}
/** Shared tail for the static analyses: emit the envelope, forward to a collector, exit on the error state. */
- async #finish(trace: Trace, render: () => string, o: any): Promise {
- emit(trace, render, o);
+ async #finish(trace: Trace, render: () => string, options: any): Promise {
+ emit(trace, render, options);
const collector = process.env.TRACE_COLLECTOR_URL;
if (collector) await Collector.emit(collector, trace.toJSON());
process.exit(trace.hasErrors() ? 1 : 0);
}
- 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 } : {}) } });
- await this.#finish(trace, () => cmd.render(trace), o);
+ async #runDeps(options: any): Promise {
+ if (!options.entry) usage("deps needs --entry ");
+ const command = new DepsCommand();
+ const trace = await command.run({
+ entry: options.entry,
+ root: options.root,
+ extensions: options.extensions,
+ tsConfig: options.tsconfig,
+ exclude: options.exclude,
+ args: { entry: options.entry, ...(options.root ? { root: options.root } : {}) },
+ });
+ emit(trace, () => command.render(trace), options);
+ // --html [path] → also write the whole module graph as the interactive diagram (same renderer as `graph`).
+ if (options.html != null) {
+ const htmlPath = typeof options.html === "string" ? options.html : join(tmpdir(), `trace-deps-${randomUUID()}.html`);
+ writeFileSync(htmlPath, command.renderHtml(trace));
+ log.info("deps 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);
}
- async #runComplexity(path: string, o: any): Promise {
- const p = path || ".";
- const cmd = new ComplexityCommand();
- const trace = await cmd.run({ path: p, root: o.root, args: { path: p, ...(o.root ? { root: o.root } : {}) } });
- await this.#finish(trace, () => cmd.render(trace), o);
+ async #runComplexity(path: string, options: any): Promise {
+ const resolvedPath = path || ".";
+ const command = new ComplexityCommand();
+ const trace = await command.run({ path: resolvedPath, root: options.root, args: { path: resolvedPath, ...(options.root ? { root: options.root } : {}) } });
+ await this.#finish(trace, () => command.render(trace), options);
}
- async #runSymbols(file: string, o: any): Promise {
- if (!file) usage("static symbols needs a ");
- const cmd = new SymbolsCommand();
- const trace = await cmd.run({ file, root: o.root, args: { file, ...(o.root ? { root: o.root } : {}) } });
- await this.#finish(trace, () => cmd.render(trace), o);
+ async #runSymbols(file: string, options: any): Promise {
+ if (!file) usage("symbols needs a ");
+ const command = new SymbolsCommand();
+ const trace = await command.run({ file, root: options.root, args: { file, ...(options.root ? { root: options.root } : {}) } });
+ await this.#finish(trace, () => command.render(trace), options);
}
build(): Command {
@@ -160,73 +215,77 @@ export class Cli {
.version(VERSION)
.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.")
+ program.command("run")
+ .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("--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("--breakpoint ", "breakpoint, repeatable: file:line or file@substring (non-pausing; in-scope locals are captured automatically)", collect, [])
+ .option("--expression ", "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 --breakpoint 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: 100; non-pausing logpoints, so a hot path is cheap to raise)", parseIntArg)
.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, [])
- .option("--out ", "chrome: output path for the screen + trace-panel recording (default: a temp file)")
+ .option("--output ", "chrome: output path for the screen + trace-panel recording (default: a temp file)")
.option("--emit ", "stream the trace to a collector (POST /v1/traces) — the session appears live as it runs (default: env TRACE_COLLECTOR_URL)")
.option("--json [path]", "envelope as JSON: to a file if a path is given, else to stdout")
- .action((o) => this.#runDynamic(o));
-
- // static analysis — code structure without running the app. Each subcommand shells out to one analyzer
- // and emits the same Trace envelope as the runtime `dynamic` command (call graph · deps · complexity · symbols).
- const stat = program.command("static")
- .description("static analysis — code structure without running the app (call graph · deps · complexity · symbols)");
+ .option("--concise", "trim the --json envelope for token-tight agent reads: per hit, locals collapse to key names and the call stack keeps its top 2 frames (watched --expression values, location & timing kept). Re-run --detailed for everything.")
+ .option("--detailed", "full --json envelope: every local's value and the complete call stack at each hit (the default)")
+ .action((options) => this.#runDynamic(options));
- stat.command("graph")
+ // static analysis — code structure without running the app. Each command shells out to one analyzer and
+ // emits the same Trace envelope as the runtime `run` command (call graph · deps · complexity · symbols).
+ program.command("graph")
.description("call graph rooted at an entry → the flow tree for a function/route, via LSP call hierarchy")
- .requiredOption("--entry [", "where to start: file:line, file:line:col, or file@symbol (e.g. src/auth.service.ts:42:9 or src/auth.service.ts@exchangeToken)")
+ .requiredOption("--entry ][", "where to start: file:line, file:line:column, or file@symbol (e.g. src/auth.service.ts:42:9 or src/auth.service.ts@exchangeToken)")
.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("--depth ", "max call depth expanded from the entry", parseIntArg, 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));
+ .action((options) => this.#runGraph(options));
- stat.command("deps")
+ program.command("deps")
.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("--extensions ", "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("--exclude ", "drop module paths matching this regexp (madge --exclude), e.g. \"(^|/)dist/\" to skip build output")
+ .option("--html [path]", "also write the whole module graph as an interactive node-and-edge diagram (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.#runDeps(o));
+ .action((options) => this.#runDeps(options));
- stat.command("complexity")
+ program.command("complexity")
.description("per-function cyclomatic complexity via lizard")
.argument("[path]", "file or directory to analyze (default: current directory)", ".")
.option("--root ", "working directory for lizard (default: cwd)")
.option("--json [path]", "envelope as JSON: to a file if a path is given, else to stdout")
- .action((path, o) => this.#runComplexity(path, o));
+ .action((path, options) => this.#runComplexity(path, options));
- stat.command("symbols")
+ program.command("symbols")
.description("top-level definitions (functions/classes/types) in a file via tree-sitter")
.argument("", "source file to outline")
.option("--root ", "working directory / project root (default: cwd)")
.option("--json [path]", "envelope as JSON: to a file if a path is given, else to stdout")
- .action((file, o) => this.#runSymbols(file, o));
+ .action((file, options) => this.#runSymbols(file, options));
program.command("doctor")
.description("report which backing tools are installed (+ versions), grouped by pillar")
.option("--json [path]", "envelope as JSON: to a file if a path is given, else to stdout")
- .action(async (o) => {
- const cmd = new DoctorCommand();
- const trace = await cmd.run();
- emit(trace, () => cmd.render(trace), o);
+ .action(async (options) => {
+ const command = new DoctorCommand();
+ const trace = await command.run();
+ emit(trace, () => command.render(trace), options);
process.exit(0);
});
program.command("serve")
.description("collector + realtime UI: ingest envelopes (POST /v1/traces) and show all traces live")
- .option("--port ", "port to listen on", int, DEFAULT_COLLECTOR_PORT)
+ .option("--port ", "port to listen on", parseIntArg, DEFAULT_COLLECTOR_PORT)
.option("--host ", "host to bind (default 0.0.0.0)")
- .option("--db ", "Postgres connection string to persist sessions (env DATABASE_URL/POSTGRES_URL)")
- .action((o) => new ServeCommand().run({ port: o.port, host: o.host, databaseUrl: o.db }));
+ .option("--database-url ", "Postgres connection string to persist sessions (env DATABASE_URL/POSTGRES_URL)")
+ .action((options) => new ServeCommand().run({ port: options.port, host: options.host, databaseUrl: options.databaseUrl }));
program.command("schema")
.description("print the output JSON Schema (the contract every Trace conforms to)")
@@ -243,14 +302,14 @@ export class Cli {
.description("copy the bundled `trace` skill into a project's .claude/skills/ so Claude Code picks it up")
.argument("[dir]", "target project root (default: current directory)")
.option("--force", "overwrite an existing .claude/skills/trace")
- .action((dir, o) => {
+ .action((dir, options) => {
try {
- const { src, dest } = new ExportSkillCommand().run({ dir, force: o.force });
- process.stdout.write(`[trace-cli] skill exported → ${dest}\n`);
- log.info("skill exported", { src, dest });
+ const { src: source, dest: destination } = new ExportSkillCommand().run({ dir, force: options.force });
+ process.stdout.write(`[trace-cli] skill exported → ${destination}\n`);
+ log.info("skill exported", { src: source, dest: destination });
process.exit(0);
- } catch (e: any) {
- process.stderr.write(`trace-cli: ${e.message}\n`);
+ } catch (error: any) {
+ process.stderr.write(`trace-cli: ${error.message}\n`);
process.exit(1);
}
});
@@ -262,12 +321,12 @@ export class Cli {
const program = this.build().exitOverride();
try {
await program.parseAsync(argv);
- } catch (err: any) {
- if (err instanceof CommanderError) {
- if (["commander.help", "commander.helpDisplayed", "commander.version"].includes(err.code)) process.exit(0);
+ } catch (error: any) {
+ if (error instanceof CommanderError) {
+ if (["commander.help", "commander.helpDisplayed", "commander.version"].includes(error.code)) process.exit(0);
process.exit(2);
}
- process.stderr.write(`trace-cli: ${err?.message || err}\n`);
+ process.stderr.write(`trace-cli: ${error?.message || error}\n`);
process.exit(1);
}
}
diff --git a/src/cli/CommandInputs.ts b/src/cli/CommandInputs.ts
index fd69b42..f30faa5 100644
--- a/src/cli/CommandInputs.ts
+++ b/src/cli/CommandInputs.ts
@@ -19,7 +19,7 @@ const MAX_PORT = 65535;
*/
export class StepInput {
@IsIn(STEP_ACTIONS as unknown as string[]) action!: string;
- @ValidateIf((o) => STEP_ACTIONS_NEEDING_ARG.has(o.action)) @IsNotEmpty() arg?: string;
+ @ValidateIf((input) => STEP_ACTIONS_NEEDING_ARG.has(input.action)) @IsNotEmpty() arg?: string;
@IsOptional() @IsString() value?: string;
constructor(init: Partial = {}) { Object.assign(this, init); }
@@ -31,22 +31,22 @@ export class StepInput {
* Validate every `--step` string against the vocabulary. Returns [] when all valid, else one line per problem
* prefixed with the step's index + action — never the raw value, which may carry a typed credential.
*/
-export function validateSteps(raw: string[]): string[] {
- const errs: string[] = [];
- raw.forEach((s, i) => {
- const step = parseStep(s);
- const at = `step #${i + 1} (${step.action || "?"})`;
- for (const m of new StepInput(step).validate()) errs.push(`${at}: ${m}`);
- if (step.action === "wait" && step.arg && !/^\d+$/.test(step.arg)) errs.push(`${at}: wait arg must be milliseconds (a positive integer)`);
+export function validateSteps(rawSteps: string[]): string[] {
+ const errors: string[] = [];
+ rawSteps.forEach((stepString, index) => {
+ const step = parseStep(stepString);
+ const label = `step #${index + 1} (${step.action || "?"})`;
+ for (const message of new StepInput(step).validate()) errors.push(`${label}: ${message}`);
+ if (step.action === "wait" && step.arg && !/^\d+$/.test(step.arg)) errors.push(`${label}: wait arg must be milliseconds (a positive integer)`);
});
- return errs;
+ return errors;
}
-/** Input contract for `trace-cli dynamic`. */
+/** Input contract for `trace-cli run`. */
export class DynamicInput {
@IsIn(Object.values(TargetKind)) target: TargetKind;
// In Chrome launch mode the port isn't known until the browser is spawned, so only range-check a real port.
- @ValidateIf((o) => !o.launch) @IsInt() @Min(1) @Max(MAX_PORT) port: number;
+ @ValidateIf((input) => !input.launch) @IsInt() @Min(1) @Max(MAX_PORT) port: number;
@IsOptional() @IsBoolean() launch?: boolean;
@IsArray() @ArrayNotEmpty() @IsString({ each: true }) breakpoints: string[];
@IsArray() @IsString({ each: true }) exprs: string[];
@@ -64,11 +64,11 @@ export class DynamicInput {
validate(): string[] { return validateStrict(this); }
}
-/** Input contract for `trace-cli graph`. Requires a file plus an anchor: a line (optional col) or a symbol. */
+/** Input contract for `trace-cli graph`. Requires a file plus an anchor: a line (optional column) or a symbol. */
export class GraphInput {
@IsString() file: string;
- @ValidateIf((o) => o.symbol === undefined) @IsInt() @Min(1) line?: number;
- @IsOptional() @IsInt() @Min(1) col?: number;
+ @ValidateIf((input) => input.symbol === undefined) @IsInt() @Min(1) line?: number;
+ @IsOptional() @IsInt() @Min(1) column?: number;
@IsOptional() @IsString() symbol?: string;
@IsOptional() @IsString() server?: string;
@IsOptional() @IsInt() @Min(1) depth?: number;
diff --git a/src/cli/commands/CliCommand.ts b/src/cli/commands/CliCommand.ts
index 3a11825..a69a28a 100644
--- a/src/cli/commands/CliCommand.ts
+++ b/src/cli/commands/CliCommand.ts
@@ -9,5 +9,5 @@
* the shared envelope-stamping and a `render(trace)` contract; everything else extends this base directly.
*/
export abstract class CliCommand {
- abstract run(req: Req): Res | Promise;
+ abstract run(request: Req): Res | Promise;
}
diff --git a/src/cli/commands/ComplexityCommand.ts b/src/cli/commands/ComplexityCommand.ts
index 79fe3d9..d1e68c8 100644
--- a/src/cli/commands/ComplexityCommand.ts
+++ b/src/cli/commands/ComplexityCommand.ts
@@ -1,10 +1,8 @@
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 { Code } from "../../shared/codes.js";
+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
@@ -15,40 +13,39 @@ export interface ComplexityRequest {
}
interface Metric { name: string; value: number; unit?: string; }
-interface FnSymbol { name: string; kind: string; loc: { file: string; line?: number; endLine?: number }; metrics: Metric[]; }
-export interface ComplexityReport { functions: FnSymbol[]; stats: { functions: number; maxCcn: number; avgCcn: number; overThreshold: number }; }
+interface FunctionSymbol { name: string; kind: string; location: { file: string; line?: number; endLine?: number }; metrics: Metric[]; }
+export interface ComplexityReport { functions: FunctionSymbol[]; stats: { functions: number; maxCcn: number; avgCcn: number; overThreshold: number }; }
/**
- * 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).
+ * ComplexityCommand — the `complexity` analysis: per-function cyclomatic complexity via `lizard --csv`.
+ * 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 = Code.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(request: ComplexityRequest): ToolInvocation {
+ return { argv: ["--csv", request.path], cwd: request.root ?? process.cwd() };
+ }
- return this.envelope({ command: "complexity.lizard", data, diagnostics, args: req.args ?? {}, startedAtMs });
+ protected interpret(toolRun: ToolRun): AnalysisOutcome {
+ const functions = ComplexityCommand.parseCsv(toolRun.stdout);
+ if (!functions.length && !toolRun.ok) {
+ // Non-zero exit with nothing parseable — a real error (bad path / unsupported language), not findings.
+ return { diagnostics: [Diagnostic.error(this.errorCode, toolRun.error ?? `lizard exited ${toolRun.exitCode} with no parseable output`)] };
+ }
+ const report = ComplexityCommand.summarize(functions);
+ const diagnostics = report.stats.overThreshold
+ ? [Diagnostic.warn(Code.COMPLEXITY_HIGH, `${report.stats.overThreshold} function(s) over CCN ${CCN_WARN} (max ${report.stats.maxCcn})`)]
+ : [];
+ return { data: new TraceData({ complexity: report }), diagnostics };
}
/**
@@ -56,53 +53,52 @@ export class ComplexityCommand extends TraceCommand {
* location — where location is "name@startLine-endLine@file". Tolerant: skips a header row and any line
* that doesn't start with a number.
*/
- static parseCsv(csv: string): FnSymbol[] {
- const out: FnSymbol[] = [];
+ static parseCsv(csv: string): FunctionSymbol[] {
+ const functionSymbols: FunctionSymbol[] = [];
for (const line of csv.split("\n")) {
- const cols = splitCsv(line);
- if (cols.length < 6) continue;
- const nloc = Number(cols[0]);
- const ccn = Number(cols[1]);
- if (!Number.isFinite(nloc) || !Number.isFinite(ccn)) continue; // header / blank / summary line
- const token = Number(cols[2]);
- const param = Number(cols[3]);
- const { name, file, line: startLine, endLine } = parseLocation(cols[5]);
- out.push({
+ const columns = splitCsv(line);
+ if (columns.length < 6) continue;
+ const nloc = Number(columns[0]);
+ const cyclomaticComplexity = Number(columns[1]);
+ if (!Number.isFinite(nloc) || !Number.isFinite(cyclomaticComplexity)) continue; // header / blank / summary line
+ const tokenCount = Number(columns[2]);
+ const parameterCount = Number(columns[3]);
+ const { name, file, line: startLine, endLine } = parseLocation(columns[5]);
+ functionSymbols.push({
name: name || "(anonymous)",
kind: "function",
- loc: { file, ...(startLine ? { line: startLine } : {}), ...(endLine ? { endLine } : {}) },
+ location: { file, ...(startLine ? { line: startLine } : {}), ...(endLine ? { endLine } : {}) },
metrics: [
- { name: "ccn", value: ccn },
+ { name: "ccn", value: cyclomaticComplexity },
{ name: "nloc", value: nloc },
- ...(Number.isFinite(param) ? [{ name: "params", value: param }] : []),
- ...(Number.isFinite(token) ? [{ name: "tokens", value: token }] : []),
+ ...(Number.isFinite(parameterCount) ? [{ name: "params", value: parameterCount }] : []),
+ ...(Number.isFinite(tokenCount) ? [{ name: "tokens", value: tokenCount }] : []),
],
});
}
- return out;
+ return functionSymbols;
}
- static summarize(functions: FnSymbol[]): ComplexityReport {
- const ccns = functions.map((f) => f.metrics.find((m) => m.name === "ccn")?.value ?? 0);
- const maxCcn = ccns.reduce((a, b) => Math.max(a, b), 0);
- const avgCcn = ccns.length ? Math.round((ccns.reduce((a, b) => a + b, 0) / ccns.length) * 10) / 10 : 0;
- const overThreshold = ccns.filter((c) => c > CCN_WARN).length;
+ static summarize(functions: FunctionSymbol[]): ComplexityReport {
+ const complexityValues = functions.map((functionSymbol) => functionSymbol.metrics.find((metric) => metric.name === "ccn")?.value ?? 0);
+ const maxCcn = complexityValues.reduce((max, value) => Math.max(max, value), 0);
+ const avgCcn = complexityValues.length ? Math.round((complexityValues.reduce((sum, value) => sum + value, 0) / complexityValues.length) * 10) / 10 : 0;
+ const overThreshold = complexityValues.filter((complexityValue) => complexityValue > CCN_WARN).length;
return { functions, stats: { functions: functions.length, maxCcn, avgCcn, overThreshold } };
}
/** 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 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}` : ""), ""];
- for (const f of sorted.slice(0, 40)) {
- const mark = ccn(f) > CCN_WARN ? "⚠️ " : " ";
- lines.push(`${mark} CCN ${String(ccn(f)).padStart(3)} ${f.name} ${f.loc.file}${f.loc.line ? ":" + f.loc.line : ""}`);
+ const maybeReport = trace.data.complexity as ComplexityReport | undefined;
+ const guard = this.emptyRender(trace, !!maybeReport?.functions?.length, "complexity", "no functions found");
+ if (guard !== undefined) return guard;
+ const report = maybeReport!;
+ const cyclomaticComplexityOf = (functionSymbol: FunctionSymbol) => functionSymbol.metrics.find((metric) => metric.name === "ccn")?.value ?? 0;
+ const sorted = [...report.functions].sort((first, second) => cyclomaticComplexityOf(second) - cyclomaticComplexityOf(first));
+ const lines = [`complexity — ${report.stats.functions} functions · max CCN ${report.stats.maxCcn} · avg ${report.stats.avgCcn}` + (report.stats.overThreshold ? ` · ${report.stats.overThreshold} over ${CCN_WARN}` : ""), ""];
+ for (const functionSymbol of sorted.slice(0, 40)) {
+ const mark = cyclomaticComplexityOf(functionSymbol) > CCN_WARN ? "⚠️ " : " ";
+ lines.push(`${mark} CCN ${String(cyclomaticComplexityOf(functionSymbol)).padStart(3)} ${functionSymbol.name} ${functionSymbol.location.file}${functionSymbol.location.line ? ":" + functionSymbol.location.line : ""}`);
}
if (sorted.length > 40) lines.push(` … ${sorted.length - 40} more`);
return lines.join("\n");
@@ -111,29 +107,29 @@ export class ComplexityCommand extends TraceCommand {
/** Split one CSV line, honoring double-quoted fields (which may contain commas). */
function splitCsv(line: string): string[] {
- const out: string[] = [];
- let cur = "";
+ const fields: string[] = [];
+ let field = "";
let inQuotes = false;
- for (let i = 0; i < line.length; i++) {
- const c = line[i];
- if (c === '"') {
- if (inQuotes && line[i + 1] === '"') { cur += '"'; i++; } // escaped ""
+ for (let index = 0; index < line.length; index++) {
+ const character = line[index];
+ if (character === '"') {
+ if (inQuotes && line[index + 1] === '"') { field += '"'; index++; } // escaped ""
else inQuotes = !inQuotes;
- } else if (c === "," && !inQuotes) { out.push(cur); cur = ""; }
- else cur += c;
+ } else if (character === "," && !inQuotes) { fields.push(field); field = ""; }
+ else field += character;
}
- out.push(cur);
- return out.map((s) => s.trim());
+ fields.push(field);
+ return fields.map((rawField) => rawField.trim());
}
/** Parse lizard's location field "name@startLine-endLine@file" → its parts (defensive about missing pieces). */
-function parseLocation(loc: string): { name: string; file: string; line?: number; endLine?: number } {
- const parts = (loc ?? "").split("@");
+function parseLocation(location: string): { name: string; file: string; line?: number; endLine?: number } {
+ const parts = (location ?? "").split("@");
const name = parts[0] ?? "";
const range = parts[1] ?? "";
const file = parts.slice(2).join("@") || (parts.length < 3 ? parts[1] ?? "" : "");
- const [s, e] = range.split("-");
- const line = Number(s);
- const endLine = Number(e);
+ const [startText, endText] = range.split("-");
+ const line = Number(startText);
+ const endLine = Number(endText);
return { name, file, ...(Number.isFinite(line) ? { line } : {}), ...(Number.isFinite(endLine) ? { endLine } : {}) };
}
diff --git a/src/cli/commands/DepsCommand.ts b/src/cli/commands/DepsCommand.ts
index 2a57be4..01b8576 100644
--- a/src/cli/commands/DepsCommand.ts
+++ b/src/cli/commands/DepsCommand.ts
@@ -1,108 +1,146 @@
+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 { Code } from "../../shared/codes.js";
+import type { ToolRun } from "../../shared/runTool.js";
+import { ShellAnalysisCommand, type AnalysisOutcome, type ToolInvocation } from "./ShellAnalysisCommand.js";
+import { GraphView } from "./GraphView.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)
+ exclude?: string; // regexp of module paths to drop (madge --exclude), e.g. build output like "(^|/)dist/"
args?: Record;
}
-interface DepNode { id: string; label: string; loc: { file: string }; }
+interface DepNode { id: string; label: string; location: { file: string }; }
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 `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 = Code.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(request: DepsRequest): ToolInvocation {
+ const cwd = request.root ?? process.cwd();
+ const extensions = request.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 = request.tsConfig ?? findTsConfig(cwd, request.entry);
+ const argv = [
+ "--json", "--extensions", extensions,
+ ...(tsConfig ? ["--ts-config", tsConfig] : []),
+ ...(request.exclude ? ["--exclude", request.exclude] : []),
+ request.entry,
+ ];
+ return { argv, cwd, args: { ...(request.args ?? {}), extensions, ...(tsConfig ? { tsConfig } : {}), ...(request.exclude ? { exclude: request.exclude } : {}) } };
+ }
- return this.envelope({ command: "deps.madge", data, diagnostics, args: req.args ?? {}, startedAtMs });
+ protected interpret(toolRun: ToolRun, request: DepsRequest): AnalysisOutcome {
+ let moduleImports: Record;
+ try { moduleImports = JSON.parse(toolRun.stdout) as Record; }
+ catch (error: any) { throw new Error(`could not parse madge output: ${String(error?.message ?? error).split("\n")[0]}`); }
+ const deps = DepsCommand.toGraph(moduleImports, request.entry);
+ const diagnostics = deps.stats.circular
+ ? [Diagnostic.warn(Code.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). */
- static toGraph(map: Record, entry?: string): DepGraph {
- const nodes: DepNode[] = Object.keys(map).map((id) => ({ id, label: id, loc: { file: id } }));
+ static toGraph(moduleImports: Record, entry?: string): DepGraph {
+ const nodes: DepNode[] = Object.keys(moduleImports).map((id) => ({ id, label: id, location: { file: id } }));
const edges: DepEdge[] = [];
- for (const [from, deps] of Object.entries(map)) for (const to of deps) edges.push({ from, to, kind: "imports" });
- return { entry, nodes, edges, stats: { modules: nodes.length, edges: edges.length, circular: countCircularGroups(map) } };
+ for (const [from, imports] of Object.entries(moduleImports)) for (const to of imports) edges.push({ from, to, kind: "imports" });
+ return { entry, nodes, edges, stats: { modules: nodes.length, edges: edges.length, circular: countCircularGroups(moduleImports) } };
}
/** 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 adj = new Map();
- for (const e of g.edges) (adj.get(e.from) ?? adj.set(e.from, []).get(e.from)!).push(e.to);
+ const maybeGraph = trace.data.deps as DepGraph | undefined;
+ const guard = this.emptyRender(trace, !!maybeGraph?.nodes?.length, "deps", "no modules");
+ if (guard !== undefined) return guard; // no payload → guard is the rendered line; else the graph is present
+ const graph = maybeGraph!;
+ const adjacency = new Map();
+ for (const edge of graph.edges) (adjacency.get(edge.from) ?? adjacency.set(edge.from, []).get(edge.from)!).push(edge.to);
const lines = [
- `deps — ${g.stats.modules} modules · ${g.stats.edges} imports` + (g.stats.circular ? ` · ${g.stats.circular} circular` : ""),
+ `deps — ${graph.stats.modules} modules · ${graph.stats.edges} imports` + (graph.stats.circular ? ` · ${graph.stats.circular} circular` : ""),
"",
];
- for (const id of g.nodes.map((n) => n.id).sort()) {
- const outs = (adj.get(id) ?? []).sort();
+ for (const id of graph.nodes.map((node) => node.id).sort()) {
+ const outgoing = (adjacency.get(id) ?? []).sort();
lines.push(id);
- outs.forEach((to, i) => lines.push(` ${i === outs.length - 1 ? "└─" : "├─"} ${to}`));
+ outgoing.forEach((to, index) => lines.push(` ${index === outgoing.length - 1 ? "└─" : "├─"} ${to}`));
}
return lines.join("\n");
}
+
+ /** HTML view: the whole module graph as an interactive node-and-edge diagram (see {@link GraphView.depsHtml}). */
+ renderHtml(trace: Trace): string {
+ return GraphView.depsHtml(trace);
+ }
+}
+
+/**
+ * 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 directory = 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(directory) && !existsSync(join(directory, "tsconfig.json"))) directory = dirname(directory);
+ const rootDirectory = parse(directory).root;
+ for (let currentDirectory = directory; ; currentDirectory = dirname(currentDirectory)) {
+ const candidate = join(currentDirectory, "tsconfig.json");
+ if (existsSync(candidate)) return candidate;
+ if (currentDirectory === rootDirectory) return undefined;
+ }
}
/** Count strongly-connected components of size > 1 (Tarjan) — each is a circular-import group. */
-function countCircularGroups(map: Record): number {
+function countCircularGroups(moduleImports: Record): number {
const index = new Map();
- const low = new Map();
+ const lowLink = new Map();
const onStack = new Set();
const stack: string[] = [];
- let idx = 0;
+ let nextIndex = 0;
let groups = 0;
- const nodes = new Set(Object.keys(map));
- for (const deps of Object.values(map)) for (const d of deps) nodes.add(d);
+ const nodes = new Set(Object.keys(moduleImports));
+ for (const dependencies of Object.values(moduleImports)) for (const dependency of dependencies) nodes.add(dependency);
- const strongconnect = (v: string): void => {
- index.set(v, idx); low.set(v, idx); idx++;
- stack.push(v); onStack.add(v);
- for (const w of map[v] ?? []) {
- if (!index.has(w)) { strongconnect(w); low.set(v, Math.min(low.get(v)!, low.get(w)!)); }
- else if (onStack.has(w)) low.set(v, Math.min(low.get(v)!, index.get(w)!));
+ const strongconnect = (vertex: string): void => {
+ index.set(vertex, nextIndex); lowLink.set(vertex, nextIndex); nextIndex++;
+ stack.push(vertex); onStack.add(vertex);
+ for (const neighborVertex of moduleImports[vertex] ?? []) {
+ if (!index.has(neighborVertex)) { strongconnect(neighborVertex); lowLink.set(vertex, Math.min(lowLink.get(vertex)!, lowLink.get(neighborVertex)!)); }
+ else if (onStack.has(neighborVertex)) lowLink.set(vertex, Math.min(lowLink.get(vertex)!, index.get(neighborVertex)!));
}
- if (low.get(v) === index.get(v)) {
+ if (lowLink.get(vertex) === index.get(vertex)) {
let size = 0;
- let w: string;
- do { w = stack.pop()!; onStack.delete(w); size++; } while (w !== v);
+ let poppedVertex: string;
+ do { poppedVertex = stack.pop()!; onStack.delete(poppedVertex); size++; } while (poppedVertex !== vertex);
if (size > 1) groups++;
}
};
- for (const v of nodes) if (!index.has(v)) strongconnect(v);
+ for (const vertex of nodes) if (!index.has(vertex)) strongconnect(vertex);
return groups;
}
diff --git a/src/cli/commands/DoctorCommand.ts b/src/cli/commands/DoctorCommand.ts
index 71f0966..f88667d 100644
--- a/src/cli/commands/DoctorCommand.ts
+++ b/src/cli/commands/DoctorCommand.ts
@@ -4,11 +4,12 @@ import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import { Trace, TraceData } from "../../domain/Trace.js";
import { Diagnostic } from "../../domain/Diagnostic.js";
+import { Code } from "../../shared/codes.js";
import { TraceCommand } from "./TraceCommand.js";
-const pexec = promisify(execFile);
+const execFileAsync = promisify(execFile);
-interface ToolDef { name: string; pillar: string; purpose: string; cmd?: string; args?: string[]; chrome?: boolean; s3?: boolean; db?: boolean; mod?: string; }
+interface ToolDef { name: string; pillar: string; purpose: string; cmd?: string; args?: string[]; chrome?: boolean; s3?: boolean; db?: boolean; moduleName?: string; }
export interface ToolStatus { name: string; pillar: string; purpose: string; present: boolean; version?: string; }
const TOOLS: ToolDef[] = [
@@ -19,7 +20,7 @@ const TOOLS: ToolDef[] = [
{ name: "lizard", pillar: "static", purpose: "static complexity", cmd: "lizard", args: ["--version"] },
{ name: "tree-sitter", pillar: "static", purpose: "static symbols (AST)", cmd: "tree-sitter", args: ["--version"] },
{ name: "madge", pillar: "static", purpose: "static deps (JS/TS)", cmd: "madge", args: ["--version"] },
- { name: "typescript-language-server", pillar: "static", purpose: "code graph — `trace static graph` default LSP server (wraps tsserver)", mod: "typescript-language-server" },
+ { name: "typescript-language-server", pillar: "static", purpose: "code graph — `trace graph` default LSP server (wraps tsserver)", moduleName: "typescript-language-server" },
{ name: "otel-cli", pillar: "runtime", purpose: "exec spans (OTel)", cmd: "otel-cli", args: ["--version"] },
{ name: "playwright", pillar: "frontend", purpose: "web traces", cmd: "playwright", args: ["--version"] },
{ name: "s3", pillar: "storage", purpose: "upload recordings (S3_ENDPOINT)", s3: true },
@@ -35,66 +36,66 @@ export class DoctorCommand extends TraceCommand {
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser",
];
- return candidates.find((p) => existsSync(p)) ?? null;
+ return candidates.find((candidatePath) => existsSync(candidatePath)) ?? null;
}
- async #probe(t: ToolDef): Promise {
- if (t.s3) {
- const ep = process.env.S3_ENDPOINT || process.env.AWS_S3_ENDPOINT;
- return { name: t.name, pillar: t.pillar, purpose: t.purpose, present: !!ep, version: ep ?? undefined };
+ async #probe(tool: ToolDef): Promise {
+ if (tool.s3) {
+ const endpoint = process.env.S3_ENDPOINT || process.env.AWS_S3_ENDPOINT;
+ return { name: tool.name, pillar: tool.pillar, purpose: tool.purpose, present: !!endpoint, version: endpoint ?? undefined };
}
- if (t.db) {
- const url = process.env.DATABASE_URL || process.env.POSTGRES_URL;
+ if (tool.db) {
+ const databaseUrl = process.env.DATABASE_URL || process.env.POSTGRES_URL;
// Redact credentials before surfacing the connection string.
- const shown = url?.replace(/\/\/[^@/]*@/, "//***@");
- return { name: t.name, pillar: t.pillar, purpose: t.purpose, present: !!url, version: shown ?? undefined };
+ const redactedUrl = databaseUrl?.replace(/\/\/[^@/]*@/, "//***@");
+ return { name: tool.name, pillar: tool.pillar, purpose: tool.purpose, present: !!databaseUrl, version: redactedUrl ?? undefined };
}
- if (t.chrome) {
- const p = DoctorCommand.chromePath();
- return { name: t.name, pillar: t.pillar, purpose: t.purpose, present: !!p, version: p ?? undefined };
+ if (tool.chrome) {
+ const chromePath = DoctorCommand.chromePath();
+ return { name: tool.name, pillar: tool.pillar, purpose: tool.purpose, present: !!chromePath, version: chromePath ?? undefined };
}
- if (t.mod) {
+ if (tool.moduleName) {
// A resolved package dependency (the bundled LSP server), not a CLI on PATH — read its package version.
try {
- const require = createRequire(import.meta.url);
- const pkg = require(`${t.mod}/package.json`);
- return { name: t.name, pillar: t.pillar, purpose: t.purpose, present: true, version: pkg.version ? `${t.mod} ${pkg.version}` : "present" };
+ const requireFromHere = createRequire(import.meta.url);
+ const packageJson = requireFromHere(`${tool.moduleName}/package.json`);
+ return { name: tool.name, pillar: tool.pillar, purpose: tool.purpose, present: true, version: packageJson.version ? `${tool.moduleName} ${packageJson.version}` : "present" };
} catch {
- return { name: t.name, pillar: t.pillar, purpose: t.purpose, present: false };
+ return { name: tool.name, pillar: tool.pillar, purpose: tool.purpose, present: false };
}
}
try {
- const { stdout, stderr } = await pexec(t.cmd!, t.args!, { timeout: 5000 });
+ const { stdout, stderr } = await execFileAsync(tool.cmd!, tool.args!, { timeout: 5000 });
const version = (stdout || stderr || "").trim().split("\n")[0] || "present";
- return { name: t.name, pillar: t.pillar, purpose: t.purpose, present: true, version };
+ return { name: tool.name, pillar: tool.pillar, purpose: tool.purpose, present: true, version };
} catch {
- return { name: t.name, pillar: t.pillar, purpose: t.purpose, present: false };
+ return { name: tool.name, pillar: tool.pillar, purpose: tool.purpose, present: false };
}
}
async run(): Promise {
- const tools = await Promise.all(TOOLS.map((t) => this.#probe(t)));
+ const tools = await Promise.all(TOOLS.map((tool) => this.#probe(tool)));
const toolVersions: Record = {};
- for (const t of tools) if (t.present && t.version) toolVersions[t.name] = t.version;
- const diagnostics = tools.filter((t) => !t.present).map((t) => Diagnostic.warn("TOOL_MISSING", `${t.name} not found — ${t.purpose} (pillar: ${t.pillar})`));
+ for (const tool of tools) if (tool.present && tool.version) toolVersions[tool.name] = tool.version;
+ const diagnostics = tools.filter((tool) => !tool.present).map((tool) => Diagnostic.warn(Code.TOOL_MISSING, `${tool.name} not found — ${tool.purpose} (pillar: ${tool.pillar})`));
// Missing tools are warnings, never errors — doctor itself always succeeds (ok: true).
return this.envelope({ command: "doctor", ok: true, data: new TraceData({ tools }), diagnostics, toolVersions });
}
render(trace: Trace): string {
const tools = (trace.data.tools ?? []) as ToolStatus[];
- const pillars = [...new Set(tools.map((t) => t.pillar))];
+ const pillars = [...new Set(tools.map((tool) => tool.pillar))];
const lines = ["trace-cli doctor — backing tools"];
for (const pillar of pillars) {
lines.push(`\n ${pillar}`);
- for (const t of tools.filter((x) => x.pillar === pillar)) {
- const mark = t.present ? "✅" : "⚠️ ";
- const ver = t.present ? (t.version ? ` ${t.version}` : "") : " (missing)";
- lines.push(` ${mark} ${t.name.padEnd(12)}${ver}`);
+ for (const tool of tools.filter((candidateTool) => candidateTool.pillar === pillar)) {
+ const statusMark = tool.present ? "✅" : "⚠️ ";
+ const versionLabel = tool.present ? (tool.version ? ` ${tool.version}` : "") : " (missing)";
+ lines.push(` ${statusMark} ${tool.name.padEnd(12)}${versionLabel}`);
}
}
- const missing = tools.filter((t) => !t.present).length;
- lines.push(`\n ${tools.length - missing}/${tools.length} present` + (missing ? `, ${missing} missing` : ""));
+ const missingCount = tools.filter((tool) => !tool.present).length;
+ lines.push(`\n ${tools.length - missingCount}/${tools.length} present` + (missingCount ? `, ${missingCount} missing` : ""));
return lines.join("\n");
}
}
diff --git a/src/cli/commands/DynamicCommand.ts b/src/cli/commands/DynamicCommand.ts
index a35ead2..74a3089 100644
--- a/src/cli/commands/DynamicCommand.ts
+++ b/src/cli/commands/DynamicCommand.ts
@@ -10,9 +10,10 @@ import { LineageAnalyzer } from "../../analysis/LineageAnalyzer.js";
import { Trace, TraceData, CurlResponse } from "../../domain/Trace.js";
import { Diagnostic } from "../../domain/Diagnostic.js";
import { Recording } from "../../domain/Recording.js";
-import { TargetKind, TargetRef } from "../../domain/Target.js";
+import { TargetKind, TargetReference } from "../../domain/Target.js";
import type { ArtifactStore } from "../../storage/ArtifactStore.js";
import { logger } from "../../shared/logger.js";
+import { Code } from "../../shared/codes.js";
import { TraceCommand } from "./TraceCommand.js";
const log = logger.child({ component: "dynamic" });
@@ -22,7 +23,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
@@ -50,34 +50,34 @@ export class DynamicCommand extends TraceCommand
super();
}
- async run(req: DynamicRequest): Promise {
+ async run(request: DynamicRequest): Promise {
const startedAtMs = this.started();
- const sessionId = req.sessionId ?? randomUUID();
- const isChrome = req.target === TargetKind.Chrome;
+ const sessionId = request.sessionId ?? randomUUID();
+ const isChrome = request.target === TargetKind.Chrome;
// Provisional trigger for the running envelopes (the final one carries the exact value from the capture):
// node → the curl; chrome → the first goto: URL, else a step count.
- const gotoStep = req.steps?.find((s) => s.startsWith("goto:"))?.slice("goto:".length);
- const trigger = isChrome ? (gotoStep ?? (req.steps?.length ? `${req.steps.length} steps` : null)) : (req.curl ?? null);
- const ctx: RunCtx = { sessionId, target: req.target, trigger, args: req.args ?? {}, startedAtMs };
+ const gotoStep = request.steps?.find((step) => step.startsWith("goto:"))?.slice("goto:".length);
+ const trigger = isChrome ? (gotoStep ?? (request.steps?.length ? `${request.steps.length} steps` : null)) : (request.curl ?? null);
+ const context: RunCtx = { sessionId, target: request.target, trigger, args: request.args ?? {}, startedAtMs };
// The session exists in the collector the instant the run begins (0 events), then updates on every hit.
- req.onProgress?.(this.#runningTrace([], ctx));
+ request.onProgress?.(this.#runningTrace([], context));
// Chrome launch mode (`--chrome` with no port): spawn a throwaway headless Chrome to BE the trace target,
// then tear it down. Attach mode (`--chrome `) uses the running browser as-is.
let launched: LaunchedChrome | undefined;
try {
- let opts: TraceOptions = {
- ...req, sessionId,
- ...(req.onProgress ? { onEvent: (events) => req.onProgress!(this.#runningTrace(events, ctx)) } : {}),
+ let options: TraceOptions = {
+ ...request, sessionId,
+ ...(request.onProgress ? { onEvent: (events) => request.onProgress!(this.#runningTrace(events, context)) } : {}),
};
- if (isChrome && req.launch) { launched = await ChromeLauncher.launch(); opts = { ...opts, port: launched.port }; }
+ if (isChrome && request.launch) { launched = await ChromeLauncher.launch(); options = { ...options, port: launched.port }; }
// Both targets go through the engine the same way: one method, one CaptureResult. Chrome layers on the
// extra it alone supports — the screen + trace-panel recording.
- const capture = isChrome ? await this.tracer.traceChrome(opts) : await this.tracer.traceNode(opts);
- const trace = this.#toTrace(capture, { sessionId, args: req.args ?? {}, startedAtMs });
- if (isChrome) await this.#record(capture, trace, sessionId, req.recordOut);
+ const capture = isChrome ? await this.tracer.traceChrome(options) : await this.tracer.traceNode(options);
+ const trace = this.#toTrace(capture, { sessionId, args: request.args ?? {}, startedAtMs });
+ if (isChrome) await this.#record(capture, trace, sessionId, request.recordOut);
return { trace, capture };
} finally {
launched?.kill();
@@ -88,48 +88,48 @@ export class DynamicCommand extends TraceCommand
* A partial, mid-run envelope: the same shape as the finished trace but flagged `running` and carrying only
* the events captured so far (lineage/recording/diagnostics are computed once at the end in {@link #toTrace}).
*/
- #runningTrace(events: CaptureResult["events"], ctx: RunCtx): Trace {
+ #runningTrace(events: CaptureResult["events"], context: RunCtx): Trace {
return this.envelope({
- command: `dynamic.${ctx.target}`,
+ command: `run.${context.target}`,
data: new TraceData({ events }),
running: true,
- sessionId: ctx.sessionId,
- args: ctx.args,
- startedAtMs: ctx.startedAtMs,
- target: new TargetRef({ kind: ctx.target, source: "cdp", trigger: ctx.trigger }),
+ sessionId: context.sessionId,
+ args: context.args,
+ startedAtMs: context.startedAtMs,
+ target: new TargetReference({ kind: context.target, source: "cdp", trigger: context.trigger }),
});
}
- #toTrace(c: CaptureResult, ctx: { sessionId: string; args: Record; startedAtMs: number }): Trace {
+ #toTrace(capture: CaptureResult, context: { sessionId: string; args: Record; startedAtMs: number }): Trace {
const source = "cdp";
const diagnostics: Diagnostic[] = [];
- if (c.fatal) diagnostics.push(Diagnostic.error("ENGINE_FATAL", String(c.fatal).split("\n")[0]));
+ if (capture.fatal) diagnostics.push(Diagnostic.error(Code.ENGINE_FATAL, String(capture.fatal).split("\n")[0]));
// A failed journey step (selector not found / timed out) flips the envelope's `ok` — same gate the old
// `journey` command applied to its exit code, now expressed as an error diagnostic.
- for (const s of c.steps ?? []) if (!s.ok) diagnostics.push(Diagnostic.error("STEP_FAILED", `#${s.seq} ${s.step}${s.note ? " — " + s.note : ""}`));
- for (const b of c.breakpoints.filter((b) => !b.bound)) {
- diagnostics.push(Diagnostic.warn("BP_UNBOUND", `${b.file}:${b.line} did not bind${b.note ? " — " + b.note : ""}`));
+ for (const step of capture.steps ?? []) if (!step.ok) diagnostics.push(Diagnostic.error(Code.STEP_FAILED, `#${step.sequence} ${step.step}${step.note ? " — " + step.note : ""}`));
+ for (const breakpoint of capture.breakpoints.filter((breakpoint) => !breakpoint.bound)) {
+ diagnostics.push(Diagnostic.warn(Code.BP_UNBOUND, `${breakpoint.file}:${breakpoint.line} did not bind${breakpoint.note ? " — " + breakpoint.note : ""}`));
}
- const lineage = LineageAnalyzer.compute(c.events);
+ const lineage = LineageAnalyzer.compute(capture.events);
const data = new TraceData({
- breakpoints: c.breakpoints,
- events: c.events,
+ breakpoints: capture.breakpoints,
+ events: capture.events,
...(lineage.length ? { lineage } : {}),
- ...(c.response ? { response: new CurlResponse(c.response) } : {}),
- ...(c.console?.length ? { console: c.console } : {}),
- ...(c.network?.length ? { network: c.network } : {}),
- ...(c.finalUrl ? { finalUrl: c.finalUrl } : {}),
- ...(c.screenshot ? { screenshot: c.screenshot } : {}),
+ ...(capture.response ? { response: new CurlResponse(capture.response) } : {}),
+ ...(capture.console?.length ? { console: capture.console } : {}),
+ ...(capture.network?.length ? { network: capture.network } : {}),
+ ...(capture.finalUrl ? { finalUrl: capture.finalUrl } : {}),
+ ...(capture.screenshot ? { screenshot: capture.screenshot } : {}),
});
// `ok` derives from the diagnostics: ENGINE_FATAL or STEP_FAILED (errors) flip it false; BP_UNBOUND (warn) doesn't.
return this.envelope({
- command: `dynamic.${c.target}`,
+ command: `run.${capture.target}`,
data,
diagnostics,
- sessionId: ctx.sessionId,
- args: ctx.args,
- startedAtMs: ctx.startedAtMs,
- target: new TargetRef({ kind: c.target, source, trigger: c.trigger }),
+ sessionId: context.sessionId,
+ args: context.args,
+ startedAtMs: context.startedAtMs,
+ target: new TargetReference({ kind: capture.target, source, trigger: capture.trigger }),
});
}
@@ -139,17 +139,17 @@ export class DynamicCommand extends TraceCommand
}
/** Render the Chrome debug-replay video, upload it (if an ArtifactStore is configured), attach the link. */
- async #record(capture: CaptureResult, trace: Trace, sessionId: string, out?: string): Promise {
+ async #record(capture: CaptureResult, trace: Trace, sessionId: string, outputPath?: string): Promise {
try {
- const path = out ?? join(tmpdir(), `trace-${sessionId}.mp4`);
- const mp4 = await Recorder.renderJourney(capture.frames ?? [], capture.traced ?? [], path);
- if (!mp4) { log.warn("no frames captured — nothing to record", { sessionId }); return; }
- const up = this.artifacts && this.artifacts.isConfigured() ? await this.artifacts.upload(mp4, `recordings/${sessionId}.mp4`, "video/mp4") : null;
- trace.data.recording = up ? new Recording({ url: up.url, bytes: up.bytes }) : new Recording({ path: mp4 });
- if (up) log.info("recording uploaded", { sessionId, url: up.url, bytes: up.bytes });
- else log.info("recording saved locally — set S3_ENDPOINT to upload + get a link", { sessionId, path: mp4 });
- } catch (e: any) {
- log.error("recording failed", { sessionId, err: e });
+ const videoOutputPath = outputPath ?? join(tmpdir(), `trace-${sessionId}.mp4`);
+ const videoPath = await Recorder.renderJourney(capture.frames ?? [], capture.traced ?? [], videoOutputPath);
+ if (!videoPath) { log.warn("no frames captured — nothing to record", { code: Code.RECORD_EMPTY, sessionId }); return; }
+ const upload = this.artifacts && this.artifacts.isConfigured() ? await this.artifacts.upload(videoPath, `recordings/${sessionId}.mp4`, "video/mp4") : null;
+ trace.data.recording = upload ? new Recording({ url: upload.url, bytes: upload.bytes }) : new Recording({ path: videoPath });
+ if (upload) log.info("recording uploaded", { sessionId, url: upload.url, bytes: upload.bytes });
+ else log.info("recording saved locally — set S3_ENDPOINT to upload + get a link", { sessionId, path: videoPath });
+ } catch (error: any) {
+ log.error("recording failed", { code: Code.RECORD, sessionId, err: error });
}
}
}
diff --git a/src/cli/commands/ExportSkillCommand.ts b/src/cli/commands/ExportSkillCommand.ts
index c04b392..cdeb35b 100644
--- a/src/cli/commands/ExportSkillCommand.ts
+++ b/src/cli/commands/ExportSkillCommand.ts
@@ -22,27 +22,27 @@ export class ExportSkillCommand extends CliCommand existsSync(join(p, "SKILL.md")));
- if (!found) throw new Error(`bundled '${ExportSkillCommand.SKILL_NAME}' skill not found (looked in: ${candidates.join(", ")})`);
- return found;
+ const foundPath = candidatePaths.find((candidatePath) => existsSync(join(candidatePath, "SKILL.md")));
+ if (!foundPath) throw new Error(`bundled '${ExportSkillCommand.SKILL_NAME}' skill not found (looked in: ${candidatePaths.join(", ")})`);
+ return foundPath;
}
- run(req: ExportSkillRequest = {}): ExportSkillResult {
- const src = this.#resolveSource();
- const projectRoot = resolve(req.dir ?? process.cwd());
- const skillsDir = join(projectRoot, ".claude", "skills");
- const dest = join(skillsDir, ExportSkillCommand.SKILL_NAME);
- if (existsSync(dest) && !req.force) {
- throw new Error(`${dest} already exists — pass --force to overwrite`);
+ run(request: ExportSkillRequest = {}): ExportSkillResult {
+ const sourcePath = this.#resolveSource();
+ const projectRoot = resolve(request.dir ?? process.cwd());
+ const skillsDirectory = join(projectRoot, ".claude", "skills");
+ const destinationPath = join(skillsDirectory, ExportSkillCommand.SKILL_NAME);
+ if (existsSync(destinationPath) && !request.force) {
+ throw new Error(`${destinationPath} already exists — pass --force to overwrite`);
}
- mkdirSync(skillsDir, { recursive: true });
- cpSync(src, dest, { recursive: true, force: true });
- return { src, dest };
+ mkdirSync(skillsDirectory, { recursive: true });
+ cpSync(sourcePath, destinationPath, { recursive: true, force: true });
+ return { src: sourcePath, dest: destinationPath };
}
}
diff --git a/src/cli/commands/GraphCommand.ts b/src/cli/commands/GraphCommand.ts
index 84053d9..85287b9 100644
--- a/src/cli/commands/GraphCommand.ts
+++ b/src/cli/commands/GraphCommand.ts
@@ -3,16 +3,18 @@ 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 { Code } from "../../shared/codes.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, EntryReference } from "../../codegraph/CodeGraphProvider.js";
import { TraceCommand } from "./TraceCommand.js";
+import { GraphView } 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
export interface GraphRequest {
- entry: EntryRef;
+ entry: EntryReference;
provider?: string;
root?: string; // optional: auto-detected from the entry file when absent
maxDepth: number;
@@ -30,32 +32,32 @@ export interface GraphRequest {
* dynamic command surfaces engine failures — an agent always gets back a Trace.
*/
export class GraphCommand extends TraceCommand {
- async run(req: GraphRequest): Promise {
+ async run(request: GraphRequest): Promise {
const startedAtMs = this.started();
- const provider = createCodeGraphProvider(req.provider);
+ const provider = createCodeGraphProvider(request.provider);
const diagnostics: Diagnostic[] = [];
let data = new TraceData({});
try {
// Resolve the entry to an absolute path, then auto-detect the project root from it when --root is absent
// (nearest tsconfig/package.json/.git ancestor — what an IDE does), so the common case is just --entry.
- const base = req.root ? resolve(req.root) : process.cwd();
- const entryFile = isAbsolute(req.entry.file) ? req.entry.file : resolve(base, req.entry.file);
- const root = req.root ? resolve(req.root) : findProjectRoot(entryFile);
- const graph = await provider.callGraph({ ...req.entry, file: entryFile }, {
+ const baseDirectory = request.root ? resolve(request.root) : process.cwd();
+ const entryFile = isAbsolute(request.entry.file) ? request.entry.file : resolve(baseDirectory, request.entry.file);
+ const root = request.root ? resolve(request.root) : findProjectRoot(entryFile);
+ const graph = await provider.callGraph({ ...request.entry, file: entryFile }, {
root,
- maxDepth: req.maxDepth,
- includeExternal: req.includeExternal ?? false,
- maxNodes: req.maxNodes ?? MAX_NODES,
- server: req.server,
+ maxDepth: request.maxDepth,
+ includeExternal: request.includeExternal ?? false,
+ maxNodes: request.maxNodes ?? MAX_NODES,
+ server: request.server,
});
data = new TraceData({ graph });
if (graph.stats.truncated) {
- diagnostics.push(Diagnostic.warn("GRAPH_TRUNCATED", `graph truncated at depth ${req.maxDepth} — raise --depth for more, or pick a more specific entry`));
+ diagnostics.push(Diagnostic.warn(Code.GRAPH_TRUNCATED, `graph truncated at depth ${request.maxDepth} — raise --depth for more, or pick a more specific entry`));
}
- } catch (e: any) {
- diagnostics.push(Diagnostic.error("CODEGRAPH_FAILED", String(e?.message ?? e).split("\n")[0]));
- log.error("call graph failed", { provider: provider.name, err: e });
+ } catch (error: any) {
+ diagnostics.push(Diagnostic.error(Code.CODEGRAPH_FAILED, String(error?.message ?? error).split("\n")[0]));
+ log.error("call graph failed", { code: Code.CODEGRAPH_FAILED, provider: provider.name, err: error });
}
// `ok` derives from the diagnostics: a CODEGRAPH_FAILED error flips it false, GRAPH_TRUNCATED (warn) doesn't.
@@ -63,7 +65,7 @@ export class GraphCommand extends TraceCommand {
command: `graph.${provider.name}`,
data,
diagnostics,
- args: req.args ?? {},
+ args: request.args ?? {},
startedAtMs,
});
}
@@ -71,54 +73,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 : GraphView.tree(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 GraphView.callGraphHtml}). */
+ renderHtml(trace: Trace): string {
+ return GraphView.callGraphHtml(trace);
}
}
diff --git a/src/cli/commands/GraphView.ts b/src/cli/commands/GraphView.ts
new file mode 100644
index 0000000..0f81c20
--- /dev/null
+++ b/src/cli/commands/GraphView.ts
@@ -0,0 +1,429 @@
+import type { Trace } from "../../domain/Trace.js";
+import type { CodeGraph, GraphEdge, GraphNode } from "../../codegraph/CodeGraphProvider.js";
+import type { DepGraph } from "./DepsCommand.js"; // type-only: no runtime cycle (DepsCommand imports the renderer)
+
+/** Header + stats + truncation note rendered above the graph. */
+interface ForceMeta { title: string; h1: string; sub: string; stats: string; truncated?: string }
+/** Node/edge payload handed to the inline force layout. `scope` drives node colour (local=blue, external=amber). */
+interface ForcePayload {
+ entry: string;
+ nodes: { id: string; label: string; kind: string; file: string; line: number; scope: string }[];
+ edges: { from: string; to: string; weight: number }[];
+}
+
+/**
+ * GraphView — the human + HTML presentation of a built call/module graph, split out of {@link GraphCommand} and
+ * {@link DepsCommand} so each command stays a thin use-case (build the graph, stamp the envelope) and the
+ * rendering (a text flow-tree and a self-contained interactive SVG page, ~250 lines of CSS/JS strings) lives on
+ * its own. A namespace of pure static renderers — no IO, no instance state — keyed off the graph/trace.
+ */
+export class GraphView {
+ /**
+ * Text view: the call graph unrolled into a flow tree, with shared callees, cycles and externals marked. A
+ * traversal over the normalized graph — a node reached twice is marked `→ shared` and a back-edge `↻ cycle`
+ * rather than re-expanded, so the tree terminates on recursion.
+ */
+ static tree(graph: CodeGraph): string {
+ const nodesById = new Map(graph.nodes.map((node) => [node.id, node]));
+ const adjacency = new Map();
+ for (const edge of graph.edges) (adjacency.get(edge.from) ?? adjacency.set(edge.from, []).get(edge.from)!).push(edge);
+
+ const root = nodesById.get(graph.entry);
+ const headerLines = [
+ `graph — ${root?.label ?? graph.entry} (${root?.location.file}:${root?.location.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 formatLabel = (node: GraphNode, weight?: number): string => {
+ const weightSuffix = weight && weight > 1 ? ` ×${weight}` : "";
+ if (node.scope !== "local") return `${node.label} ⊗ ${node.scope}${weightSuffix}`;
+ return `${node.label} ${node.location.file}:${node.location.line}${weightSuffix}`;
+ };
+
+ const walk = (id: string, prefix: string, connector: string, weight: number | undefined): void => {
+ const node = nodesById.get(id);
+ if (!node) return;
+ const isCycle = onPath.has(id);
+ const children = adjacency.get(id) ?? [];
+ const isShared = emitted.has(id) && children.length > 0;
+ const tag = isCycle ? " ↻ cycle" : isShared ? " → shared" : "";
+ lines.push(`${prefix}${connector}${formatLabel(node, weight)}${tag}`);
+ if (isCycle || isShared) return; // back-edge / already-expanded: reference only, don't recurse
+ emitted.add(id);
+ onPath.add(id);
+ const childPrefix = connector ? prefix + (connector.startsWith("└") ? " " : "│ ") : prefix;
+ children.forEach((childEdge, index) => {
+ const isLast = index === children.length - 1;
+ walk(childEdge.to, childPrefix, isLast ? "└─ " : "├─ ", childEdge.weight);
+ });
+ onPath.delete(id);
+ };
+
+ walk(graph.entry, "", "", undefined);
+ return headerLines.concat(lines).join("\n");
+ }
+
+ /**
+ * HTML view: the call graph drawn as an actual node-and-edge diagram — a self-contained, zero-dependency
+ * interactive page. Every `graph.nodes` entry is a circle, every `graph.edges` entry a directed arrow, laid
+ * out by an inline force-directed simulation (SVG + vanilla JS). Pan (drag background), zoom (wheel), drag a
+ * node to pin it, hover to spotlight a node's callers/callees, and filter by name/file. This is the whole
+ * point of `--html`: see the codebase as a graph of calls, not a list of collapsible rows. The entry is
+ * accented, externals are amber, hubs scale with degree, and recursion shows as a self-loop. Returns a
+ * complete HTML document (no external assets) ready to write to a file.
+ */
+ static callGraphHtml(trace: Trace): string {
+ const graph = trace.data.graph as CodeGraph | undefined;
+ if (!graph || !graph.nodes?.length) {
+ const errorDiagnostic = trace.diagnostics.find((diagnostic) => diagnostic.level === "error");
+ const message = errorDiagnostic ? `graph failed: ${errorDiagnostic.message}` : "graph — no nodes";
+ return GraphView.htmlDoc("trace-cli graph", `${GraphView.escapeHtml(message)}
`);
+ }
+
+ // The graph IS the data: no traversal/dedup here — nodes and edges go to the force renderer verbatim. Cycles
+ // are just edges that close a loop; a recursive call is a self-edge. Entry is accented, externals are amber.
+ const root = graph.nodes.find((node) => node.id === graph.entry);
+ const stats =
+ `${graph.stats.nodes} nodes · ${graph.stats.edges} edges · depth≤${graph.stats.maxDepth}` +
+ (graph.stats.external ? ` · ${graph.stats.external} external` : "") +
+ (graph.stats.truncated ? " · truncated" : "");
+ return GraphView.forceGraphDoc(
+ {
+ title: `graph — ${root?.label ?? graph.entry}`,
+ h1: root?.label ?? graph.entry,
+ sub: `${root?.location.file ?? ""}${root?.location.line ? ":" + root.location.line : ""} · via ${graph.provider}`,
+ stats,
+ truncated: graph.stats.truncated ? "graph truncated — raise --depth for more, or pick a more specific entry" : undefined,
+ },
+ {
+ entry: graph.entry,
+ nodes: graph.nodes.map((node) => ({ id: node.id, label: node.label, kind: node.kind, file: node.location.file, line: node.location.line, scope: node.scope })),
+ edges: graph.edges.map((edge) => ({ from: edge.from, to: edge.to, weight: edge.weight ?? 1 })),
+ },
+ );
+ }
+
+ /**
+ * HTML view of the module-import graph (`deps`) — the whole repo as a graph, reusing the call-graph's
+ * force-directed renderer. Each module is a node, each import a directed edge (importer → imported). Module
+ * paths are long, so the node label is the basename and the full path lives in the hover title. madge gives no
+ * call counts, so every edge has weight 1.
+ */
+ static depsHtml(trace: Trace): string {
+ const depGraph = trace.data.deps as DepGraph | undefined;
+ if (!depGraph || !depGraph.nodes?.length) {
+ const errorDiagnostic = trace.diagnostics.find((diagnostic) => diagnostic.level === "error");
+ const message = errorDiagnostic ? `deps failed: ${errorDiagnostic.message}` : "deps — no modules";
+ return GraphView.htmlDoc("trace-cli deps", `${GraphView.escapeHtml(message)}
`);
+ }
+ const stats = `${depGraph.stats.modules} modules · ${depGraph.stats.edges} imports` + (depGraph.stats.circular ? ` · ${depGraph.stats.circular} circular` : "");
+ return GraphView.forceGraphDoc(
+ {
+ title: `deps — ${depGraph.stats.modules} modules`,
+ h1: "module graph",
+ sub: `${depGraph.entry ?? ""} · via madge`,
+ stats,
+ },
+ {
+ entry: depGraph.entry ?? "",
+ // label = basename for a readable node; full path goes in the hover title (file). All in-repo → "local".
+ nodes: depGraph.nodes.map((node) => ({ id: node.id, label: node.id.split("/").pop() || node.id, kind: "module", file: node.id, line: 0, scope: "local" })),
+ edges: depGraph.edges.map((edge) => ({ from: edge.from, to: edge.to, weight: 1 })),
+ },
+ );
+ }
+
+ /** HTML-escape a value for safe interpolation into page text/attributes. */
+ private static escapeHtml(value: unknown): string {
+ return String(value).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
+ }
+
+ /**
+ * Shared force-graph page builder: serialize the payload safely, assemble the header/controls/SVG scaffold, and
+ * wrap it in the self-contained document. Both the call graph and the module-import graph render through here —
+ * one layout + interaction implementation, two data sources.
+ */
+ private static forceGraphDoc(meta: ForceMeta, payload: ForcePayload): string {
+ // Inline JSON safely: neutralize "<" (so "" can't terminate the block) and the JS line separators.
+ const dataJson = JSON.stringify(payload)
+ .replace(/${GraphView.escapeHtml(meta.truncated)}` : "";
+ const body =
+ `` +
+ `` +
+ `fit ` +
+ `re-layout ` +
+ `freeze ` +
+ ` ` +
+ ` entry local externaldrag · scroll-zoom · hover ` +
+ `
` +
+ `` +
+ `` +
+ ` ` +
+ ` ` +
+ ` ` +
+ ` `;
+ return GraphView.htmlDoc(meta.title, body, { style: GraphView.GRAPH_CSS, script: GraphView.GRAPH_JS.replace("__DATA__", () => dataJson) });
+ }
+
+ /**
+ * Wrap a rendered body in a complete, self-contained HTML document. `extra.style` is appended after the base
+ * chrome CSS and `extra.script` injected before
+${body}${extra?.script ? `\n` : ""}
+ — the graph view passes the SVG styles + force-layout
+ * JS this way, while the empty/error page uses neither.
+ */
+ private static htmlDoc(title: string, body: string, extra?: { style?: string; script?: string }): string {
+ const escapedTitle = title.replace(/&/g, "&").replace(//g, ">");
+ return `
+
+