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
291 changes: 63 additions & 228 deletions src/cli/Cli.ts

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions src/io/InputError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Code } from "../shared/codes.js";

/**
* InputError — what {@link InputManager} throws when transport-neutral input fails validation, BEFORE any
* tracer/engine/analysis work begins. It replaces the CLI's old `usage()` (which wrote to stderr and
* `process.exit(2)`) with a structured throw, so every frontend maps the same failure to its own shape:
* • CLI → `usage(message)` → stderr + exit 2 (byte-identical to before),
* • MCP → a tool result with `isError: true`,
* • 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()`);
* these are already redaction-safe (step problems are labelled by index + action, never the raw typed value).
*/
export class InputError extends Error {
readonly code = Code.INPUT;
readonly problems: string[];

constructor(message: string, problems: string[] = []) {
super(message);
this.name = "InputError";
this.problems = problems;
}
}
98 changes: 98 additions & 0 deletions src/io/InputManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { TargetKind } from "../domain/Target.js";
import { EntryReference } from "../codegraph/CodeGraphProvider.js";
import { DEFAULT_NODE_PORT } from "../shared/defaults.js";
import { InputValidator } from "./InputValidator.js";
import type {
RawRunInput, RawGraphInput, RawDepsInput, RawComplexityInput, RawSymbolsInput,
NormalizedRun, GraphRequest, DepsRequest, ComplexityRequest, SymbolsRequest,
} from "./descriptors.js";

const parseIntArg = (value: string) => parseInt(value, 10);

interface PickedTarget { target: TargetKind; port: number; launch: boolean; profileDir?: string; headed?: boolean; }
function pickTarget(options: RawRunInput): PickedTarget {
// A named --chrome-profile selects Chrome and implies launching it (a profile can only be grafted onto a
// browser we spawn), even without --chrome; bare --chrome (no port) launches a throwaway, a port attaches.
if (options.chrome != null || options.chromeProfile) {
const profileDir: string | undefined = options.chromeProfile || undefined;
const launch = profileDir != null || options.chrome === true;
const headed = options.headed === true || profileDir != null; // a logged-in profile is shown so you can watch/intervene
return { target: TargetKind.Chrome, port: launch ? 0 : parseIntArg(options.chrome as string), launch, ...(profileDir ? { profileDir } : {}), headed };
}
return { target: TargetKind.Node, port: options.node === undefined || options.node === true ? DEFAULT_NODE_PORT : parseIntArg(options.node as unknown as string), launch: false };
}

// 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 redactStep = (step: string) => step.startsWith("type:") ? step.replace(/=.*/s, "=***") : step.startsWith("eval:") ? "eval:***" : step;

