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 = + `
` + + `

${GraphView.escapeHtml(meta.h1)}

` + + `
${GraphView.escapeHtml(meta.sub)}
` + + `
${GraphView.escapeHtml(meta.stats)}
` + + truncated + + `
` + + `
` + + `` + + `` + + `` + + `` + + `entrylocalexternaldrag · 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 — 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 ` + + + + +${escapedTitle} + + + +${body}${extra?.script ? `\n` : ""} + +`; + } + + /** Shared page chrome (header, controls, legend, empty state) — light/dark aware via the `--bg`/`--fg` vars. */ + private static readonly HTML_BASE_CSS = ` :root { color-scheme: light dark; --bg: #fbfbfd; --fg: #1d1d1f; } + @media (prefers-color-scheme: dark) { :root { --bg: #161618; --fg: #e6e6e8; } } + body { font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; margin: 0; padding: 1.25rem 1.5rem; background: var(--bg); color: var(--fg); } + header h1 { margin: 0; font-size: 1.15rem; } + .sub { opacity: .7; margin-top: .2rem; } + .stats { opacity: .55; margin-top: .15rem; font-size: .85em; } + .warn { margin-top: .5rem; color: #b26a00; } + .empty { opacity: .6; } + .controls { margin: 1rem 0 .6rem; display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; } + .controls button { font: inherit; cursor: pointer; border: 1px solid currentColor; background: transparent; color: inherit; opacity: .7; border-radius: 6px; padding: .2rem .6rem; } + .controls button:hover { opacity: 1; } + #filter { font: inherit; padding: .25rem .5rem; border: 1px solid #8884; border-radius: 6px; background: transparent; color: inherit; min-width: 14rem; } + .legend { display: inline-flex; align-items: center; opacity: .7; font-size: .85em; margin-left: auto; } + .legend .dot { width: .7rem; height: .7rem; border-radius: 50%; display: inline-block; margin: 0 .3rem 0 .7rem; } + .legend .hint { margin-left: 1rem; opacity: .8; } + .dot.entry { background: #e8543f; } .dot.local { background: #5b8def; } .dot.ext { background: #caa45a; }`; + + /** Graph-view styles: the SVG canvas, edges (arrows), and nodes (circles + labels) with hover/filter states. */ + private static readonly GRAPH_CSS = ` #graph { width: 100%; height: 76vh; border: 1px solid #8883; border-radius: 10px; touch-action: none; cursor: grab; display: block; overflow: hidden; + background: radial-gradient(circle at 1px 1px, #8881 1px, transparent 0) 0 0 / 22px 22px, var(--bg); } + #graph.grabbing { cursor: grabbing; } + .edge { stroke: #8b94a3; stroke-opacity: .5; fill: none; } + .edge.heavy { stroke-opacity: .8; } + .arrow { fill: #8b94a3; } + .node { cursor: pointer; } + .node circle { fill: #5b8def; stroke: var(--bg); stroke-width: 1.5; } + .node.entry circle { fill: #e8543f; } + .node.ext circle { fill: #caa45a; } + .node text { fill: var(--fg); font-size: 11px; paint-order: stroke; stroke: var(--bg); stroke-width: 3px; stroke-linejoin: round; opacity: .92; pointer-events: none; user-select: none; } + .node.hl circle { stroke: var(--fg); stroke-width: 2.5; } + .node.hl text { opacity: 1; font-weight: 600; } + .node.faded { opacity: .12; } + .edge.faded { stroke-opacity: .05; } + .edge.hl { stroke: #e8543f; stroke-opacity: .95; } + .node.dim { opacity: .16; } + .node.match circle { stroke: #e8543f; stroke-width: 2.6; } + .edge.dim { stroke-opacity: .04; }`; + + /** + * Force-directed layout + interaction for the graph view, as a plain-JS IIFE (no build step, no deps). It + * receives the node/edge payload at the `__DATA__` placeholder, builds the SVG, runs a cooling simulation + * (repulsion + link springs + a gentle caller-above-callee bias), then idles. Intentionally template-free + * (string concatenation, no backticks / ${}) so it embeds cleanly inside the document template literal. + */ + private static readonly GRAPH_JS = `(function () { + var graphData = __DATA__; + var svgNamespace = "http://www.w3.org/2000/svg"; + var svg = document.getElementById("graph"); + var viewport = document.getElementById("viewport"); + var edgesGroup = document.getElementById("edges"); + var nodesGroup = document.getElementById("nodes"); + + var nodes = graphData.nodes.map(function (node) { + return { id: node.id, label: node.label, kind: node.kind, file: node.file, line: node.line, scope: node.scope, x: 0, y: 0, vx: 0, vy: 0, deg: 0 }; + }); + var nodesById = new Map(); nodes.forEach(function (node) { nodesById.set(node.id, node); }); + var edges = []; + graphData.edges.forEach(function (edge) { + var sourceNode = nodesById.get(edge.from), targetNode = nodesById.get(edge.to); + if (!sourceNode || !targetNode) return; + edges.push({ source: sourceNode, target: targetNode, weight: edge.weight || 1, self: sourceNode === targetNode }); + sourceNode.deg++; targetNode.deg++; + }); + var neighbors = new Map(); nodes.forEach(function (node) { neighbors.set(node.id, new Set()); }); + edges.forEach(function (edge) { neighbors.get(edge.source.id).add(edge.target.id); neighbors.get(edge.target.id).add(edge.source.id); }); + + var nodeCount = nodes.length; + // Deterministic golden-angle spiral seed so the first frame is already spread out (no Math.random clump). + nodes.forEach(function (node, index) { + var angle = index * 2.399963, radius = 30 + 26 * Math.sqrt(index + 1); + node.x = Math.cos(angle) * radius; node.y = Math.sin(angle) * radius; + }); + var entryNode = nodesById.get(graphData.entry); if (entryNode) { entryNode.x = 0; entryNode.y = 0; } + + function radiusOf(node) { return 6 + Math.min(Math.sqrt(node.deg) * 2.4, 14); } + + edges.forEach(function (edge) { + var element = document.createElementNS(svgNamespace, edge.self ? "path" : "line"); + element.setAttribute("class", "edge" + (edge.weight > 1 ? " heavy" : "")); + element.setAttribute("marker-end", "url(#arrow)"); + element.style.strokeWidth = Math.min(1 + (edge.weight - 1) * 0.7, 4.5); + edgesGroup.appendChild(element); edge.el = element; + }); + nodes.forEach(function (node) { + node.r = radiusOf(node); + var group = document.createElementNS(svgNamespace, "g"); + group.setAttribute("class", "node" + (node.id === graphData.entry ? " entry" : "") + (node.scope !== "local" ? " ext" : "")); + var circle = document.createElementNS(svgNamespace, "circle"); circle.setAttribute("r", node.r); + var textNode = document.createElementNS(svgNamespace, "text"); textNode.setAttribute("x", node.r + 4); textNode.setAttribute("y", 4); textNode.textContent = node.label; + var titleNode = document.createElementNS(svgNamespace, "title"); + var metaText = node.scope === "local" ? (node.file ? (" " + node.file + (node.line ? ":" + node.line : "")) : "") : (" external: " + node.scope); + titleNode.textContent = node.label + metaText; + group.appendChild(circle); group.appendChild(textNode); group.appendChild(titleNode); + nodesGroup.appendChild(group); node.el = group; + group.addEventListener("pointerdown", function (event) { startDrag(event, node); }); + group.addEventListener("mouseenter", function () { focus(node); }); + group.addEventListener("mouseleave", unfocus); + }); + + // --- cooling force simulation --- + var alpha = 1, REP = 4200, LINK = 78, LINKK = 0.04, DIR = 0.06, CENTER = 0.012; + var decay = nodeCount > 500 ? 0.965 : 0.99; + function tick() { + var indexA, indexB, nodeA, nodeB, deltaX, deltaY, distanceSquared, distance, force, unitX, unitY; + for (indexA = 0; indexA < nodeCount; indexA++) { + nodeA = nodes[indexA]; + for (indexB = indexA + 1; indexB < nodeCount; indexB++) { + nodeB = nodes[indexB]; + deltaX = nodeA.x - nodeB.x; deltaY = nodeA.y - nodeB.y; distanceSquared = deltaX * deltaX + deltaY * deltaY; + if (distanceSquared < 0.01) { deltaX = ((indexA * 13 + 7) % 17) - 8; deltaY = ((indexB * 7 + 3) % 17) - 8; distanceSquared = deltaX * deltaX + deltaY * deltaY + 0.01; } + distance = Math.sqrt(distanceSquared); force = (REP / distanceSquared) * alpha; unitX = deltaX / distance; unitY = deltaY / distance; + nodeA.vx += force * unitX; nodeA.vy += force * unitY; nodeB.vx -= force * unitX; nodeB.vy -= force * unitY; + } + } + for (var edgeIndex = 0; edgeIndex < edges.length; edgeIndex++) { + var edge = edges[edgeIndex]; if (edge.self) continue; + deltaX = edge.target.x - edge.source.x; deltaY = edge.target.y - edge.source.y; distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 0.01; + force = (distance - LINK) * LINKK; unitX = deltaX / distance; unitY = deltaY / distance; + edge.source.vx += force * unitX; edge.source.vy += force * unitY; edge.target.vx -= force * unitX; edge.target.vy -= force * unitY; + var separation = (LINK - (edge.target.y - edge.source.y)) * DIR; edge.target.vy += separation; edge.source.vy -= separation; // callee below caller + } + for (indexA = 0; indexA < nodeCount; indexA++) { nodeA = nodes[indexA]; nodeA.vx -= nodeA.x * CENTER; nodeA.vy -= nodeA.y * CENTER; } + for (indexA = 0; indexA < nodeCount; indexA++) { + nodeA = nodes[indexA]; + if (nodeA.fixed) { nodeA.vx = 0; nodeA.vy = 0; continue; } + nodeA.vx *= 0.84; nodeA.vy *= 0.84; + var speed = Math.sqrt(nodeA.vx * nodeA.vx + nodeA.vy * nodeA.vy); if (speed > 30) { nodeA.vx = nodeA.vx / speed * 30; nodeA.vy = nodeA.vy / speed * 30; } + nodeA.x += nodeA.vx; nodeA.y += nodeA.vy; + } + alpha *= decay; + } + + function draw() { + for (var edgeIndex = 0; edgeIndex < edges.length; edgeIndex++) { + var edge = edges[edgeIndex]; + if (edge.self) { + var node = edge.source, radius = node.r; + edge.el.setAttribute("d", "M " + (node.x - radius * 0.6) + " " + (node.y - radius * 0.8) + " C " + (node.x - radius * 3.2) + " " + (node.y - radius * 4) + ", " + (node.x + radius * 3.2) + " " + (node.y - radius * 4) + ", " + (node.x + radius * 0.6) + " " + (node.y - radius * 0.8)); + } else { + var deltaX = edge.target.x - edge.source.x, deltaY = edge.target.y - edge.source.y, distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1; + var startX = edge.source.x + deltaX / distance * edge.source.r, startY = edge.source.y + deltaY / distance * edge.source.r; + var endX = edge.target.x - deltaX / distance * (edge.target.r + 6), endY = edge.target.y - deltaY / distance * (edge.target.r + 6); + edge.el.setAttribute("x1", startX); edge.el.setAttribute("y1", startY); edge.el.setAttribute("x2", endX); edge.el.setAttribute("y2", endY); + } + } + for (var index = 0; index < nodeCount; index++) { var node = nodes[index]; node.el.setAttribute("transform", "translate(" + node.x + "," + node.y + ")"); } + } + + // --- pan / zoom --- + var view = { x: 0, y: 0, k: 1 }; + function applyView() { viewport.setAttribute("transform", "translate(" + view.x + "," + view.y + ") scale(" + view.k + ")"); } + function size() { var rect = svg.getBoundingClientRect(); return { w: rect.width, h: rect.height, left: rect.left, top: rect.top }; } + function fit() { + if (!nodeCount) return; + var minX = 1e9, minY = 1e9, maxX = -1e9, maxY = -1e9; + nodes.forEach(function (node) { minX = Math.min(minX, node.x - node.r); minY = Math.min(minY, node.y - node.r); maxX = Math.max(maxX, node.x + node.r); maxY = Math.max(maxY, node.y + node.r); }); + var viewportSize = size(), padding = 70, graphWidth = Math.max(maxX - minX, 1), graphHeight = Math.max(maxY - minY, 1); + var scale = Math.max(0.1, Math.min((viewportSize.w - padding) / graphWidth, (viewportSize.h - padding) / graphHeight, 2.2)); + view.k = scale; view.x = viewportSize.w / 2 - (minX + maxX) / 2 * scale; view.y = viewportSize.h / 2 - (minY + maxY) / 2 * scale; + applyView(); + } + + var animationFrame = null, refitOnSettle = true; + function loop() { + if (alpha > 0.02) { tick(); draw(); animationFrame = requestAnimationFrame(loop); } + else { draw(); if (refitOnSettle) { refitOnSettle = false; fit(); } animationFrame = null; } + } + function reheat(targetAlpha) { alpha = Math.max(alpha, targetAlpha || 0.6); if (!animationFrame) animationFrame = requestAnimationFrame(loop); } + + // --- interaction --- + function screenToGraph(event) { var viewportSize = size(); return { x: (event.clientX - viewportSize.left - view.x) / view.k, y: (event.clientY - viewportSize.top - view.y) / view.k }; } + var drag = null, pan = null; + function startDrag(event, node) { event.stopPropagation(); event.preventDefault(); drag = { n: node }; node.fixed = true; svg.setPointerCapture(event.pointerId); svg.classList.add("grabbing"); } + svg.addEventListener("pointerdown", function (event) { + if (drag) return; + pan = { px: event.clientX, py: event.clientY, ox: view.x, oy: view.y }; svg.setPointerCapture(event.pointerId); svg.classList.add("grabbing"); + }); + svg.addEventListener("pointermove", function (event) { + if (drag) { var point = screenToGraph(event); drag.n.x = point.x; drag.n.y = point.y; drag.n.vx = 0; drag.n.vy = 0; reheat(0.3); draw(); } + else if (pan) { view.x = pan.ox + (event.clientX - pan.px); view.y = pan.oy + (event.clientY - pan.py); applyView(); } + }); + function endPtr() { if (drag) { drag.n.fixed = false; drag = null; } pan = null; svg.classList.remove("grabbing"); } + svg.addEventListener("pointerup", endPtr); + svg.addEventListener("pointercancel", endPtr); + svg.addEventListener("wheel", function (event) { + event.preventDefault(); + var viewportSize = size(), pointerX = event.clientX - viewportSize.left, pointerY = event.clientY - viewportSize.top; + var nextScale = Math.max(0.08, Math.min(view.k * Math.exp(-event.deltaY * 0.0015), 4)); + view.x = pointerX - (pointerX - view.x) * (nextScale / view.k); view.y = pointerY - (pointerY - view.y) * (nextScale / view.k); view.k = nextScale; applyView(); + }, { passive: false }); + + // --- hover spotlight + name/file filter --- + function focus(node) { + if (drag || pan) return; + var neighborIds = neighbors.get(node.id); + nodes.forEach(function (other) { var isOn = (other === node) || neighborIds.has(other.id); other.el.classList.toggle("faded", !isOn); other.el.classList.toggle("hl", other === node); }); + edges.forEach(function (edge) { var isOn = (edge.source === node || edge.target === node); edge.el.classList.toggle("faded", !isOn); edge.el.classList.toggle("hl", isOn); }); + } + function unfocus() { nodes.forEach(function (node) { node.el.classList.remove("faded", "hl"); }); edges.forEach(function (edge) { edge.el.classList.remove("faded", "hl"); }); } + function doFilter(query) { + query = (query || "").trim().toLowerCase(); + if (!query) { nodes.forEach(function (node) { node.el.classList.remove("dim", "match"); }); edges.forEach(function (edge) { edge.el.classList.remove("dim"); }); return; } + var matched = new Set(); + nodes.forEach(function (node) { + var isHit = (node.label + " " + (node.file || "") + " " + (node.scope || "")).toLowerCase().indexOf(query) >= 0; + node.el.classList.toggle("match", isHit); node.el.classList.toggle("dim", !isHit); if (isHit) matched.add(node.id); + }); + edges.forEach(function (edge) { edge.el.classList.toggle("dim", !(matched.has(edge.source.id) && matched.has(edge.target.id))); }); + } + + document.getElementById("fit").addEventListener("click", fit); + document.getElementById("relayout").addEventListener("click", function () { refitOnSettle = true; reheat(1); }); + var frozen = false; + document.getElementById("freeze").addEventListener("click", function (event) { + frozen = !frozen; event.target.textContent = frozen ? "resume" : "freeze"; + if (frozen) { alpha = 0; } else { reheat(0.4); } + }); + document.getElementById("filter").addEventListener("input", function (event) { doFilter(event.target.value); }); + + var initialSize = size(); view.x = initialSize.w / 2; view.y = initialSize.h / 2; applyView(); + reheat(1); +})();`; +} diff --git a/src/cli/commands/ManifestCommand.ts b/src/cli/commands/ManifestCommand.ts index 108c5c4..0aa2980 100644 --- a/src/cli/commands/ManifestCommand.ts +++ b/src/cli/commands/ManifestCommand.ts @@ -61,42 +61,42 @@ export class ManifestCommand extends CliCommand { return { tool: "trace", version: VERSION, command: this.#command(program) }; } - #command(cmd: Command): ManifestCommandNode { + #command(command: Command): ManifestCommandNode { return { - name: cmd.name(), - aliases: cmd.aliases(), - description: cmd.description(), - usage: cmd.usage(), - arguments: cmd.registeredArguments.map((a) => this.#argument(a)), - options: cmd.options.filter((o) => !o.hidden).map((o) => this.#option(o)), - commands: cmd.commands.map((c) => this.#command(c)), + name: command.name(), + aliases: command.aliases(), + description: command.description(), + usage: command.usage(), + arguments: command.registeredArguments.map((argument) => this.#argument(argument)), + options: command.options.filter((option) => !option.hidden).map((option) => this.#option(option)), + commands: command.commands.map((subcommand) => this.#command(subcommand)), }; } - #option(o: Option): ManifestOption { - const out: ManifestOption = { - flags: o.flags, - description: o.description, - required: o.required, - optional: o.optional, - variadic: o.variadic, - negate: o.negate, + #option(option: Option): ManifestOption { + const manifestOption: ManifestOption = { + flags: option.flags, + description: option.description, + required: option.required, + optional: option.optional, + variadic: option.variadic, + negate: option.negate, }; - if (o.defaultValue !== undefined) out.default = o.defaultValue; - if (o.argChoices) out.choices = o.argChoices; - if (o.envVar) out.envVar = o.envVar; - return out; + if (option.defaultValue !== undefined) manifestOption.default = option.defaultValue; + if (option.argChoices) manifestOption.choices = option.argChoices; + if (option.envVar) manifestOption.envVar = option.envVar; + return manifestOption; } - #argument(a: Argument): ManifestArgument { - const out: ManifestArgument = { - name: a.name(), - description: a.description, - required: a.required, - variadic: a.variadic, + #argument(argument: Argument): ManifestArgument { + const manifestArgument: ManifestArgument = { + name: argument.name(), + description: argument.description, + required: argument.required, + variadic: argument.variadic, }; - if (a.defaultValue !== undefined) out.default = a.defaultValue; - if (a.argChoices) out.choices = a.argChoices; - return out; + if (argument.defaultValue !== undefined) manifestArgument.default = argument.defaultValue; + if (argument.argChoices) manifestArgument.choices = argument.argChoices; + return manifestArgument; } } diff --git a/src/cli/commands/SchemaCommand.ts b/src/cli/commands/SchemaCommand.ts index a9314e6..0fe0083 100644 --- a/src/cli/commands/SchemaCommand.ts +++ b/src/cli/commands/SchemaCommand.ts @@ -4,11 +4,11 @@ import { dirname, join } from "node:path"; import { CliCommand } from "./CliCommand.js"; -const here = dirname(fileURLToPath(import.meta.url)); +const moduleDirectory = dirname(fileURLToPath(import.meta.url)); /** SchemaCommand — prints the output JSON Schema (the contract every command's Trace conforms to). */ export class SchemaCommand extends CliCommand { run(): string { - return readFileSync(join(here, "../../shared/trace.schema.json"), "utf8"); + return readFileSync(join(moduleDirectory, "../../shared/trace.schema.json"), "utf8"); } } diff --git a/src/cli/commands/ServeCommand.ts b/src/cli/commands/ServeCommand.ts index 3d1b664..f4c07f3 100644 --- a/src/cli/commands/ServeCommand.ts +++ b/src/cli/commands/ServeCommand.ts @@ -6,9 +6,9 @@ export interface ServeOptions { port?: number; host?: string; databaseUrl?: stri /** ServeCommand — runs the collector + realtime UI (a long-lived process), backed by Postgres. */ export class ServeCommand extends CliCommand { - run(opts: ServeOptions = {}): void { - const store = createSessionStore({ databaseUrl: opts.databaseUrl }); - new Collector(store).listen({ port: opts.port, host: opts.host }); + run(options: ServeOptions = {}): void { + const store = createSessionStore({ databaseUrl: options.databaseUrl }); + new Collector(store).listen({ port: options.port, host: options.host }); // the listening server keeps the process alive — no exit here. } } diff --git a/src/cli/commands/ShellAnalysisCommand.ts b/src/cli/commands/ShellAnalysisCommand.ts new file mode 100644 index 0000000..e2493ef --- /dev/null +++ b/src/cli/commands/ShellAnalysisCommand.ts @@ -0,0 +1,80 @@ +import { Trace, TraceData } from "../../domain/Trace.js"; +import { Diagnostic } from "../../domain/Diagnostic.js"; +import { logger } from "../../shared/logger.js"; +import { runTool, type ToolRun } from "../../shared/runTool.js"; +import { TraceCommand } from "./TraceCommand.js"; + +/** The tool call one analysis run makes: argv + working dir, plus any extra `meta.args` to record beyond req.args. */ +export interface ToolInvocation { + argv: string[]; + cwd: string; + args?: Record; +} + +/** What `interpret` yields from a non-fatal run: a data payload and/or diagnostics (warnings or a soft failure). */ +export interface AnalysisOutcome { + data?: TraceData; + diagnostics?: Diagnostic[]; +} + +/** + * ShellAnalysisCommand — the Template-Method base shared by every "shell out to an analyzer, normalize its + * stdout into a Trace" command (deps · complexity · symbols; the call graph has its own provider seam). The + * base owns the run skeleton these three repeated verbatim — start stamp, spawn the tool, decide + * fatal-vs-findings, turn any throw into a single error diagnostic, and stamp the envelope — so a subclass + * declares its identity (tool/command/errorCode/component) and supplies only the two parts that differ: + * the tool call ({@link invocation}) and how to read its output ({@link interpret}). A tool that is missing, + * times out, or emits unparseable output becomes a `` error on a still-well-formed Trace, honouring + * the same "an agent always gets a Trace" contract the dynamic/graph commands keep. + */ +export abstract class ShellAnalysisCommand }> extends TraceCommand { + /** Binary to spawn, e.g. `"madge"`. */ protected abstract readonly tool: string; + /** Envelope command id, e.g. `"deps.madge"`. */ protected abstract readonly command: string; + /** Error-diagnostic code raised on failure. */ protected abstract readonly errorCode: string; + /** Logger component label, e.g. `"deps"`. */ protected abstract readonly component: string; + + /** The tool call for this request: argv, cwd, and any meta.args to record. */ + protected abstract invocation(request: Req): ToolInvocation; + + /** Normalize a non-fatal run's output into a payload (+ optional diagnostics). Throw on unparseable output. */ + protected abstract interpret(toolRun: ToolRun, request: Req): AnalysisOutcome; + + /** + * Whether a non-zero exit is a hard failure. Default `true` (madge succeeds with exit 0). Tools that exit + * non-zero merely to signal findings — lizard (threshold breaches), tree-sitter (no grammar / parse errors) + * — override to `false`, so only a process that never produced an exit code (`code === null`) is fatal and + * everything else flows to {@link interpret}. + */ + protected nonZeroIsFailure(): boolean { + return true; + } + + async run(request: Req): Promise { + const startedAtMs = this.started(); + const diagnostics: Diagnostic[] = []; + let data = new TraceData({}); + let args = request.args ?? {}; + + try { + const toolInvocation = this.invocation(request); + if (toolInvocation.args) args = toolInvocation.args; + const toolRun = await runTool(this.tool, toolInvocation.argv, { cwd: toolInvocation.cwd }); + const fatal = this.nonZeroIsFailure() ? !toolRun.ok : toolRun.exitCode === null; + if (fatal) { + const message = toolRun.error ?? (this.nonZeroIsFailure() ? `${this.tool} exited ${toolRun.exitCode}` : `${this.tool} did not run`); + diagnostics.push(Diagnostic.error(this.errorCode, message)); + // Same code on the log line as the envelope diagnostic, so the two channels join on `errorCode`. + logger.child({ component: this.component }).error(`${this.tool} failed`, { code: this.errorCode, err: toolRun.error }); + } else { + const outcome = this.interpret(toolRun, request); + if (outcome.data) data = outcome.data; + if (outcome.diagnostics?.length) diagnostics.push(...outcome.diagnostics); + } + } catch (error: any) { + // A throw from invocation/interpret (e.g. unparseable output, unreadable file) → one error diagnostic. + diagnostics.push(Diagnostic.error(this.errorCode, String(error?.message ?? error).split("\n")[0])); + } + + return this.envelope({ command: this.command, data, diagnostics, args, startedAtMs }); + } +} diff --git a/src/cli/commands/SymbolsCommand.ts b/src/cli/commands/SymbolsCommand.ts index b852707..9835c4c 100644 --- a/src/cli/commands/SymbolsCommand.ts +++ b/src/cli/commands/SymbolsCommand.ts @@ -3,11 +3,9 @@ import { isAbsolute, resolve } from "node:path"; import { Trace, TraceData } from "../../domain/Trace.js"; import { Diagnostic } from "../../domain/Diagnostic.js"; -import { logger } from "../../shared/logger.js"; -import { runTool } from "../../shared/runTool.js"; -import { TraceCommand } from "./TraceCommand.js"; - -const log = logger.child({ component: "symbols" }); +import { Code } from "../../shared/codes.js"; +import type { ToolRun } from "../../shared/runTool.js"; +import { ShellAnalysisCommand, type AnalysisOutcome, type ToolInvocation } from "./ShellAnalysisCommand.js"; export interface SymbolsRequest { file: string; // a single source file @@ -15,7 +13,7 @@ export interface SymbolsRequest { args?: Record; } -interface SymbolEntry { name: string; kind: string; loc: { file: string; line: number; col?: number }; } +interface SymbolEntry { name: string; kind: string; location: { file: string; line: number; column?: number }; } export interface SymbolReport { file: string; symbols: SymbolEntry[]; } // tree-sitter node types that name a definition → the kind we report. Covers common JS/TS/Python/Rust/Go/C grammars. @@ -32,82 +30,78 @@ const DEF_LINE = /^(\s*)\(([a-z_]+)\s+\[(\d+),\s*\d+\]/; const NAME_LINE = /name:\s*\((?:identifier|type_identifier|property_identifier|field_identifier|constant)\s+\[(\d+),\s*(\d+)\]\s*-\s*\[(\d+),\s*(\d+)\]/; /** - * SymbolsCommand — the `static symbols` analysis: top-level definitions in a file via `tree-sitter parse`. - * tree-sitter emits node *types* + positions but not source text, so we read the file and slice each - * definition's name-identifier span to recover its name. Each becomes a schema `Symbol` under `data.symbols`. - * Best-effort + grammar-dependent: when tree-sitter (or the language grammar) is absent, it degrades to a - * SYMBOLS_FAILED diagnostic on a well-formed Trace. + * SymbolsCommand — the `symbols` analysis: top-level definitions in a file via `tree-sitter parse`. + * A {@link ShellAnalysisCommand}: the base owns the run/envelope/failure skeleton; this class supplies the + * tree-sitter call and the S-expression → Symbol normalization. tree-sitter emits node *types* + positions but + * not source text, so {@link interpret} re-reads the file and slices each definition's name-identifier span to + * recover its name. tree-sitter exits non-zero on a missing grammar / parse error rather than a hard crash, so + * {@link nonZeroIsFailure} is false and only a process that never ran (`code === null`) is fatal; an unreadable + * file or a non-zero exit with no parseable output degrades to a SYMBOLS_FAILED diagnostic on a well-formed Trace. */ -export class SymbolsCommand extends TraceCommand { - async run(req: SymbolsRequest): Promise { - const startedAtMs = this.started(); - const diagnostics: Diagnostic[] = []; - let data = new TraceData({}); +export class SymbolsCommand extends ShellAnalysisCommand { + protected readonly tool = "tree-sitter"; + protected readonly command = "symbols.tree-sitter"; + protected readonly errorCode = Code.SYMBOLS_FAILED; + protected readonly component = "symbols"; + protected override nonZeroIsFailure(): boolean { return false; } - const root = req.root ?? process.cwd(); - const abs = isAbsolute(req.file) ? req.file : resolve(root, req.file); - let source: string; - try { - source = readFileSync(abs, "utf8"); - } catch (e: any) { - diagnostics.push(Diagnostic.error("SYMBOLS_FAILED", `cannot read ${req.file}: ${String(e?.message ?? e).split("\n")[0]}`)); - return this.envelope({ command: "symbols.tree-sitter", data, diagnostics, args: req.args ?? {}, startedAtMs }); - } + /** Resolve the request's file against its root (or cwd). */ + #abs(request: SymbolsRequest): string { + return isAbsolute(request.file) ? request.file : resolve(request.root ?? process.cwd(), request.file); + } - const res = await runTool("tree-sitter", ["parse", abs], { cwd: root }); - if (res.code === null) { - diagnostics.push(Diagnostic.error("SYMBOLS_FAILED", res.error ?? "tree-sitter did not run")); - log.error("tree-sitter failed", { file: req.file, err: res.error }); - } else { - const symbols = SymbolsCommand.parseSexp(res.stdout, source, req.file); - if (!symbols.length && !res.ok) { - // No symbols and a non-zero exit usually means "no grammar for this file type" or a parse error. - diagnostics.push(Diagnostic.error("SYMBOLS_FAILED", res.error || res.stderr.split("\n")[0] || `tree-sitter exited ${res.code}`)); - } else { - data = new TraceData({ symbols: { file: req.file, symbols } as SymbolReport }); - } - } + protected invocation(request: SymbolsRequest): ToolInvocation { + return { argv: ["parse", this.#abs(request)], cwd: request.root ?? process.cwd() }; + } - return this.envelope({ command: "symbols.tree-sitter", data, diagnostics, args: req.args ?? {}, startedAtMs }); + protected interpret(toolRun: ToolRun, request: SymbolsRequest): AnalysisOutcome { + let source: string; + try { source = readFileSync(this.#abs(request), "utf8"); } + catch (error: any) { throw new Error(`cannot read ${request.file}: ${String(error?.message ?? error).split("\n")[0]}`); } + const symbols = SymbolsCommand.parseSexp(toolRun.stdout, source, request.file); + if (!symbols.length && !toolRun.ok) { + // No symbols and a non-zero exit usually means "no grammar for this file type" or a parse error. + return { diagnostics: [Diagnostic.error(this.errorCode, toolRun.error || toolRun.stderr.split("\n")[0] || `tree-sitter exited ${toolRun.exitCode}`)] }; + } + return { data: new TraceData({ symbols: { file: request.file, symbols } as SymbolReport }) }; } /** * Parse a `tree-sitter parse` S-expression into definition Symbols. For each definition node we take the * first following `name:` identifier field (it precedes nested children in tree-sitter output) and slice the - * source at that [row,col] span to recover the name. Greedy + line-based — robust for top-level + one level + * source at that [row,column] span to recover the name. Greedy + line-based — robust for top-level + one level * of nesting (a class and its methods), which is what a symbol outline needs. */ static parseSexp(sexp: string, source: string, file: string): SymbolEntry[] { const lines = source.split("\n"); - const out: SymbolEntry[] = []; + const entries: SymbolEntry[] = []; let pending: { kind: string; row: number } | null = null; - for (const raw of sexp.split("\n")) { - const def = DEF_LINE.exec(raw); - if (def && DEF_KINDS[def[2]]) { pending = { kind: DEF_KINDS[def[2]], row: Number(def[3]) }; continue; } - const nm = NAME_LINE.exec(raw); - if (nm && pending) { - const row = Number(nm[1]); - const colStart = Number(nm[2]); - const colEnd = Number(nm[4]); - const lineText = lines[row] ?? ""; - const name = Number(nm[3]) === row ? lineText.slice(colStart, colEnd) : lineText.slice(colStart); - if (name) out.push({ name, kind: pending.kind, loc: { file, line: pending.row + 1, col: colStart } }); + for (const rawLine of sexp.split("\n")) { + const defMatch = DEF_LINE.exec(rawLine); + if (defMatch && DEF_KINDS[defMatch[2]]) { pending = { kind: DEF_KINDS[defMatch[2]], row: Number(defMatch[3]) }; continue; } + const nameMatch = NAME_LINE.exec(rawLine); + if (nameMatch && pending) { + const nameRow = Number(nameMatch[1]); + const colStart = Number(nameMatch[2]); + const colEnd = Number(nameMatch[4]); + const lineText = lines[nameRow] ?? ""; + const name = Number(nameMatch[3]) === nameRow ? lineText.slice(colStart, colEnd) : lineText.slice(colStart); + if (name) entries.push({ name, kind: pending.kind, location: { file, line: pending.row + 1, column: colStart } }); pending = null; } } - return out; + return entries; } /** Human view: definitions grouped by kind, in source order. */ render(trace: Trace): string { - const r = trace.data.symbols as SymbolReport | undefined; - if (!r || !r.symbols?.length) { - const err = trace.diagnostics.find((d) => d.level === "error"); - return err ? `symbols — failed: ${err.message}` : "symbols — no definitions found"; - } - const lines = [`symbols — ${r.symbols.length} definitions in ${r.file}`, ""]; - for (const s of r.symbols) lines.push(` ${s.kind.padEnd(10)} ${s.name} :${s.loc.line}`); + const maybeReport = trace.data.symbols as SymbolReport | undefined; + const guard = this.emptyRender(trace, !!maybeReport?.symbols?.length, "symbols", "no definitions found"); + if (guard !== undefined) return guard; + const report = maybeReport!; + const lines = [`symbols — ${report.symbols.length} definitions in ${report.file}`, ""]; + for (const symbolEntry of report.symbols) lines.push(` ${symbolEntry.kind.padEnd(10)} ${symbolEntry.name} :${symbolEntry.location.line}`); return lines.join("\n"); } } diff --git a/src/cli/commands/TraceCommand.ts b/src/cli/commands/TraceCommand.ts index 5fa51f7..6e13f5c 100644 --- a/src/cli/commands/TraceCommand.ts +++ b/src/cli/commands/TraceCommand.ts @@ -2,13 +2,13 @@ import { performance } from "node:perf_hooks"; import { Trace, TraceMeta, TraceData } from "../../domain/Trace.js"; import type { Diagnostic } from "../../domain/Diagnostic.js"; -import type { TargetRef } from "../../domain/Target.js"; +import type { TargetReference } from "../../domain/Target.js"; import { VERSION } from "../../shared/version.js"; import { CliCommand } from "./CliCommand.js"; /** The command-specific pieces of a Trace; {@link TraceCommand.envelope} stamps everything common around them. */ export interface Envelope { - command: string; // e.g. "dynamic.node", "graph.lsp", "doctor" + command: string; // e.g. "run.node", "graph.lsp", "doctor" data: TraceData; diagnostics?: Diagnostic[]; ok?: boolean; // default: true unless a diagnostic is an error @@ -17,7 +17,7 @@ export interface Envelope { sessionId?: string; args?: Record; toolVersions?: Record; - target?: TargetRef | null; + target?: TargetReference | null; } /** @@ -34,26 +34,38 @@ export abstract class TraceCommand extends CliCommand d.level === "error"), + command: envelopeSpec.command, + ok: envelopeSpec.ok ?? !diagnostics.some((diagnostic) => diagnostic.level === "error"), meta: new TraceMeta({ at: new Date().toISOString(), - ...(e.sessionId ? { sessionId: e.sessionId } : {}), - ...(e.args ? { args: e.args } : {}), - ...(e.toolVersions ? { toolVersions: e.toolVersions } : {}), - ...(e.running ? { running: true } : {}), - ...(e.startedAtMs !== undefined ? { durationMs: Math.round(performance.now() - e.startedAtMs) } : {}), + ...(envelopeSpec.sessionId ? { sessionId: envelopeSpec.sessionId } : {}), + ...(envelopeSpec.args ? { args: envelopeSpec.args } : {}), + ...(envelopeSpec.toolVersions ? { toolVersions: envelopeSpec.toolVersions } : {}), + ...(envelopeSpec.running ? { running: true } : {}), + ...(envelopeSpec.startedAtMs !== undefined ? { durationMs: Math.round(performance.now() - envelopeSpec.startedAtMs) } : {}), }), - target: e.target ?? null, - data: e.data, + target: envelopeSpec.target ?? null, + data: envelopeSpec.data, diagnostics, }); } + /** + * Shared empty/error guard for `render()`. When the trace has no usable payload, returns the one-line human + * string every command rendered the same way — `"