From 9f344c92b155e8161d755875f363adcaeb4119cb Mon Sep 17 00:00:00 2001 From: burrows99 Date: Fri, 19 Jun 2026 11:42:45 +0100 Subject: [PATCH] refactor(io): extract a shared Input/Processing/Output manager tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The use-case commands were already transport-neutral, but everything around them — accepting/validating input, orchestrating the run (collector + streaming + abort handling), and shaping output — lived welded inside the 398-line Cli, coupled to process.stdout/process.exit/writeFileSync. No second frontend (MCP, HTTP, …) could reuse it. Introduce src/io/, a middle tier the CLI (and any future frontend) sits on: raw → InputManager.accept* → ProcessingManager.run* → OutputManager.emit - InputManager: pickTarget, --url→goto step assembly, secret redaction, meta.args shaping. Delegates the RULES to InputValidator (the guards, the strict DTO/step/graph checks), which throws InputError instead of exiting. - ProcessingManager: collector resolve + serialized emit chain + onProgress + the abort flush (EngineAbortError) + the static explicit-only forward. Owns emitFailureMessage. No stdout/exit. - OutputManager: condense + render-vs-JSON + --json/--html file CONTENTS + exit code; the schema gate is delegated to OutputValidator. Returns a descriptor; writes nothing. - Cli is now a thin adapter: argv → managers → stdout/files/exit, mapping InputError→usage()/exit 2 and EngineAbortError→exit 1. build()/run() and the command surface are unchanged; condense/emitFailureMessage are re-exported so existing import points keep resolving. Behavior-preserving: the full pre-existing suite passes with no test edits. Adds Code.INPUT for the structured input error. New unit tests cover the tier directly (input guards + the security-critical step redaction, output descriptors + schema gate, processing abort/emit-folding/forward, and both validators). 77 tests, 76 pass, 1 skip (Postgres needs a live DB). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/cli/Cli.ts | 291 ++++++++---------------------------- src/io/InputError.ts | 24 +++ src/io/InputManager.ts | 98 ++++++++++++ src/io/InputValidator.ts | 70 +++++++++ src/io/OutputManager.ts | 78 ++++++++++ src/io/OutputValidator.ts | 19 +++ src/io/ProcessingManager.ts | 142 ++++++++++++++++++ src/io/descriptors.ts | 118 +++++++++++++++ src/io/index.ts | 16 ++ src/shared/codes.ts | 6 + test/io-input.test.js | 138 +++++++++++++++++ test/io-output.test.js | 67 +++++++++ test/io-processing.test.js | 84 +++++++++++ test/io-validators.test.js | 70 +++++++++ 14 files changed, 993 insertions(+), 228 deletions(-) create mode 100644 src/io/InputError.ts create mode 100644 src/io/InputManager.ts create mode 100644 src/io/InputValidator.ts create mode 100644 src/io/OutputManager.ts create mode 100644 src/io/OutputValidator.ts create mode 100644 src/io/ProcessingManager.ts create mode 100644 src/io/descriptors.ts create mode 100644 src/io/index.ts create mode 100644 test/io-input.test.js create mode 100644 test/io-output.test.js create mode 100644 test/io-processing.test.js create mode 100644 test/io-validators.test.js diff --git a/src/cli/Cli.ts b/src/cli/Cli.ts index 6616268..4f533a6 100644 --- a/src/cli/Cli.ts +++ b/src/cli/Cli.ts @@ -1,261 +1,97 @@ 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"; -import { DepsCommand } from "./commands/DepsCommand.js"; -import { ComplexityCommand } from "./commands/ComplexityCommand.js"; -import { SymbolsCommand } from "./commands/SymbolsCommand.js"; -import { DoctorCommand } from "./commands/DoctorCommand.js"; -import { ExportSkillCommand } from "./commands/ExportSkillCommand.js"; import { ManifestCommand } from "./commands/ManifestCommand.js"; import { SchemaCommand } from "./commands/SchemaCommand.js"; import { ServeCommand } from "./commands/ServeCommand.js"; -import { Tracer } from "../engine/Tracer.js"; -import { Collector, type EmitResult } from "../collector/Collector.js"; -import { S3ArtifactStore } from "../storage/S3ArtifactStore.js"; +import { ExportSkillCommand } from "./commands/ExportSkillCommand.js"; +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 { VERSION } from "../shared/version.js"; -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 { EntryReference } from "../codegraph/CodeGraphProvider.js"; import { logger } from "../shared/logger.js"; -import { Code } from "../shared/codes.js"; -import type { Trace } from "../domain/Trace.js"; + +// The output-policy helpers live in the IO tier now; re-export them from here so the long-standing import point +// (`trace-cli/dist/cli/Cli.js`, used by the output tests) keeps resolving. +export { condense } from "../io/OutputManager.js"; +export { emitFailureMessage } from "../io/ProcessingManager.js"; const log = logger.child({ component: "cli" }); 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); }; -interface PickedTarget { target: DynamicTargetKind; port: number; launch: boolean; profileDir?: string; headed?: boolean; } -function pickTarget(options: any): 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), launch, ...(profileDir ? { profileDir } : {}), headed }; - } - 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). + * Cli — the commander shell. Thin: it maps flags onto the IO tier (InputManager → ProcessingManager → + * OutputManager) and owns only the things a terminal frontend owns — stderr/exit-2 on bad input (`usage`), + * writing stdout / `--json` / `--html` files, and the process exit code. The tier is shared verbatim with the + * MCP and HTTP frontends; the use-cases themselves live in the command objects. */ -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); - } +export class Cli { + #input = new InputManager(); + #processing = new ProcessingManager(); + #output = new OutputManager(); + + /** Write a finished output descriptor to the terminal: any files, then stdout, then the side logs. */ + #writeTrace(out: OutputResult): void { + for (const file of out.files) writeFileSync(file.path, file.contents); + process.stdout.write(out.stdout + "\n"); + for (const line of out.logs) log.info(line.message, line.data); } - return json; -} -/** - * emitFailureMessage — the end-of-run diagnostic for collector emit failures. An HTTP status means the collector - * received the request and rejected it; no status means the POST never landed (connection refused/timeout/DNS), - * so word each distinctly rather than calling both "rejected". `count` is the total failed emits this run; `last` - * is the most recent failure (whose reason is shown). Extracted so the wording/count stay unit-testable. - */ -export function emitFailureMessage(collector: string, count: number, last: EmitResult): string { - return last.status - ? `collector ${collector} rejected ${count} emit(s): HTTP ${last.status}${last.body ? ` — ${last.body.slice(0, 200)}` : ""}` - : `${count} emit(s) to collector ${collector} failed: ${last.error ?? "unknown error"}`; -} - -/** emit policy: bare --json → JSON to stdout; --json → file (stdout stays human); else human. */ -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(Code.SCHEMA, problem)); - trace.ok = !trace.hasErrors(); - 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 }); -} - -/** - * Cli — the commander shell. Thin: it maps flags onto command objects (DynamicCommand/DoctorCommand/…), - * which own the use-cases. Output (stdout/--json/--emit) and exit codes live here. - */ -export class Cli { - #dynamic = new DynamicCommand(new Tracer(), new S3ArtifactStore()); + /** 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 { + 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 { - if (options.chrome != null && options.node != null) usage("pick one target: --node or --chrome, not both"); - if (options.chromeProfile && options.node != null) usage("--chrome-profile is a chrome option — don't combine it with --node"); - // --chrome-profile launches a browser on that profile; an explicit --chrome means attach to a running one. - if (options.chromeProfile && typeof options.chrome === "string") usage("pick one: --chrome-profile launches a logged-in browser, or --chrome attaches to a running one — not both"); - if (options.headed && !(options.chrome != null || options.chromeProfile)) usage("--headed only applies when launching Chrome (use with --chrome or --chrome-profile)"); - if (options.concise && options.detailed) usage("pick one envelope verbosity: --concise or --detailed, not both"); - const { target, port, launch, profileDir, headed } = pickTarget(options); - const isChrome = target === TargetKind.Chrome; - 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 ? [...(options.url ? [`goto:${options.url}`] : []), ...options.step] : []; - if (isChrome && !steps.length) usage("chrome target needs --url or at least one --step"); - 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, profileDir, headed, breakpoints: options.breakpoint, exprs: options.expression, steps, curl: options.curl }); - const badInput = input.validate(); - if (badInput.length) usage(`invalid input — ${badInput.join("; ")}`); - - // Strict step vocabulary: 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 in the runner. - const badSteps = validateSteps(steps); - if (badSteps.length) usage(`invalid step — ${badSteps.join("; ")}`); - - // 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; - - // Stream to the collector. An explicit --emit / TRACE_COLLECTOR_URL wins; otherwise we auto-discover a - // collector listening locally, so a running `trace serve` dashboard catches the run with zero config. - // 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 = await Collector.resolve(options.emit); - let emitChain: Promise = Promise.resolve(); - // Only the count and the most recent failure are surfaced, so keep just those — not every failed result. - // onProgress can emit on a hot path, and retaining each failure would grow memory without bound. - let emitFailureCount = 0; - let lastEmitFailure: EmitResult | undefined; - const emitToCollector = collector - ? (envelope: unknown) => { emitChain = emitChain.then(async () => { const result = await Collector.emit(collector, envelope); if (!result.ok) { emitFailureCount++; lastEmitFailure = result; } }); } - : undefined; - - let trace: Trace; - try { - ({ trace } = await this.#dynamic.run({ - target, port, launch, profileDir, headed, - 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 }), ...(profileDir ? { profile: profileDir } : {}), ...(headed && !profileDir ? { headed: true } : {}), 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()) } : {}), - })); - } catch (error) { - // The run threw (attach failed, engine crashed, recording threw). It already emitted a TERMINAL envelope - // via onProgress that clears the dashboard's "running" session — flush the chain so that POST actually - // lands before we exit, then surface the failure (non-zero exit + the same ENGINE_FATAL code in the log). - if (emitToCollector) await emitChain; - log.error("dynamic trace aborted before completion", { code: Code.ENGINE_FATAL, err: error }); - process.exit(1); - } - - // Flush the final (complete) envelope and all pending emits BEFORE rendering, so a rejected emit - // (a 400 schema error, a 503 dead store) becomes a visible diagnostic in the printed/--json envelope - // instead of vanishing into an info log — the gap that sent a debugging loop chasing the wrong cause. - if (emitToCollector) { - emitToCollector(trace.toJSON()); - await emitChain; - if (lastEmitFailure && collector) { - trace.diagnostics.push(Diagnostic.warn(Code.EMIT, emitFailureMessage(collector, emitFailureCount, lastEmitFailure))); - } + 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); } + 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. + if (error instanceof EngineAbortError) process.exit(1); + throw error; } - emit(trace, () => this.#dynamic.render(trace), options); - process.exit(trace.hasErrors() ? 1 : 0); + const out = this.#output.emit(result, options); + this.#writeTrace(out); + process.exit(out.exitCode); } 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 command = new GraphCommand(); - const trace = await command.run({ - entry, - 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, () => 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 }); - } - // Static analyses carry no sessionId, so the session dashboard can't ingest them — emit only when a - // collector is explicitly configured (no auto-discovery here, unlike a dynamic trace run). - 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, options: any): Promise { - emit(trace, render, options); - const collector = process.env.TRACE_COLLECTOR_URL; // static: explicit-only (no sessionId → dashboard can't ingest) - if (collector) await Collector.emit(collector, trace.toJSON()); - process.exit(trace.hasErrors() ? 1 : 0); + 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 { - 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; // static: explicit-only (no sessionId → dashboard can't ingest) - if (collector) await Collector.emit(collector, trace.toJSON()); - process.exit(trace.hasErrors() ? 1 : 0); + 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 { - 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); + const request = this.#input.acceptComplexity({ ...options, path }); + await this.#finishStatic(await this.#processing.runComplexity(request), options); } 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); + let request; + try { request = this.#input.acceptSymbols({ ...options, file }); } + catch (error) { if (error instanceof InputError) usage(error.message); throw error; } + await this.#finishStatic(await this.#processing.runSymbols(request), options); } build(): Command { @@ -326,10 +162,9 @@ export class Cli { .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 (options) => { - const command = new DoctorCommand(); - const trace = await command.run(); - emit(trace, () => command.render(trace), options); - process.exit(0); + const result = await this.#processing.runDoctor(); + this.#writeTrace(this.#output.emit(result, options)); + process.exit(0); // missing tools are warnings — doctor itself always succeeds }); program.command("serve") diff --git a/src/io/InputError.ts b/src/io/InputError.ts new file mode 100644 index 0000000..64fa130 --- /dev/null +++ b/src/io/InputError.ts @@ -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; + } +} diff --git a/src/io/InputManager.ts b/src/io/InputManager.ts new file mode 100644 index 0000000..6f207ef --- /dev/null +++ b/src/io/InputManager.ts @@ -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 } : {}) } }; + } +} diff --git a/src/io/InputValidator.ts b/src/io/InputValidator.ts new file mode 100644 index 0000000..6bb00f8 --- /dev/null +++ b/src/io/InputValidator.ts @@ -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 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 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 "); + } + requireSymbolsFile(file?: string): void { + if (!file) throw new InputError("symbols needs a "); + } +} diff --git a/src/io/OutputManager.ts b/src/io/OutputManager.ts new file mode 100644 index 0000000..ce07096 --- /dev/null +++ b/src/io/OutputManager.ts @@ -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): 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; +} + +/** + * 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 ` / + * `--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 → 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 }; + } +} diff --git a/src/io/OutputValidator.ts b/src/io/OutputValidator.ts new file mode 100644 index 0000000..16e6656 --- /dev/null +++ b/src/io/OutputValidator.ts @@ -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(); + } +} diff --git a/src/io/ProcessingManager.ts b/src/io/ProcessingManager.ts new file mode 100644 index 0000000..a510560 --- /dev/null +++ b/src/io/ProcessingManager.ts @@ -0,0 +1,142 @@ +import { Tracer } from "../engine/Tracer.js"; +import { S3ArtifactStore } from "../storage/S3ArtifactStore.js"; +import { Collector, type EmitResult } from "../collector/Collector.js"; +import { DynamicCommand } from "../cli/commands/DynamicCommand.js"; +import { GraphCommand } from "../cli/commands/GraphCommand.js"; +import { DepsCommand } from "../cli/commands/DepsCommand.js"; +import { ComplexityCommand } from "../cli/commands/ComplexityCommand.js"; +import { SymbolsCommand } from "../cli/commands/SymbolsCommand.js"; +import { DoctorCommand } from "../cli/commands/DoctorCommand.js"; +import { Diagnostic } from "../domain/Diagnostic.js"; +import type { Trace } from "../domain/Trace.js"; +import { logger } from "../shared/logger.js"; +import { Code } from "../shared/codes.js"; +import type { + NormalizedRun, ProcessingResult, GraphRequest, DepsRequest, ComplexityRequest, SymbolsRequest, +} from "./descriptors.js"; + +const log = logger.child({ component: "processing" }); + +/** + * EngineAbortError — thrown by ProcessingManager.runDynamic when the dynamic run threw (attach failed, + * engine crashed, recording threw). By this point the terminal envelope has already been streamed to the + * collector and the emit chain flushed, so each frontend just decides the response: the CLI exits 1, HTTP + * answers 500, MCP returns an error tool result. Carries the original cause for logging. + */ +export class EngineAbortError extends Error { + readonly code = Code.ENGINE_FATAL; + constructor(override readonly cause: unknown) { + super(String((cause as Error)?.message ?? cause).split("\n")[0]); + this.name = "EngineAbortError"; + } +} + +/** + * emitFailureMessage — the end-of-run diagnostic for collector emit failures. An HTTP status means the collector + * received the request and rejected it; no status means the POST never landed (connection refused/timeout/DNS), + * so word each distinctly rather than calling both "rejected". `count` is the total failed emits this run; `last` + * is the most recent failure (whose reason is shown). Extracted so the wording/count stay unit-testable. + */ +export function emitFailureMessage(collector: string, count: number, last: EmitResult): string { + return last.status + ? `collector ${collector} rejected ${count} emit(s): HTTP ${last.status}${last.body ? ` — ${last.body.slice(0, 200)}` : ""}` + : `${count} emit(s) to collector ${collector} failed: ${last.error ?? "unknown error"}`; +} + +/** + * ProcessingManager — the orchestration tier. Owns everything that turns a validated request into a finished + * {@link Trace}: resolving the collector, serializing the streaming emit chain, wiring `onProgress`, the abort + * flush, and folding a collector failure into a diagnostic — then running the command. It performs no stdout / + * `process.exit`; it returns a {@link ProcessingResult} the OutputManager + adapter consume. The dynamic command + * is injected (so tests can drive it with a fake tracer); the static commands are cheap to construct per call. + */ +export class ProcessingManager { + constructor( + private readonly dynamic: DynamicCommand = new DynamicCommand(new Tracer(), new S3ArtifactStore()), + ) {} + + /** + * Run a dynamic breakpoint trace. Streams to the collector (an explicit --emit / TRACE_COLLECTOR_URL wins, + * else a locally-running dashboard is auto-discovered). Emits are serialized through one promise chain so a + * slow POST can't land a stale envelope after a newer one. On a run that throws, the terminal envelope was + * already emitted via onProgress — flush the chain so that POST lands, then throw {@link EngineAbortError}. + */ + async runDynamic(normalized: NormalizedRun): Promise { + const collector = await Collector.resolve(normalized.emit); + let emitChain: Promise = Promise.resolve(); + // Only the count and the most recent failure are surfaced, so keep just those — not every failed result. + // onProgress can emit on a hot path, and retaining each failure would grow memory without bound. + let emitFailureCount = 0; + let lastEmitFailure: EmitResult | undefined; + const emitToCollector = collector + ? (envelope: unknown) => { emitChain = emitChain.then(async () => { const result = await Collector.emit(collector, envelope); if (!result.ok) { emitFailureCount++; lastEmitFailure = result; } }); } + : undefined; + + let trace: Trace; + try { + ({ trace } = await this.dynamic.run({ + ...normalized.request, + ...(emitToCollector ? { onProgress: (intermediateTrace: Trace) => emitToCollector(intermediateTrace.toJSON()) } : {}), + })); + } catch (error) { + // The run threw. It already emitted a TERMINAL envelope via onProgress that clears the dashboard's + // "running" session — flush the chain so that POST actually lands, then surface the failure to the caller. + if (emitToCollector) await emitChain; + log.error("dynamic trace aborted before completion", { code: Code.ENGINE_FATAL, err: error }); + throw new EngineAbortError(error); + } + + // Flush the final (complete) envelope and all pending emits BEFORE returning, so a rejected emit (a 400 + // schema error, a 503 dead store) becomes a visible diagnostic in the printed/--json envelope instead of + // vanishing into an info log. The snapshot emitted here is pre-schema-gate (the gate runs later, in the + // OutputManager) and pre-EMIT-diagnostic — matching the long-standing behavior the dashboard relies on. + if (emitToCollector) { + emitToCollector(trace.toJSON()); + await emitChain; + if (lastEmitFailure && collector) { + trace.diagnostics.push(Diagnostic.warn(Code.EMIT, emitFailureMessage(collector, emitFailureCount, lastEmitFailure))); + } + } + return { trace, render: () => this.dynamic.render(trace) }; + } + + async runGraph(request: GraphRequest): Promise { + const command = new GraphCommand(); + const trace = await command.run(request); + return { trace, render: () => command.render(trace), renderHtml: () => command.renderHtml(trace) }; + } + + async runDeps(request: DepsRequest): Promise { + const command = new DepsCommand(); + const trace = await command.run(request); + return { trace, render: () => command.render(trace), renderHtml: () => command.renderHtml(trace) }; + } + + async runComplexity(request: ComplexityRequest): Promise { + const command = new ComplexityCommand(); + const trace = await command.run(request); + return { trace, render: () => command.render(trace) }; + } + + async runSymbols(request: SymbolsRequest): Promise { + const command = new SymbolsCommand(); + const trace = await command.run(request); + return { trace, render: () => command.render(trace) }; + } + + async runDoctor(): Promise { + const command = new DoctorCommand(); + const trace = await command.run(); + return { trace, render: () => command.render(trace) }; + } + + /** + * Forward a finished STATIC envelope to a collector — explicit-only (`TRACE_COLLECTOR_URL`), never the + * dynamic auto-discovery: a static analysis carries no sessionId, so the session dashboard can't ingest it. + * Called by the adapter AFTER the output gate so the collector sees the same gated envelope the CLI printed. + */ + async forwardStatic(trace: Trace): Promise { + const collector = process.env.TRACE_COLLECTOR_URL; + if (collector) await Collector.emit(collector, trace.toJSON()); + } +} diff --git a/src/io/descriptors.ts b/src/io/descriptors.ts new file mode 100644 index 0000000..845653c --- /dev/null +++ b/src/io/descriptors.ts @@ -0,0 +1,118 @@ +import type { Trace } from "../domain/Trace.js"; +import type { DynamicRequest } from "../cli/commands/DynamicCommand.js"; +import type { GraphRequest } from "../cli/commands/GraphCommand.js"; +import type { DepsRequest } from "../cli/commands/DepsCommand.js"; +import type { ComplexityRequest } from "../cli/commands/ComplexityCommand.js"; +import type { SymbolsRequest } from "../cli/commands/SymbolsCommand.js"; + +/** + * descriptors — the transport-neutral data shapes the three IO managers pass between each other and hand to the + * frontend adapters. Raw* types mirror the already-parsed flag/param objects (NOT argv); the managers never see + * a transport. ProcessingResult / OutputResult are descriptors — they carry WHAT to do (text to print, files to + * write, the exit code) but perform no I/O, so the same result drives stdout+exit on the CLI, a JSON body on + * HTTP, or tool content on MCP. + */ + +// ── raw input (mirrors the parsed flags; field names match the commander option camelCase) ──────────────── + +export interface RawRunInput { + node?: number | boolean; + chrome?: number | boolean | string; + chromeProfile?: string; + headed?: boolean; + breakpoint: string[]; + expression: string[]; + root?: string; + maxHits?: number; + curl?: string; + url?: string; + step: string[]; + output?: string; + emit?: string | null; + json?: string | boolean; + concise?: boolean; + detailed?: boolean; +} + +export interface RawGraphInput { + entry: string; + root?: string; + server?: string; + depth: number; + html?: string | boolean; + json?: string | boolean; + concise?: boolean; +} + +export interface RawDepsInput { + entry: string; + root?: string; + extensions?: string; + tsconfig?: string; + exclude?: string; + html?: string | boolean; + json?: string | boolean; + concise?: boolean; +} + +export interface RawComplexityInput { + path?: string; + root?: string; + json?: string | boolean; + concise?: boolean; +} + +export interface RawSymbolsInput { + file: string; + root?: string; + json?: string | boolean; + concise?: boolean; +} + +// ── normalized input (InputManager → ProcessingManager) ──────────────────────────────────────────────────── + +/** The dynamic-run request minus the live streaming sink — ProcessingManager owns and injects `onProgress`. */ +export type RunRequest = Omit; + +/** The dynamic command's normalized form: the validated request plus the resolved collector policy (the raw + * `--emit` value; `Collector.resolve` turns it into an explicit target or auto-discovers a local dashboard). */ +export interface NormalizedRun { + request: RunRequest; + emit?: string | null; +} + +// ── processing result (ProcessingManager → OutputManager / adapter) ───────────────────────────────────────── + +/** The outcome of one use-case run: the finished envelope plus the command's bound render thunks. The thunks + * defer rendering so OutputManager only pays for the representation a given frontend actually emits. */ +export interface ProcessingResult { + trace: Trace; + render: () => string; + renderHtml?: () => string; +} + +// ── output (OutputManager → adapter) ──────────────────────────────────────────────────────────────────────── + +/** The format/destination knobs an adapter forwards from its raw input (the verbosity + sink flags). */ +export interface OutputOptions { + json?: string | boolean; // bare true → JSON to stdout; a string → JSON to that file (stdout stays human) + concise?: boolean; // trim the PRINTED envelope (locals → key names, stack → top frames) + html?: string | boolean; // graph/deps only: also emit the interactive diagram (true → temp file, string → path) +} + +/** A file the adapter should write. OutputManager computes the path + contents; it never touches the disk. */ +export interface FileOutput { path: string; contents: string; } + +/** A side-channel log line the adapter should emit (stderr) after writing — e.g. "envelope written". */ +export interface OutputLog { message: string; data: Record; } + +/** Everything the adapter needs to render one result: the primary text, any files, the side logs, the exit code + * (derived from the envelope's error state AFTER the schema gate). The adapter decides how to deliver each. */ +export interface OutputResult { + stdout: string; + files: FileOutput[]; + logs: OutputLog[]; + exitCode: number; +} + +export type { GraphRequest, DepsRequest, ComplexityRequest, SymbolsRequest }; diff --git a/src/io/index.ts b/src/io/index.ts new file mode 100644 index 0000000..da5c97a --- /dev/null +++ b/src/io/index.ts @@ -0,0 +1,16 @@ +/** + * `trace-cli/io` — the middle tier the CLI, MCP, and HTTP frontends share. Each frontend is a thin adapter: + * it maps its transport (argv / tool params / an HTTP body) onto these three managers and renders the result + * its own way. The managers own input acceptance, run orchestration, and output shaping; they never touch a + * transport, `process.stdout`, or `process.exit`. + */ +export { InputManager } from "./InputManager.js"; +export { InputValidator } from "./InputValidator.js"; +export { InputError } from "./InputError.js"; +export { ProcessingManager, EngineAbortError, emitFailureMessage } from "./ProcessingManager.js"; +export { OutputManager, condense } from "./OutputManager.js"; +export { OutputValidator } from "./OutputValidator.js"; +export type { + RawRunInput, RawGraphInput, RawDepsInput, RawComplexityInput, RawSymbolsInput, + NormalizedRun, RunRequest, ProcessingResult, OutputOptions, OutputResult, FileOutput, OutputLog, +} from "./descriptors.js"; diff --git a/src/shared/codes.ts b/src/shared/codes.ts index 0d3aa49..e1af794 100644 --- a/src/shared/codes.ts +++ b/src/shared/codes.ts @@ -10,6 +10,12 @@ * literals, so the two channels can never silently drift apart. */ export const Code = { + // ── input contract ─────────────────────────────────────────────────────── + /** the caller's input failed validation before any work began (a bad flag combination, a missing required + * argument, an invalid journey step) — the structured counterpart of the CLI's exit-2 `usage()` error, so + * non-CLI frontends (MCP/HTTP) can map an InputError to their own error shape instead of a process exit */ + INPUT: "E_INPUT", + // ── envelope contract ──────────────────────────────────────────────────── /** the outgoing envelope failed its own JSON-Schema validation (a structural bug — should never ship) */ SCHEMA: "E_SCHEMA", diff --git a/test/io-input.test.js b/test/io-input.test.js new file mode 100644 index 0000000..1290192 --- /dev/null +++ b/test/io-input.test.js @@ -0,0 +1,138 @@ +// InputManager tests — the input tier. It accepts a transport-neutral parsed object (NOT argv), throws a +// structured InputError (never process.exit) on bad input, and normalizes the rest into a typed request. +// Pure + synchronous: no engine, no network. Covers the guards, the SECURITY-critical step redaction, and the +// target/args normalization that used to be inlined in Cli.#runDynamic. +import "reflect-metadata"; +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { InputManager } from "../dist/io/InputManager.js"; +import { InputError } from "../dist/io/InputError.js"; +import { TargetKind } from "../dist/domain/Target.js"; +import { Code } from "../dist/shared/codes.js"; + +const im = new InputManager(); +// Commander always supplies the repeatable arrays; mirror that so a raw input is realistic. +const runRaw = (over = {}) => ({ breakpoint: ["app.js:10"], expression: [], step: [], ...over }); +const thrown = (fn) => { try { fn(); } catch (e) { return e; } return undefined; }; + +test("acceptRun redacts type:/eval: steps in meta.args but keeps them raw for the runner", () => { + const { request } = im.acceptRun(runRaw({ + chrome: true, url: "http://x", + step: ["type:#pw=hunter2", "eval:document.cookie", "click:#go"], + })); + // The raw steps reach the engine intact (the runner needs the real password/script). + assert.deepEqual(request.steps, ["goto:http://x", "type:#pw=hunter2", "eval:document.cookie", "click:#go"]); + // meta.args (which lands in the envelope) is redacted. + assert.deepEqual(request.args.steps, ["goto:http://x", "type:#pw=***", "eval:***", "click:#go"]); + const argsJson = JSON.stringify(request.args); + assert.ok(!argsJson.includes("hunter2"), "a typed password must never leak into meta.args"); + assert.ok(!argsJson.includes("document.cookie"), "an eval body must never leak into meta.args"); +}); + +test("acceptRun maps every guard to an InputError with the exact usage wording (and the INPUT code)", () => { + const cases = [ + [{ node: true, chrome: true }, "pick one target: --node or --chrome, not both"], + [{ node: true, chromeProfile: "/p" }, "--chrome-profile is a chrome option — don't combine it with --node"], + [{ chromeProfile: "/p", chrome: "9222" }, "pick one: --chrome-profile launches a logged-in browser, or --chrome attaches to a running one — not both"], + [{ headed: true }, "--headed only applies when launching Chrome (use with --chrome or --chrome-profile)"], + [{ node: true, curl: "c", concise: true, detailed: true }, "pick one envelope verbosity: --concise or --detailed, not both"], + [{ node: true, curl: "c", breakpoint: [] }, "run needs at least one --breakpoint (file:line or file@substring)"], + [{ chrome: true }, "chrome target needs --url or at least one --step"], + [{ chrome: true, url: "http://x", curl: "c" }, "--curl is a node-only trigger (chrome uses --url/--step)"], + [{ node: true, curl: "c", step: ["click:#x"] }, "--step is a chrome-only trigger (node uses --curl)"], + [{ node: true }, "node target needs --curl"], + ]; + for (const [over, message] of cases) { + const error = thrown(() => im.acceptRun(runRaw(over))); + assert.ok(error instanceof InputError, `expected InputError for ${message}`); + assert.equal(error.code, Code.INPUT); + assert.equal(error.message, message); + } +}); + +test("acceptRun rejects an unknown step verb as an invalid-step InputError carrying the problems", () => { + const error = thrown(() => im.acceptRun(runRaw({ chrome: true, step: ["frobnicate:x"] }))); + assert.ok(error instanceof InputError); + assert.match(error.message, /^invalid step —/); + assert.ok(error.problems.length >= 1); +}); + +test("acceptRun surfaces a DynamicInput violation (out-of-range port) as an invalid-input InputError", () => { + const error = thrown(() => im.acceptRun(runRaw({ node: "70000", curl: "c" }))); + assert.ok(error instanceof InputError); + assert.match(error.message, /^invalid input —/); +}); + +test("acceptRun normalizes the target: node default/explicit, chrome launch/attach, chrome-profile", () => { + let { request } = im.acceptRun(runRaw({ curl: "c" })); // node default + assert.equal(request.target, TargetKind.Node); + assert.equal(request.port, 9229); + assert.equal(request.launch, false); + + ({ request } = im.acceptRun(runRaw({ node: "9300", curl: "c" }))); // node explicit port + assert.equal(request.port, 9300); + + ({ request } = im.acceptRun(runRaw({ chrome: true, url: "http://x" }))); // bare --chrome → launch throwaway + assert.equal(request.target, TargetKind.Chrome); + assert.equal(request.launch, true); + assert.equal(request.port, 0); + + ({ request } = im.acceptRun(runRaw({ chrome: "9222", url: "http://x" }))); // --chrome → attach + assert.equal(request.launch, false); + assert.equal(request.port, 9222); + + ({ request } = im.acceptRun(runRaw({ chromeProfile: "/tmp/prof", url: "http://x" }))); // profile → launch+headed + assert.equal(request.launch, true); + assert.equal(request.headed, true); + assert.equal(request.profileDir, "/tmp/prof"); +}); + +test("acceptRun assembles meta.args: node carries the port, a chrome launch carries launch:true + the goto step", () => { + let { request } = im.acceptRun(runRaw({ node: "9300", curl: "c", root: "/r", maxHits: 5 })); + assert.deepEqual(request.args, { target: "node", port: 9300, breakpoints: ["app.js:10"], root: "/r", maxHits: 5, curl: "c" }); + + ({ request } = im.acceptRun(runRaw({ chrome: true, url: "http://x" }))); + assert.equal(request.args.launch, true); + assert.equal(request.args.port, undefined); + assert.deepEqual(request.args.steps, ["goto:http://x"]); +}); + +test("acceptGraph parses file@symbol and file:line:column entries", () => { + let request = im.acceptGraph({ entry: "src/a.ts@foo", depth: 6 }); + assert.equal(request.entry.file, "src/a.ts"); + assert.equal(request.entry.symbol, "foo"); + assert.equal(request.maxDepth, 6); + + request = im.acceptGraph({ entry: "src/a.ts:42:9", depth: 4 }); + assert.equal(request.entry.line, 42); + assert.equal(request.entry.column, 9); +}); + +test("acceptGraph rejects an entry with neither a line nor a symbol", () => { + const error = thrown(() => im.acceptGraph({ entry: "src/a.ts", depth: 6 })); + assert.ok(error instanceof InputError); + assert.match(error.message, /^invalid input —/); +}); + +test("acceptDeps requires an entry and assembles args", () => { + const error = thrown(() => im.acceptDeps({})); + assert.ok(error instanceof InputError); + assert.equal(error.message, "deps needs --entry "); + + const request = im.acceptDeps({ entry: "src", root: "/r" }); + assert.equal(request.entry, "src"); + assert.deepEqual(request.args, { entry: "src", root: "/r" }); +}); + +test("acceptComplexity defaults the path to '.'", () => { + assert.equal(im.acceptComplexity({}).path, "."); + assert.equal(im.acceptComplexity({ path: "src" }).path, "src"); +}); + +test("acceptSymbols requires a file", () => { + const error = thrown(() => im.acceptSymbols({})); + assert.ok(error instanceof InputError); + assert.equal(error.message, "symbols needs a "); + assert.equal(im.acceptSymbols({ file: "a.ts" }).file, "a.ts"); +}); diff --git a/test/io-output.test.js b/test/io-output.test.js new file mode 100644 index 0000000..4ee6a11 --- /dev/null +++ b/test/io-output.test.js @@ -0,0 +1,67 @@ +// OutputManager tests — the output tier. It turns a ProcessingResult into an OutputResult descriptor: it runs +// the schema gate (mutating the trace), applies --concise, chooses human-vs-JSON, and computes --json/--html +// file CONTENTS — but writes nothing and never exits. The adapter performs the I/O. (condense itself is unit- +// tested in output.test.js; here we test the descriptor shaping and the gate.) +import "reflect-metadata"; +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { OutputManager } from "../dist/io/OutputManager.js"; +import { ProcessingManager } from "../dist/io/ProcessingManager.js"; +import { InputManager } from "../dist/io/InputManager.js"; +import { Trace } from "../dist/domain/Trace.js"; + +const om = new OutputManager(); +const pm = new ProcessingManager(); + +test("emit: json:true → stdout is the JSON envelope; a clean trace → exit 0 with no E_SCHEMA diagnostic", async () => { + const result = await pm.runDoctor(); // doctor is always well-formed + ok:true + const out = om.emit(result, { json: true }); + assert.equal(JSON.parse(out.stdout).command, "doctor"); + assert.equal(out.exitCode, 0); + assert.equal(result.trace.diagnostics.filter((d) => d.code === "E_SCHEMA").length, 0); + assert.deepEqual(out.files, []); +}); + +test("emit: default (no --json) → stdout is the human render", async () => { + const result = await pm.runDoctor(); + const out = om.emit(result, {}); + assert.match(out.stdout, /trace-cli doctor/); +}); + +test("emit: --json → a file descriptor + an 'envelope written' log; stdout stays human", async () => { + const result = await pm.runDoctor(); + const out = om.emit(result, { json: "/tmp/io-output-test.json" }); + assert.equal(out.files.length, 1); + assert.equal(out.files[0].path, "/tmp/io-output-test.json"); + assert.equal(JSON.parse(out.files[0].contents).command, "doctor"); + assert.ok(out.logs.some((l) => l.message === "envelope written")); + assert.match(out.stdout, /trace-cli doctor/); // the file path → stdout stays the human view +}); + +test("emit: --html on a deps result → a trace-deps-*.html file + a 'deps HTML written' log", async () => { + const im = new InputManager(); + const result = await pm.runDeps(im.acceptDeps({ entry: "src/io" })); // degrades cleanly if madge is absent + const out = om.emit(result, { html: true }); + const html = out.files.find((f) => /trace-deps-.*\.html$/.test(f.path)); + assert.ok(html, "expected a deps html file descriptor with a kind-derived temp name"); + assert.ok(html.contents.length > 0); + assert.ok(out.logs.some((l) => l.message === "deps HTML written")); +}); + +test("emit: a malformed envelope is caught by the schema gate → E_SCHEMA error, ok:false, exit 1", () => { + // `version` as a number violates the envelope's @IsString contract, so validate() reports it and the gate + // turns it into an error diagnostic instead of shipping a silently-malformed Trace. + const trace = Trace.fromPlain({ tool: "trace", version: 123, command: "x", ok: true, meta: { at: "now" }, data: {}, diagnostics: [] }); + const out = om.emit({ trace, render: () => "x" }, {}); + assert.ok(trace.diagnostics.some((d) => d.code === "E_SCHEMA" && d.level === "error")); + assert.equal(trace.ok, false); + assert.equal(out.exitCode, 1); +}); + +test("text: a literal stdout descriptor with no files and the given exit code", () => { + const out = om.text("hello", 0); + assert.equal(out.stdout, "hello"); + assert.deepEqual(out.files, []); + assert.equal(out.exitCode, 0); +}); diff --git a/test/io-processing.test.js b/test/io-processing.test.js new file mode 100644 index 0000000..9b211f5 --- /dev/null +++ b/test/io-processing.test.js @@ -0,0 +1,84 @@ +// ProcessingManager tests — the orchestration tier. Owns the collector wiring (resolve + serialized emit chain +// + onProgress + the failure→diagnostic fold), the abort path (EngineAbortError), the static collector forward, +// and the command render thunks. The DynamicCommand is injected (a fake, like dynamic-diagnostics.test.js) and +// Collector's static helpers are stubbed so no real network is touched. `node --test` isolates each file in its +// own process, so these stubs never leak into other suites. +import "reflect-metadata"; +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { ProcessingManager, EngineAbortError } from "../dist/io/ProcessingManager.js"; +import { InputManager } from "../dist/io/InputManager.js"; +import { Collector } from "../dist/collector/Collector.js"; +import { Trace, TraceData, TraceMeta } from "../dist/domain/Trace.js"; + +const mkTrace = () => new Trace({ + version: "0.0.0", command: "run.node", + meta: new TraceMeta({ at: "2026-01-01T00:00:00.000Z" }), + data: new TraceData({ events: [] }), ok: true, +}); +// A duck-typed DynamicCommand: `run` is the injected behavior, `render` proves the thunk is bound to it. +const fakeDynamic = (run) => ({ run, render: (trace) => `rendered:${trace.command}` }); + +test("runDynamic: with no collector → returns the trace and a render thunk bound to the command", async () => { + Collector.resolve = async () => null; // nothing configured, nothing discovered + const trace = mkTrace(); + const pm = new ProcessingManager(fakeDynamic(async () => ({ trace }))); + const result = await pm.runDynamic({ request: { target: "node" }, emit: null }); + assert.equal(result.trace, trace); + assert.equal(result.render(), "rendered:run.node"); +}); + +test("runDynamic: a throwing run rejects with EngineAbortError carrying the cause", async () => { + Collector.resolve = async () => null; + const pm = new ProcessingManager(fakeDynamic(async () => { throw new Error("attach failed"); })); + await assert.rejects( + () => pm.runDynamic({ request: { target: "node" }, emit: null }), + (error) => error instanceof EngineAbortError && error.code === "ENGINE_FATAL" && /attach failed/.test(error.message), + ); +}); + +test("runDynamic: a failing collector emit folds into an EMIT warn diagnostic on the returned trace", async () => { + const emitted = []; + Collector.resolve = async () => "http://collector.test"; + Collector.emit = async (_url, envelope) => { emitted.push(envelope); return { ok: false, status: 400, body: "bad envelope" }; }; + const trace = mkTrace(); + // The fake streams one running envelope (onProgress), then returns the final one — both POSTs fail. + const pm = new ProcessingManager(fakeDynamic(async (opts) => { opts.onProgress?.(trace); return { trace }; })); + + const result = await pm.runDynamic({ request: { target: "node" }, emit: null }); + assert.ok(emitted.length >= 1, "the collector should have received at least one envelope"); + const emitDiag = result.trace.diagnostics.find((d) => d.code === "EMIT_FAILED"); + assert.ok(emitDiag && emitDiag.level === "warn", "a failed emit must surface as an EMIT warn diagnostic"); + assert.match(emitDiag.message, /rejected .*HTTP 400/); +}); + +test("forwardStatic: forwards to TRACE_COLLECTOR_URL when set (explicit-only), no-ops otherwise", async () => { + const forwarded = []; + Collector.emit = async (url) => { forwarded.push(url); return { ok: true, status: 200 }; }; + const pm = new ProcessingManager(); + const trace = mkTrace(); + + delete process.env.TRACE_COLLECTOR_URL; + await pm.forwardStatic(trace); + assert.equal(forwarded.length, 0, "no env → no forward (a static analysis has no sessionId to ingest)"); + + process.env.TRACE_COLLECTOR_URL = "http://static.test"; + await pm.forwardStatic(trace); + assert.deepEqual(forwarded, ["http://static.test"]); + delete process.env.TRACE_COLLECTOR_URL; +}); + +test("static runs return render thunks; deps additionally exposes renderHtml", async () => { + const pm = new ProcessingManager(); + const im = new InputManager(); + + const doctor = await pm.runDoctor(); + assert.equal(doctor.trace.command, "doctor"); + assert.equal(typeof doctor.render, "function"); + assert.equal(doctor.renderHtml, undefined); + + const deps = await pm.runDeps(im.acceptDeps({ entry: "src/io" })); // degrades cleanly if madge is absent + assert.equal(typeof deps.renderHtml, "function"); + assert.ok(deps.renderHtml().length > 0); +}); diff --git a/test/io-validators.test.js b/test/io-validators.test.js new file mode 100644 index 0000000..a89397f --- /dev/null +++ b/test/io-validators.test.js @@ -0,0 +1,70 @@ +// Validator tests — the validation halves split out of the managers. InputValidator owns the run guards + the +// strict DTO/step/graph checks (throwing InputError); OutputValidator owns the envelope schema gate. The +// managers (io-input/io-output) test the wiring; here we test the rules in isolation, at the validator boundary. +import "reflect-metadata"; +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { InputValidator } from "../dist/io/InputValidator.js"; +import { OutputValidator } from "../dist/io/OutputValidator.js"; +import { InputError } from "../dist/io/InputError.js"; +import { TargetKind } from "../dist/domain/Target.js"; +import { Trace, TraceData, TraceMeta } from "../dist/domain/Trace.js"; + +const iv = new InputValidator(); +const ov = new OutputValidator(); +const thrown = (fn) => { try { fn(); } catch (e) { return e; } return undefined; }; +const runRaw = (over = {}) => ({ breakpoint: ["app.js:10"], expression: [], step: [], ...over }); + +test("InputValidator.guardRunFlags: throws on a target conflict, passes a clean combo", () => { + assert.ok(thrown(() => iv.guardRunFlags(runRaw({ node: true, chrome: true }))) instanceof InputError); + assert.equal(iv.guardRunFlags(runRaw({ node: true })), undefined); +}); + +test("InputValidator.guardRunTrigger: requires a breakpoint first, then the target's trigger", () => { + const error = thrown(() => iv.guardRunTrigger(runRaw({ breakpoint: [] }), { target: TargetKind.Node, isChrome: false, steps: [] })); + assert.ok(error instanceof InputError); + assert.match(error.message, /at least one --breakpoint/); + // a node target with a curl trigger is clean + assert.equal(iv.guardRunTrigger(runRaw({ curl: "c" }), { target: TargetKind.Node, isChrome: false, steps: [] }), undefined); +}); + +test("InputValidator.validateDynamic: an out-of-range port throws InputError carrying the problems", () => { + const error = thrown(() => iv.validateDynamic({ target: TargetKind.Node, port: 70000, breakpoints: ["a:1"], exprs: [], steps: [] })); + assert.ok(error instanceof InputError); + assert.match(error.message, /^invalid input —/); + assert.ok(error.problems.length >= 1); +}); + +test("InputValidator.validateSteps: an unknown verb throws an invalid-step InputError; a known journey passes", () => { + const error = thrown(() => iv.validateSteps(["frobnicate:x"])); + assert.ok(error instanceof InputError); + assert.match(error.message, /^invalid step —/); + assert.equal(iv.validateSteps(["goto:http://x", "click:#go"]), undefined); +}); + +test("InputValidator.validateGraph: requires a line or a symbol", () => { + assert.ok(thrown(() => iv.validateGraph({ file: "a.ts" })) instanceof InputError); + assert.equal(iv.validateGraph({ file: "a.ts", symbol: "foo" }), undefined); + assert.equal(iv.validateGraph({ file: "a.ts", line: 3 }), undefined); +}); + +test("InputValidator.requireDepsEntry / requireSymbolsFile: presence gates with their exact wording", () => { + assert.equal(thrown(() => iv.requireDepsEntry("")).message, "deps needs --entry "); + assert.equal(iv.requireDepsEntry("src"), undefined); + assert.equal(thrown(() => iv.requireSymbolsFile(undefined)).message, "symbols needs a "); + assert.equal(iv.requireSymbolsFile("a.ts"), undefined); +}); + +test("OutputValidator.gate: a clean trace is untouched; a malformed one gets E_SCHEMA + ok:false", () => { + const clean = new Trace({ version: "0", command: "run.node", meta: new TraceMeta({ at: "2026-01-01T00:00:00.000Z" }), data: new TraceData({ events: [] }), ok: true }); + ov.gate(clean); + assert.equal(clean.ok, true); + assert.equal(clean.diagnostics.filter((d) => d.code === "E_SCHEMA").length, 0); + + // `version` as a number violates @IsString → the gate records it and flips ok. + const bad = Trace.fromPlain({ tool: "trace", version: 123, command: "x", ok: true, meta: { at: "now" }, data: {}, diagnostics: [] }); + ov.gate(bad); + assert.ok(bad.diagnostics.some((d) => d.code === "E_SCHEMA" && d.level === "error")); + assert.equal(bad.ok, false); +});