Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
# open http://localhost:4747
#
# Then point traces at it from the host:
# trace dynamic --node 9229 --bp app.js:42 --curl '…' --emit http://localhost:4747
# trace run --node 9229 --bp app.js:42 --curl '…' --emit http://localhost:4747
#
# The image is the full CLI (ENTRYPOINT `trace`); CMD defaults to `serve`, which launches the standalone
# dashboard. Override to run other subcommands, e.g. docker run trace-cli doctor.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@ trace-cli run --chrome --url http://localhost:5173/route --breakpoint src/pages/
<summary>Library (TypeScript)</summary>

```ts
import { DynamicCommand, Trace } from "trace-cli"; // DynamicCommand powers `trace run`
import { RunCommand, Trace } from "trace-cli"; // RunCommand powers `trace run`

const { trace } = await new DynamicCommand().run({
const { trace } = await new RunCommand().run({
target: "node", port: 9229,
curl: 'curl -s http://127.0.0.1:3100/price?qty=3',
breakpoints: ["test/servers/node-api/server.js:42"],
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
# Emit traces from the host (the CLI runs where your debug target is reachable). Chrome traces record by
# default and upload the video to mock-aws, attaching a link to the trace:
# export S3_ENDPOINT=http://localhost:19000
# TRACE_COLLECTOR_URL=http://localhost:14747 trace dynamic --chrome 9222 --url http://localhost:3000 --bp src/App.tsx:9
# TRACE_COLLECTOR_URL=http://localhost:14747 trace run --chrome 9222 --url http://localhost:3000 --bp src/App.tsx:9
#
# Bringing the stack up runs the whole end-to-end demo too (Node + React + Chrome targets, all in
# one demo container), no host setup — `docker compose up --build` starts everything; `docker compose down` stops it all:
Expand Down
2 changes: 1 addition & 1 deletion docs/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ trace schema # print the JSON Schema (the contract)
src/
cli.js # commander root; registers subcommands
commands/
dynamic.js # wraps engine/trace.js → envelope (today's behavior, incl. auto-recorded Chrome replay)
run.js # wraps engine/trace.js → envelope (today's behavior, incl. auto-recorded Chrome replay)
static.js # deps | complexity | symbols | search dispatch
exec.js # otel-cli exec
spans.js # otel store query
Expand Down
21 changes: 12 additions & 9 deletions src/cli/Cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import { InputManager } from "../io/InputManager.js";
import { InputError } from "../io/InputError.js";
import { ProcessingManager, EngineAbortError } from "../io/ProcessingManager.js";
import { OutputManager } from "../io/OutputManager.js";
import type { ProcessingResult, OutputResult } from "../io/descriptors.js";
import type {
ProcessingResult, OutputResult, OutputOptions,
RawRunInput, RawGraphInput, RawDepsInput, RawComplexityInput, RawSymbolsInput,
} from "../io/descriptors.js";
import { VERSION } from "../shared/version.js";
import { DEFAULT_NODE_PORT, DEFAULT_COLLECTOR_PORT } from "../shared/defaults.js";
import { logger } from "../shared/logger.js";
Expand Down Expand Up @@ -43,20 +46,20 @@ export class Cli {
}

/** Shared tail for the static analyses: render the envelope, forward it to a collector, exit on its error state. */
async #finishStatic(result: ProcessingResult, options: any): Promise<never> {
async #finishStatic(result: ProcessingResult, options: OutputOptions): Promise<never> {
const out = this.#output.emit(result, options);
this.#writeTrace(out);
await this.#processing.forwardStatic(result.trace); // post-gate, explicit-only (TRACE_COLLECTOR_URL)
process.exit(out.exitCode);
}

async #runDynamic(options: any): Promise<void> {
async #runTrace(options: RawRunInput): Promise<void> {
let normalized;
try { normalized = this.#input.acceptRun(options); }
catch (error) { if (error instanceof InputError) usage(error.message); throw error; }

let result: ProcessingResult;
try { result = await this.#processing.runDynamic(normalized); }
try { result = await this.#processing.runTrace(normalized); }
catch (error) {
// The run threw; ProcessingManager already flushed the terminal envelope to the collector and logged the
// ENGINE_FATAL cause. Surface the failure as a non-zero exit, matching the old inline behavior.
Expand All @@ -68,26 +71,26 @@ export class Cli {
process.exit(out.exitCode);
}

async #runGraph(options: any): Promise<void> {
async #runGraph(options: RawGraphInput): Promise<void> {
let request;
try { request = this.#input.acceptGraph(options); }
catch (error) { if (error instanceof InputError) usage(error.message); throw error; }
await this.#finishStatic(await this.#processing.runGraph(request), options);
}

async #runDeps(options: any): Promise<void> {
async #runDeps(options: RawDepsInput): Promise<void> {
let request;
try { request = this.#input.acceptDeps(options); }
catch (error) { if (error instanceof InputError) usage(error.message); throw error; }
await this.#finishStatic(await this.#processing.runDeps(request), options);
}

async #runComplexity(path: string, options: any): Promise<void> {
async #runComplexity(path: string, options: Omit<RawComplexityInput, "path">): Promise<void> {
const request = this.#input.acceptComplexity({ ...options, path });
await this.#finishStatic(await this.#processing.runComplexity(request), options);
}

async #runSymbols(file: string, options: any): Promise<void> {
async #runSymbols(file: string, options: Omit<RawSymbolsInput, "file">): Promise<void> {
let request;
try { request = this.#input.acceptSymbols({ ...options, file }); }
catch (error) { if (error instanceof InputError) usage(error.message); throw error; }
Expand Down Expand Up @@ -119,7 +122,7 @@ export class Cli {
.option("--json [path]", "envelope as JSON: to a file if a path is given, else to stdout")
.option("--concise", "trim the PRINTED --json envelope (stdout/file) 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). Does NOT affect --emit — the collector always receives the full envelope. 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));
.action((options) => this.#runTrace(options));

// 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).
Expand Down
4 changes: 2 additions & 2 deletions src/cli/CommandInputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export function validateSteps(rawSteps: string[]): string[] {
}

/** Input contract for `trace-cli run`. */
export class DynamicInput {
export class RunInput {
@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((input) => !input.launch) @IsInt() @Min(1) @Max(MAX_PORT) port: number;
Expand All @@ -55,7 +55,7 @@ export class DynamicInput {
@IsOptional() @IsArray() @IsString({ each: true }) steps?: string[]; // chrome: the ordered UI journey
@IsOptional() @IsString() curl?: string;

constructor(init: Partial<DynamicInput> = {}) {
constructor(init: Partial<RunInput> = {}) {
this.target = init.target ?? TargetKind.Node;
this.port = init.port ?? 0;
this.breakpoints = init.breakpoints ?? [];
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/DoctorCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export interface ToolStatus { name: string; pillar: string; purpose: string; pre
const TOOLS: ToolDef[] = [
{ name: "node", pillar: "engine", purpose: "Node --inspect (CDP) target", cmd: "node", args: ["--version"] },
{ name: "chrome", pillar: "frontend", purpose: "Chrome target / recording frames", chrome: true },
{ name: "ffmpeg", pillar: "frontend", purpose: "dynamic --record video", cmd: "ffmpeg", args: ["-version"] },
{ name: "ffmpeg", pillar: "frontend", purpose: "run --record video", cmd: "ffmpeg", args: ["-version"] },
{ name: "rg", pillar: "static", purpose: "static search (ripgrep)", cmd: "rg", args: ["--version"] },
{ 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"] },
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/GraphCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export interface GraphRequest {
* graph rooted at the entry, and normalize it into one Trace envelope (`data.graph`). The provider is the
* injected collaborator (Dependency Inversion); this class owns the use-case and the envelope, not the analysis.
* A resolution/analysis failure becomes an error diagnostic on a still-well-formed envelope, matching how the
* dynamic command surfaces engine failures — an agent always gets back a Trace.
* run command surfaces engine failures — an agent always gets back a Trace.
*/
export class GraphCommand extends TraceCommand<GraphRequest> {
async run(request: GraphRequest): Promise<Trace> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ import { logger } from "../../shared/logger.js";
import { Code } from "../../shared/codes.js";
import { TraceCommand } from "./TraceCommand.js";

const log = logger.child({ component: "dynamic" });
const log = logger.child({ component: "run" });

export type DynamicTargetKind = TargetKind;
export type RunTargetKind = TargetKind;

export interface DynamicRequest extends TraceOptions {
target: DynamicTargetKind;
export interface RunRequest extends TraceOptions {
target: RunTargetKind;
launch?: boolean; // chrome: spawn a throwaway headless Chrome instead of attaching to `port`
profileDir?: string; // chrome: launch on a persistent --user-data-dir (a real, logged-in profile)
headed?: boolean; // chrome: launch the browser visibly instead of headless
Expand All @@ -35,25 +35,25 @@ export interface DynamicRequest extends TraceOptions {
onProgress?: (trace: Trace) => void;
}

export interface DynamicResult { trace: Trace; capture: CaptureResult; }
export interface RunResult { trace: Trace; capture: CaptureResult; }

/** Context shared by the running (partial) and final envelopes of one trace run. */
interface RunCtx { sessionId: string; target: TargetKind; trigger: string | null; args: Record<string, unknown>; startedAtMs: number; }

/**
* DynamicCommand — orchestrates a breakpoint trace: pick the tracer by target, run it, normalize the
* RunCommand — orchestrates a breakpoint trace: pick the tracer by target, run it, normalize the
* capture into a Trace (lineage + diagnostics), and (for Chrome) record + upload the replay. Collaborators
* are injected (Tracer, ArtifactStore) — Dependency Inversion; this class owns the use-case, not the IO.
*/
export class DynamicCommand extends TraceCommand<DynamicRequest, DynamicResult> {
export class RunCommand extends TraceCommand<RunRequest, RunResult> {
constructor(
private readonly tracer: Tracer = new Tracer(),
private readonly artifacts?: ArtifactStore,
) {
super();
}

async run(request: DynamicRequest): Promise<DynamicResult> {
async run(request: RunRequest): Promise<RunResult> {
const startedAtMs = this.started();
const sessionId = request.sessionId ?? randomUUID();
const isChrome = request.target === TargetKind.Chrome;
Expand Down Expand Up @@ -172,7 +172,7 @@ export class DynamicCommand extends TraceCommand<DynamicRequest, DynamicResult>
});
}

/** Human view of a dynamic trace: the breakpoint/timeline render plus the lineage panel. */
/** Human view of a breakpoint trace: the breakpoint/timeline render plus the lineage panel. */
render(trace: Trace): string {
return Renderer.render(trace) + Renderer.renderLineage(trace.data.lineage);
}
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/ServeCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export class ServeCommand extends CliCommand<ServeOptions, void> {
// default); on any other --port a run must point at this URL explicitly, so don't make the user guess it.
const collectorUrl = `http://localhost:${port}`;
log.info("starting dashboard", { url: collectorUrl, host });
log.info("stream traces here", { hint: `trace dynamic … --emit ${collectorUrl} (or: export TRACE_COLLECTOR_URL=${collectorUrl})` });
log.info("stream traces here", { hint: `trace run … --emit ${collectorUrl} (or: export TRACE_COLLECTOR_URL=${collectorUrl})` });
const child = spawn(process.execPath, [SERVER_ENTRY], {
stdio: "inherit",
env: { ...process.env, PORT: String(port), HOSTNAME: host, DATABASE_URL: databaseUrl },
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/ShellAnalysisCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export interface AnalysisOutcome {
* 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 `<errorCode>` error on a still-well-formed Trace, honouring
* the same "an agent always gets a Trace" contract the dynamic/graph commands keep.
* the same "an agent always gets a Trace" contract the run/graph commands keep.
*/
export abstract class ShellAnalysisCommand<Req extends { args?: Record<string, unknown> }> extends TraceCommand<Req> {
/** Binary to spawn, e.g. `"madge"`. */ protected abstract readonly tool: string;
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/TraceCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export interface Envelope {

/**
* TraceCommand — the {@link CliCommand} specialization for every command that produces a Trace envelope
* (dynamic, graph, doctor). Each subclass assembles only its own `data` payload + diagnostics; the base owns
* (run, graph, doctor). Each subclass assembles only its own `data` payload + diagnostics; the base owns
* the part they all repeated verbatim — stamping version, timestamp and duration, and deriving `ok` from the
* diagnostics — so the envelope shape stays identical across commands and lives in exactly one place.
* `render` is the human view each trace-producing command must supply (what prints when `--json` is off).
Expand Down
2 changes: 1 addition & 1 deletion src/engine/Tracer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ export class Tracer {
const parsedSteps = steps.map((step) => JourneyRunner.parseStep(step));
const screencaster = new Screencaster(CAPTURE_VIEWPORT); // portrait-ish: fills the replay's left pane, no letterbox
const config: TraceConfig = { bps: BreakpointResolver.resolveAll(breakpoints, root), root, exprs, frames, maxHits, onEvent: options.onEvent };
// Wrap the running browser (DynamicCommand already launched/attached it) as a session so target discovery
// Wrap the running browser (RunCommand already launched/attached it) as a session so target discovery
// goes through the bridge, not raw CdpDriver calls.
const runner = new JourneyRunner(ChromeLauncher.attach(port), screencaster, config);
let stepResults: StepResult[] = [];
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ export { PostgresSessionStore } from "./collector/PostgresSessionStore.js";
export { createSessionStore } from "./collector/createSessionStore.js";
export type { StoreOptions } from "./collector/createSessionStore.js";
export type { SessionStore, SessionSummary } from "./collector/SessionStore.js";
export { DynamicCommand } from "./cli/commands/DynamicCommand.js";
export { RunCommand } from "./cli/commands/RunCommand.js";
export type { RunRequest, RunResult, RunTargetKind } from "./cli/commands/RunCommand.js";
export { DoctorCommand } from "./cli/commands/DoctorCommand.js";
export { ExportSkillCommand } from "./cli/commands/ExportSkillCommand.js";
export { Cli } from "./cli/Cli.js";
Expand Down
2 changes: 1 addition & 1 deletion src/io/InputError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Code } from "../shared/codes.js";
* • HTTP → `400 { error, code, problems }`.
*
* `message` is the single human line (the exact string the CLI prints). `problems` carries the individual
* validation lines when the error aggregates several (a failed `DynamicInput.validate()` / `validateSteps()`);
* validation lines when the error aggregates several (a failed `RunInput.validate()` / `validateSteps()`);
* these are already redaction-safe (step problems are labelled by index + action, never the raw typed value).
*/
export class InputError extends Error {
Expand Down
4 changes: 2 additions & 2 deletions src/io/InputManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,15 @@ const redactStep = (step: string) => step.startsWith("type:") ? step.replace(/=.
export class InputManager {
#validator = new InputValidator();

/** Validate + normalize a `run` invocation into a dynamic request + the collector (`--emit`) policy. */
/** Validate + normalize a `run` invocation into a run request + the collector (`--emit`) policy. */
acceptRun(raw: RawRunInput): NormalizedRun {
this.#validator.guardRunFlags(raw);
const { target, port, launch, profileDir, headed } = pickTarget(raw);
const isChrome = target === TargetKind.Chrome;
// Chrome trigger = an ordered UI journey; --url is shorthand for a leading `goto:`. Node trigger = a curl.
const steps: string[] = isChrome ? [...(raw.url ? [`goto:${raw.url}`] : []), ...raw.step] : [];
this.#validator.guardRunTrigger(raw, { target, isChrome, steps });
this.#validator.validateDynamic({ target, port, launch, profileDir, headed, breakpoints: raw.breakpoint, exprs: raw.expression, steps, curl: raw.curl });
this.#validator.validateRun({ target, port, launch, profileDir, headed, breakpoints: raw.breakpoint, exprs: raw.expression, steps, curl: raw.curl });
this.#validator.validateSteps(steps);

const request: NormalizedRun["request"] = {
Expand Down
12 changes: 6 additions & 6 deletions src/io/InputValidator.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { TargetKind } from "../domain/Target.js";
import { DynamicInput, GraphInput, validateSteps } from "../cli/CommandInputs.js";
import { RunInput, GraphInput, validateSteps } from "../cli/CommandInputs.js";
import { InputError } from "./InputError.js";
import type { RawRunInput } from "./descriptors.js";

/** The normalized dynamic fields the strict DTO validation needs (target + trigger, post-`pickTarget`). */
export interface DynamicFields {
/** The normalized run fields the strict DTO validation needs (target + trigger, post-`pickTarget`). */
export interface RunFields {
target: TargetKind; port: number; launch?: boolean; profileDir?: string; headed?: boolean;
breakpoints: string[]; exprs: string[]; steps: string[]; curl?: string;
}
Expand Down Expand Up @@ -39,9 +39,9 @@ export class InputValidator {
if (!isChrome && !raw.curl) throw new InputError(`${target} target needs --curl`);
}

/** Strict DTO validation of the normalized dynamic input (the class-validator regime). */
validateDynamic(fields: DynamicFields): void {
const problems = new DynamicInput(fields).validate();
/** Strict DTO validation of the normalized run input (the class-validator regime). */
validateRun(fields: RunFields): void {
const problems = new RunInput(fields).validate();
if (problems.length) throw new InputError(`invalid input — ${problems.join("; ")}`, problems);
}

Expand Down
Loading