Skip to content
Open
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
19 changes: 19 additions & 0 deletions src/analysis/Analyzer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Analyzer — the parent of every analysis in trace-cli. An analysis takes one kind of raw signal (a runtime
* event timeline, a language server's call hierarchy, …) and normalizes it into a domain shape that consumers
* render and ship inside the Trace envelope. Subclasses declare a stable `name` and implement `analyze`; the
* base fixes the contract so the analyses stay interchangeable — a command can depend on `Analyzer<In, Out>`
* rather than a concrete one, the same Dependency-Inversion seam used by SessionStore / ArtifactStore / the
* code-graph provider.
*
* `analyze` may be sync or async: the lineage analysis is a pure in-process transform (sync); the graph
* analysis drives an out-of-process language server (async). Each subclass narrows `In`/`Out` and the return to
* exactly one of the two, so call sites get a precise type while the family shares one shape.
*/
export abstract class Analyzer<In, Out> {
/** Stable analyzer id — used in logs and the envelope command provenance (e.g. "lineage", "lsp"). */
abstract readonly name: string;

/** Normalize the raw input signal into its domain output. */
abstract analyze(input: In): Out | Promise<Out>;
}
38 changes: 38 additions & 0 deletions src/analysis/GraphAnalyzer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { createCodeGraphProvider } from "../codegraph/createCodeGraphProvider.js";
import type {
CallGraphOptions, CodeGraph, CodeGraphProvider, EntryReference, ProviderAvailability,
} from "../codegraph/CodeGraphProvider.js";
import { Analyzer } from "./Analyzer.js";

/** The resolved input one graph build needs: where to start, plus the bounded build knobs. */
export interface GraphAnalysisInput {
entry: EntryReference;
options: CallGraphOptions;
}

/**
* GraphAnalyzer — the graphical analysis. Sibling to {@link LineageAnalyzer}: both are {@link Analyzer}s that
* normalize raw signal into a domain shape — lineage turns the runtime event timeline into Lineage[], the graph
* turns a language server's call hierarchy into a CodeGraph (nodes + edges). The actual call-hierarchy walk is
* delegated to a pluggable {@link CodeGraphProvider} (LSP today); GraphAnalyzer is the analysis-tier seam that
* lets the `graph` command depend on `Analyzer`, with the provider as the swappable backend behind it.
*/
export class GraphAnalyzer extends Analyzer<GraphAnalysisInput, CodeGraph> {
/** Stable id mirrors the backing provider — it drives the envelope command `graph.<name>`. */
readonly name: string;

constructor(private readonly provider: CodeGraphProvider = createCodeGraphProvider()) {
super();
this.name = provider.name;
}

/** Can this analysis run against `root`? (the backing provider's CLI/library resolves.) Never throws. */
isAvailable(root: string): Promise<ProviderAvailability> {
return this.provider.isAvailable(root);
}

/** Build the outgoing-call graph rooted at the entry. Throws with a clear message on unresolvable input. */
analyze(input: GraphAnalysisInput): Promise<CodeGraph> {
return this.provider.callGraph(input.entry, input.options);
}
}
14 changes: 9 additions & 5 deletions src/analysis/LineageAnalyzer.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { Lineage, LineagePoint, type LineageKind } from "../domain/Lineage.js";
import type { TraceEvent } from "../domain/TraceEvent.js";
import { Analyzer } from "./Analyzer.js";

const norm = (value: unknown): string => { try { return JSON.stringify(value); } catch { return String(value); } };

/**
* LineageAnalyzer — the normalization tier. Derives mutation lineage (value-over-time) from the event
* timeline: for every watched expression and local, the ordered series of its values, with each occurrence
* flagged when it differs from the previous. Drops values that never change (no lineage without flow).
* LineageAnalyzer — the runtime-value analysis. An {@link Analyzer} over the captured event timeline: for every
* watched expression and local, the ordered series of its values, each occurrence flagged when it differs from
* the previous. Drops values that never change (no lineage without flow). A pure, synchronous in-process
* transform — TraceEvent[] → Lineage[].
*/
export class LineageAnalyzer {
static compute(events: TraceEvent[] = []): Lineage[] {
export class LineageAnalyzer extends Analyzer<TraceEvent[], Lineage[]> {
readonly name = "lineage";

analyze(events: TraceEvent[] = []): Lineage[] {
const tracks = new Map<string, { name: string; kind: LineageKind; series: LineagePoint[] }>();

for (const event of events) {
Expand Down
32 changes: 18 additions & 14 deletions src/cli/commands/GraphCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Code } from "../../shared/codes.js";
import { findProjectRoot } from "../../shared/projectRoot.js";
import { createCodeGraphProvider } from "../../codegraph/createCodeGraphProvider.js";
import type { CodeGraph, EntryReference } from "../../codegraph/CodeGraphProvider.js";
import { GraphAnalyzer } from "../../analysis/GraphAnalyzer.js";
import { TraceCommand } from "./TraceCommand.js";
import { GraphView } from "./GraphView.js";

Expand All @@ -25,16 +26,16 @@ export interface GraphRequest {
}

/**
* GraphCommand — orchestrates a static call-graph build: pick the provider (factory), build the outgoing-call
* 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
* run command surfaces engine failures — an agent always gets back a Trace.
* GraphCommand — orchestrates a static call-graph build: pick the analyzer (a {@link GraphAnalyzer} over the
* chosen provider), build the outgoing-call graph rooted at the entry, and normalize it into one Trace envelope
* (`data.graph`). The analyzer 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 run command surfaces engine failures — an agent always gets a Trace.
*/
export class GraphCommand extends TraceCommand<GraphRequest> {
async run(request: GraphRequest): Promise<Trace> {
const startedAtMs = this.started();
const provider = createCodeGraphProvider(request.provider);
const analyzer = new GraphAnalyzer(createCodeGraphProvider(request.provider));
const diagnostics: Diagnostic[] = [];
let data = new TraceData({});
Comment on lines +38 to 40

Expand All @@ -44,25 +45,28 @@ export class GraphCommand extends TraceCommand<GraphRequest> {
const baseDirectory = request.root ? resolve(request.root) : process.cwd();
const entryFile = isAbsolute(request.entry.file) ? request.entry.file : resolve(baseDirectory, request.entry.file);
const root = request.root ? resolve(request.root) : findProjectRoot(entryFile);
const graph = await provider.callGraph({ ...request.entry, file: entryFile }, {
root,
maxDepth: request.maxDepth,
includeExternal: request.includeExternal ?? false,
maxNodes: request.maxNodes ?? MAX_NODES,
server: request.server,
const graph = await analyzer.analyze({
entry: { ...request.entry, file: entryFile },
options: {
root,
maxDepth: request.maxDepth,
includeExternal: request.includeExternal ?? false,
maxNodes: request.maxNodes ?? MAX_NODES,
server: request.server,
},
});
data = new TraceData({ graph });
if (graph.stats.truncated) {
diagnostics.push(Diagnostic.warn(Code.GRAPH_TRUNCATED, `graph truncated at depth ${request.maxDepth} — raise --depth for more, or pick a more specific entry`));
}
} catch (error: any) {
diagnostics.push(Diagnostic.error(Code.CODEGRAPH_FAILED, String(error?.message ?? error).split("\n")[0]));
log.error("call graph failed", { code: Code.CODEGRAPH_FAILED, provider: provider.name, err: error });
log.error("call graph failed", { code: Code.CODEGRAPH_FAILED, provider: analyzer.name, err: error });
}

// `ok` derives from the diagnostics: a CODEGRAPH_FAILED error flips it false, GRAPH_TRUNCATED (warn) doesn't.
return this.envelope({
command: `graph.${provider.name}`,
command: `graph.${analyzer.name}`,
data,
diagnostics,
args: request.args ?? {},
Expand Down
2 changes: 1 addition & 1 deletion src/cli/commands/RunCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ export class RunCommand extends TraceCommand<RunRequest, RunResult> {
if (boundCount > 0 && capture.events.length === 0) {
diagnostics.push(Diagnostic.warn(Code.BP_BOUND_UNHIT, `${boundCount} breakpoint(s) bound but never hit — the trigger may not have exercised this path (wrong route/branch, or the trigger didn't run).`));
}
const lineage = LineageAnalyzer.compute(capture.events);
const lineage = new LineageAnalyzer().analyze(capture.events);
const data = new TraceData({
breakpoints: capture.breakpoints,
events: capture.events,
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ export { Renderer } from "./engine/Renderer.js";
export { Recorder } from "./engine/Recorder.js";
export { CdpDriver } from "./transport/CdpDriver.js";
export type { ProtocolDriver } from "./transport/ProtocolDriver.js";
export { Analyzer } from "./analysis/Analyzer.js";
export { LineageAnalyzer } from "./analysis/LineageAnalyzer.js";
export { GraphAnalyzer } from "./analysis/GraphAnalyzer.js";
export type { GraphAnalysisInput } from "./analysis/GraphAnalyzer.js";
export { S3ArtifactStore } from "./storage/S3ArtifactStore.js";
export type { ArtifactStore } from "./storage/ArtifactStore.js";
export { Collector } from "./collector/Collector.js";
Expand Down
14 changes: 14 additions & 0 deletions test/codegraph.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { fileURLToPath } from "node:url";
import { LspCodeGraphProvider } from "../dist/codegraph/LspCodeGraphProvider.js";
import { createCodeGraphProvider, CODEGRAPH_PROVIDERS } from "../dist/codegraph/createCodeGraphProvider.js";
import { GraphCommand } from "../dist/cli/commands/GraphCommand.js";
import { Analyzer } from "../dist/analysis/Analyzer.js";
import { GraphAnalyzer } from "../dist/analysis/GraphAnalyzer.js";

const ROOT = fileURLToPath(new URL("./fixtures/codegraph", import.meta.url));
const build = (entry, extra) => new LspCodeGraphProvider().callGraph(entry, { root: ROOT, maxDepth: 10, includeExternal: false, maxNodes: 500, ...extra });
Expand Down Expand Up @@ -73,3 +75,15 @@ test("a non-existent entry fails into an error envelope, not a throw", async ()
assert.equal(trace.ok, false);
assert.ok(trace.diagnostics.some((d) => d.code === "CODEGRAPH_FAILED"));
});

test("GraphAnalyzer is an Analyzer that delegates the call-hierarchy walk to its provider", async () => {
const analyzer = new GraphAnalyzer(); // defaults to the lsp provider via the factory
assert.ok(analyzer instanceof Analyzer, "GraphAnalyzer extends the shared Analyzer base");
assert.equal(analyzer.name, "lsp", "name mirrors the backing provider — drives the `graph.<name>` command");
const g = await analyzer.analyze({
entry: { file: "sample.ts", symbol: "entry" },
options: { root: ROOT, maxDepth: 10, includeExternal: false, maxNodes: 500 },
});
assert.equal(g.provider, "lsp");
assert.deepEqual(g.nodes.map((n) => n.label).sort(), ["alpha", "beta", "entry", "gamma", "helperFn", "recur"]);
});
10 changes: 7 additions & 3 deletions test/domain.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { test } from "node:test";
import assert from "node:assert/strict";

import { Trace, TraceMeta, TraceData, TraceEvent, Breakpoint, Diagnostic, SourceLocation } from "../dist/domain/index.js";
import { Analyzer } from "../dist/analysis/Analyzer.js";
import { LineageAnalyzer } from "../dist/analysis/LineageAnalyzer.js";
import { BreakpointResolver } from "../dist/engine/BreakpointResolver.js";
import { SourceMaps } from "../dist/engine/SourceMaps.js";
Expand Down Expand Up @@ -56,7 +57,10 @@ test("Trace.fromPlain rehydrates the full object graph to class instances", () =
const ev = (sequence, exprs, locals) => new TraceEvent({ sequence, time: sequence * 10, kind: "breakpoint", attributes: { exprs, locals } });

test("LineageAnalyzer tracks a value mutating across hits (expr wins over local)", () => {
const lin = LineageAnalyzer.compute([ev(1, { total: 0 }, { total: 0, i: 0 }), ev(2, { total: 9.99 }, { total: 9.99, i: 1 }), ev(3, { total: 14.49 }, { total: 14.49, i: 2 })]);
const analyzer = new LineageAnalyzer();
assert.ok(analyzer instanceof Analyzer, "LineageAnalyzer extends the shared Analyzer base");
assert.equal(analyzer.name, "lineage");
const lin = analyzer.analyze([ev(1, { total: 0 }, { total: 0, i: 0 }), ev(2, { total: 9.99 }, { total: 9.99, i: 1 }), ev(3, { total: 14.49 }, { total: 14.49, i: 2 })]);
const total = lin.find((t) => t.name === "total");
assert.equal(total.kind, "expr");
assert.equal(total.occurrences, 3);
Expand All @@ -65,8 +69,8 @@ test("LineageAnalyzer tracks a value mutating across hits (expr wins over local)
});

test("LineageAnalyzer drops values that never change / single-hit", () => {
assert.equal(LineageAnalyzer.compute([ev(1, { c: "X" }), ev(2, { c: "X" })]).length, 0);
assert.equal(LineageAnalyzer.compute([ev(1, { total: 5 }, { total: 5 })]).length, 0);
assert.equal(new LineageAnalyzer().analyze([ev(1, { c: "X" }), ev(2, { c: "X" })]).length, 0);
assert.equal(new LineageAnalyzer().analyze([ev(1, { total: 5 }, { total: 5 })]).length, 0);
});

test("BreakpointResolver.parseSpec splits file:line and file@substring", () => {
Expand Down