/**
* InputManager — the input tier. Accepts a transport-neutral, already-parsed input object (NOT argv) and turns
* it into a typed command request the ProcessingManager can run. It owns the NORMALIZATION — `pickTarget`, the
* `--url`→`goto:` step assembly, the secret redaction of typed steps, and the `meta.args` shaping — and delegates
* the VALIDATION (the guards, the strict DTO checks, the step vocabulary) to {@link InputValidator}, which throws
* {@link InputError} so the CLI maps it to exit-2 `usage()` and MCP/HTTP map it to their own error shapes.
*/
export class InputManager {
#validator = new InputValidator();

/** Validate + normalize a `run` invocation into a dynamic 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.validateSteps(steps);

const request: NormalizedRun["request"] = {
target, port, launch, profileDir, headed,
breakpoints: raw.breakpoint, exprs: raw.expression,
steps, curl: raw.curl,
root: raw.root, maxHits: raw.maxHits,
recordOut: raw.output,
args: { target, ...(launch ? { launch: true } : { port }), ...(profileDir ? { profile: profileDir } : {}), ...(headed && !profileDir ? { headed: true } : {}), breakpoints: raw.breakpoint, ...(raw.root ? { root: raw.root } : {}), ...(raw.maxHits ? { maxHits: raw.maxHits } : {}), ...(steps.length ? { steps: steps.map(redactStep) } : {}), ...(raw.curl ? { curl: raw.curl } : {}) },
};
return { request, emit: raw.emit };
}

/** Validate + normalize a `graph` invocation: an entry anchor (file:line[:column] or file@symbol) + depth. */
acceptGraph(raw: RawGraphInput): GraphRequest {
const entry = EntryReference.parse(raw.entry);
this.#validator.validateGraph({ file: entry.file, line: entry.line, column: entry.column, symbol: entry.symbol, depth: raw.depth });
return {
entry,
root: raw.root, // optional — GraphCommand auto-detects the project root from the entry when absent
maxDepth: raw.depth,
server: raw.server,
args: { entry: raw.entry, ...(raw.root ? { root: raw.root } : {}), ...(raw.server ? { server: raw.server } : {}), depth: raw.depth },
};
}

/** Normalize a `deps` invocation (a file or directory to walk). */
acceptDeps(raw: RawDepsInput): DepsRequest {
this.#validator.requireDepsEntry(raw.entry);
return {
entry: raw.entry,
root: raw.root,
extensions: raw.extensions,
tsConfig: raw.tsconfig,
exclude: raw.exclude,
args: { entry: raw.entry, ...(raw.root ? { root: raw.root } : {}) },
};
}

/** Normalize a `complexity` invocation (default path: the current directory). No validation gate. */
acceptComplexity(raw: RawComplexityInput): ComplexityRequest {
const path = raw.path || ".";
return { path, root: raw.root, args: { path, ...(raw.root ? { root: raw.root } : {}) } };
}

/** Normalize a `symbols` invocation (a single source file to outline). */
acceptSymbols(raw: RawSymbolsInput): SymbolsRequest {
this.#validator.requireSymbolsFile(raw.file);
return { file: raw.file, root: raw.root, args: { file: raw.file, ...(raw.root ? { root: raw.root } : {}) } };
}
}
70 changes: 70 additions & 0 deletions src/io/InputValidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { TargetKind } from "../domain/Target.js";
import { DynamicInput, 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 {
target: TargetKind; port: number; launch?: boolean; profileDir?: string; headed?: boolean;
breakpoints: string[]; exprs: string[]; steps: string[]; curl?: string;
}
/** A graph entry anchor: a file plus a 1-based line (optional column) or a symbol. */
export interface GraphFields { file: string; line?: number; column?: number; symbol?: string; depth?: number; }

/**
* InputValidator — the validation half of the input tier, split out of {@link InputManager} so the RULES (what
* makes an invocation legal) live apart from the NORMALIZATION (turning flags into a request). Every method
* throws {@link InputError} on the first violation it finds — with the exact human wording each frontend
* surfaces (the CLI as exit-2, MCP/HTTP as their own error shapes) — and returns void when the input is clean.
* Stateless; the rules are the only thing here.
*/
export class InputValidator {
/** `run` flag-combination guards that depend only on the raw flags (target + verbosity mutual exclusion). */
guardRunFlags(raw: RawRunInput): void {
if (raw.chrome != null && raw.node != null) throw new InputError("pick one target: --node or --chrome, not both");
if (raw.chromeProfile && raw.node != null) throw new InputError("--chrome-profile is a chrome option — don't combine it with --node");
// --chrome-profile launches a browser on that profile; an explicit --chrome <port> means attach to a running one.
if (raw.chromeProfile && typeof raw.chrome === "string") throw new InputError("pick one: --chrome-profile launches a logged-in browser, or --chrome <port> attaches to a running one — not both");
if (raw.headed && !(raw.chrome != null || raw.chromeProfile)) throw new InputError("--headed only applies when launching Chrome (use with --chrome or --chrome-profile)");
if (raw.concise && raw.detailed) throw new InputError("pick one envelope verbosity: --concise or --detailed, not both");
}

/** `run` trigger guards that depend on the resolved target + the assembled journey steps. */
guardRunTrigger(raw: RawRunInput, resolved: { target: TargetKind; isChrome: boolean; steps: string[] }): void {
const { target, isChrome, steps } = resolved;
if (!raw.breakpoint.length) throw new InputError("run needs at least one --breakpoint (file:line or file@substring)");
if (isChrome && !steps.length) throw new InputError("chrome target needs --url or at least one --step");
if (isChrome && raw.curl) throw new InputError("--curl is a node-only trigger (chrome uses --url/--step)");
if (!isChrome && raw.step.length) throw new InputError("--step is a chrome-only trigger (node uses --curl)");
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();
if (problems.length) throw new InputError(`invalid input — ${problems.join("; ")}`, problems);
}

/**
* Strict step-vocabulary validation: reject an unknown action (`--step frobnicate:x`) or a missing required
* arg before any browser work, so the failure names the allowed verbs instead of silently no-op'ing.
*/
validateSteps(steps: string[]): void {
const problems = validateSteps(steps);
if (problems.length) throw new InputError(`invalid step — ${problems.join("; ")}`, problems);
}

/** Strict validation of a graph entry anchor (a file plus a line or a symbol). */
validateGraph(fields: GraphFields): void {
const problems = new GraphInput(fields).validate();
if (problems.length) throw new InputError(`invalid input — ${problems.join("; ")}`, problems);
}

/** Presence guards for the commands whose only input gate is "the required argument is there". */
requireDepsEntry(entry?: string): void {
if (!entry) throw new InputError("deps needs --entry <file|dir>");
}
requireSymbolsFile(file?: string): void {
if (!file) throw new InputError("symbols needs a <file>");
}
}
78 changes: 78 additions & 0 deletions src/io/OutputManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomUUID } from "node:crypto";

