From d9a8aaef61a7431c44f00ac977fab05739865fad Mon Sep 17 00:00:00 2001 From: burrows99 Date: Fri, 19 Jun 2026 12:29:38 +0100 Subject: [PATCH] refactor(analysis): add an Analyzer parent and route lineage + graph through it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `Analyzer` as the shared parent of every analysis: a stable `name` plus `analyze(input)` that normalizes raw signal into a domain shape. Both analyses now extend it: - LineageAnalyzer extends Analyzer — the runtime value analysis (sync). `compute` becomes the instance `analyze`; the pure `summary` formatter stays a static helper. - GraphAnalyzer (new) extends Analyzer — the graphical analysis (async). It delegates the call-hierarchy walk to the existing pluggable CodeGraphProvider, so the provider stays the swappable backend while `graph` now depends on an Analyzer, like a sibling to lineage. Call sites updated: RunCommand uses `new LineageAnalyzer().analyze(...)`; GraphCommand builds a `GraphAnalyzer` over the chosen provider and reads `analyzer.name` for the `graph.` envelope. Exports + tests follow, with an instanceof-Analyzer assertion on both sides to pin the contract. Verified: tsc --noEmit clean, full build green, 77/78 tests pass (1 Postgres test skipped — needs a live DB). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/analysis/Analyzer.ts | 19 ++++++++++++++++ src/analysis/GraphAnalyzer.ts | 38 ++++++++++++++++++++++++++++++++ src/analysis/LineageAnalyzer.ts | 14 +++++++----- src/cli/commands/GraphCommand.ts | 32 +++++++++++++++------------ src/cli/commands/RunCommand.ts | 2 +- src/index.ts | 3 +++ test/codegraph.test.js | 14 ++++++++++++ test/domain.test.js | 10 ++++++--- 8 files changed, 109 insertions(+), 23 deletions(-) create mode 100644 src/analysis/Analyzer.ts create mode 100644 src/analysis/GraphAnalyzer.ts diff --git a/src/analysis/Analyzer.ts b/src/analysis/Analyzer.ts new file mode 100644 index 0000000..644a03e --- /dev/null +++ b/src/analysis/Analyzer.ts @@ -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` + * 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 { + /** 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; +} diff --git a/src/analysis/GraphAnalyzer.ts b/src/analysis/GraphAnalyzer.ts new file mode 100644 index 0000000..64a8db0 --- /dev/null +++ b/src/analysis/GraphAnalyzer.ts @@ -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 { + /** Stable id mirrors the backing provider — it drives the envelope command `graph.`. */ + 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 { + 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 { + return this.provider.callGraph(input.entry, input.options); + } +} diff --git a/src/analysis/LineageAnalyzer.ts b/src/analysis/LineageAnalyzer.ts index d589b70..c7ef4ae 100644 --- a/src/analysis/LineageAnalyzer.ts +++ b/src/analysis/LineageAnalyzer.ts @@ -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 { + readonly name = "lineage"; + + analyze(events: TraceEvent[] = []): Lineage[] { const tracks = new Map(); for (const event of events) { diff --git a/src/cli/commands/GraphCommand.ts b/src/cli/commands/GraphCommand.ts index fb1bd8c..2259f3d 100644 --- a/src/cli/commands/GraphCommand.ts +++ b/src/cli/commands/GraphCommand.ts @@ -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"; @@ -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 { async run(request: GraphRequest): Promise { const startedAtMs = this.started(); - const provider = createCodeGraphProvider(request.provider); + const analyzer = new GraphAnalyzer(createCodeGraphProvider(request.provider)); const diagnostics: Diagnostic[] = []; let data = new TraceData({}); @@ -44,12 +45,15 @@ export class GraphCommand extends TraceCommand { 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) { @@ -57,12 +61,12 @@ export class GraphCommand extends TraceCommand { } } 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 ?? {}, diff --git a/src/cli/commands/RunCommand.ts b/src/cli/commands/RunCommand.ts index 0c851c5..e75190c 100644 --- a/src/cli/commands/RunCommand.ts +++ b/src/cli/commands/RunCommand.ts @@ -149,7 +149,7 @@ export class RunCommand extends TraceCommand { 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, diff --git a/src/index.ts b/src/index.ts index dae9955..f7353a9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; diff --git a/test/codegraph.test.js b/test/codegraph.test.js index e9f6ee6..d4789e0 100644 --- a/test/codegraph.test.js +++ b/test/codegraph.test.js @@ -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 }); @@ -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.` 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"]); +}); diff --git a/test/domain.test.js b/test/domain.test.js index 2ac6314..0798d35 100644 --- a/test/domain.test.js +++ b/test/domain.test.js @@ -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"; @@ -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); @@ -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", () => {