import { OutputValidator } from "./OutputValidator.js";
import type { ProcessingResult, OutputOptions, OutputResult, FileOutput, OutputLog } from "./descriptors.js";

/**
* 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<string, unknown>): Record<string, unknown> {
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;
}

/**
* OutputManager — the output tier. Turns a finished {@link ProcessingResult} into an {@link OutputResult}
* descriptor: it runs the envelope-contract gate (delegated to {@link OutputValidator}), applies the `--concise`
* transform, chooses the human-vs-JSON representation, and computes the contents of any `--json <path>` /
* `--html [path]` files — but writes NOTHING and never calls `process.exit`. The thin frontend adapter performs
* the actual I/O, so the same logic serves stdout+exit on the CLI, a JSON body on HTTP, and tool content on MCP.
*/
export class OutputManager {
#validator = new OutputValidator();

/** Gate, render, and package one trace-producing result. Mutates `result.trace` (the schema gate pushes
* diagnostics + recomputes `ok`) so a subsequent collector forward sees the same gated envelope. */
emit(result: ProcessingResult, options: OutputOptions = {}): OutputResult {
const { trace } = result;
this.#validator.gate(trace); // enforce the envelope contract before it leaves the process

const envelope = options.concise ? condense(trace.toJSON()) : trace.toJSON();
const files: FileOutput[] = [];
const logs: OutputLog[] = [];

// emit policy: bare --json → JSON to stdout; --json <path> → file (stdout stays human); else human.
if (typeof options.json === "string") {
files.push({ path: options.json, contents: JSON.stringify(envelope, null, 2) });
logs.push({ message: "envelope written", data: { path: options.json } });
}
const stdout = options.json === true ? JSON.stringify(envelope, null, 2) : result.render();

// --html [path] → also write the interactive diagram (graph/deps, which supply renderHtml). Bare flag → a
// temp file; the kind ("graph"/"deps", from `command`) names the temp prefix + the side-log, as before.
if (options.html != null && result.renderHtml) {
const kind = (trace.command.split(".")[0] || "graph");
const htmlPath = typeof options.html === "string" ? options.html : join(tmpdir(), `trace-${kind}-${randomUUID()}.html`);
files.push({ path: htmlPath, contents: result.renderHtml() });
logs.push({ message: `${kind} HTML written`, data: { path: htmlPath } });
}

return { stdout, files, logs, exitCode: trace.hasErrors() ? 1 : 0 };
}

/** A descriptor for the non-Trace commands (schema/manifest): a literal string + exit code, no files/gate. */
text(stdout: string, exitCode = 0): OutputResult {
return { stdout, files: [], logs: [], exitCode };
}
}
19 changes: 19 additions & 0 deletions src/io/OutputValidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Diagnostic } from "../domain/Diagnostic.js";
import { Code } from "../shared/codes.js";
import type { Trace } from "../domain/Trace.js";

/**
* OutputValidator — the validation half of the output tier, split out of {@link OutputManager}. It owns the
* envelope-contract GATE: run the Trace's own structural validation and turn each violation into an E_SCHEMA
* error diagnostic, then recompute `ok` — so a structurally-malformed envelope flips to ok:false (and a non-zero
* exit) instead of shipping silently. Mutates the trace in place (the same instance a later collector forward
* serializes), and returns nothing. Stateless.
*/
export class OutputValidator {
gate(trace: Trace): 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(Code.SCHEMA, problem));
trace.ok = !trace.hasErrors();
}
}
Loading