diff --git a/docs/architecture.md b/docs/architecture.md index 0427512..1c0c49d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -37,6 +37,7 @@ src/ parser/index.ts <- TS AST extraction + git churn + test detection graph/index.ts <- graphology graph + circular dep detection analyzer/index.ts <- All metric computation + graph-loader/index.ts <- Shared parse/build/analyze/cache pipeline + progress events core/index.ts <- Shared result computation (MCP + CLI) operations/index.ts <- Analysis operation descriptors + typed input schemas config/index.ts <- Config discovery + zod validation @@ -58,6 +59,10 @@ src/ ## Data Flow ``` +loadCodebaseGraph(rootDir) + -> cached CodebaseGraph when cache key matches + -> otherwise emits progress events through parse/build/analyze/cache + parseCodebase(rootDir) -> ParsedFile[] (with churn, complexity, test mapping) @@ -82,6 +87,7 @@ runOperation(operation, codebaseGraph, input, context) - **Dual interface**: MCP stdio for LLM agents, CLI subcommands for humans/CI. Both consume `src/core/`. - **Operation registry foundation**: Analysis operations now have typed descriptors in `src/operations/` with operation names, CLI command names, MCP tool names, input schemas, and discriminated run results. MCP tool registration and CLI command execution consume those descriptors; CLI text formatting still lives in `src/cli.ts` until the formatter migration lands. +- **Shared graph-load pipeline**: CLI commands and MCP stdio startup both use `src/graph-loader/` for path checks, legacy cache migration, cache reuse, parse/build/analyze, optional persistence, and stderr progress events. - **graphology**: In-memory graph with O(1) neighbor lookup. PageRank and betweenness computed via graphology-metrics. - **Batch git churn**: Single `git log --all --name-only` call, parsed for all files. Avoids O(n) subprocess spawning. - **Monorepo import resolution**: Root `tsconfig.json` path aliases and local `package.json` package names resolve to source files before graph construction. diff --git a/llms-full.txt b/llms-full.txt index 0cfb417..fceef37 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -43,6 +43,7 @@ src/ parser/index.ts <- TS AST extraction + git churn + test detection graph/index.ts <- graphology graph + circular dep detection analyzer/index.ts <- All metric computation + graph-loader/index.ts <- Shared parse/build/analyze/cache pipeline + progress events core/index.ts <- Shared result computation (MCP + CLI) operations/index.ts <- Analysis operation descriptors + typed input schemas mcp/index.ts <- 17 MCP tools for LLM integration @@ -61,6 +62,10 @@ src/ ## Data Flow ``` +loadCodebaseGraph(rootDir) + -> cached CodebaseGraph when cache key matches + -> otherwise emits progress events through parse/build/analyze/cache + parseCodebase(rootDir) -> ParsedFile[] (with churn, complexity, test mapping) @@ -82,6 +87,7 @@ runOperation(operation, codebaseGraph, input, context) - **graphology**: In-memory graph with O(1) neighbor lookup. PageRank and betweenness computed via graphology-metrics. - **Operation registry foundation**: Analysis operations have typed descriptors in `src/operations/` with operation names, CLI command names, MCP tool names, input schemas, and discriminated run results. MCP tool registration and CLI command execution consume those descriptors; CLI text formatting still lives in `src/cli.ts` until the formatter migration lands. +- **Shared graph-load pipeline**: CLI commands and MCP stdio startup both use `src/graph-loader/` for path checks, legacy cache migration, cache reuse, parse/build/analyze, optional persistence, and stderr progress events. - **Batch git churn**: Single `git log --all --name-only` call, parsed for all files. Avoids O(n) subprocess spawning. - **Dead export detection**: Cross-references parsed exports against edge symbol lists. May miss `import *` or re-exports. - **Graceful degradation**: Non-git dirs get churn=0, no-test codebases get coverage=false. Never crashes. diff --git a/roadmap.md b/roadmap.md index 85a11e9..1261c05 100644 --- a/roadmap.md +++ b/roadmap.md @@ -275,12 +275,12 @@ Collapse CLI + MCP operation duplication into one descriptor registry before add - Add CLI registry parity coverage for representative descriptor runs and invalid input. - Expand CH-P1-01 coverage from overview/representative CLI/MCP operations to every operation. - Expand CH-P1-02 coverage for descriptor validation, CLI parse failure, and cache reuse through registry-adapted commands. +- Use one graph-load pipeline with progress callbacks. +- Extend CH-P1-02 coverage to MCP/stdio graph-load behavior. **Remaining:** -- Use one graph-load pipeline with progress callbacks. - Move text/SARIF/markdown formatting over result objects into formatters. -- Extend CH-P1-02 coverage to MCP/stdio graph-load behavior after the shared graph-load pipeline exists. ### Type/Shape Layer diff --git a/src/cli.ts b/src/cli.ts index 337a1e0..09c667c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,26 +11,23 @@ process.on("uncaughtException", (err) => { import fs from "fs"; import path from "path"; -import { execSync } from "child_process"; import { createRequire } from "module"; import { Command } from "commander"; const require = createRequire(import.meta.url); const pkg = require("../package.json") as { version: string }; -import { parseCodebase } from "./parser/index.js"; -import { buildGraph } from "./graph/index.js"; -import { analyzeGraph } from "./analyzer/index.js"; import { startMcpServer } from "./mcp/index.js"; -import { setIndexedHead, setRoot } from "./server/graph-store.js"; -import { exportGraph, importGraph } from "./persistence/index.js"; -import { getCacheKey } from "./persistence/cache-key.js"; +import { importGraph } from "./persistence/index.js"; import { cleanIndexDirectories, - getCacheFacts, getCacheFactsForTarget, - prepareIndexDirectory, - type IndexDirectoryResolution, } from "./persistence/index-dir.js"; +import { + GraphLoadError, + loadCodebaseGraph, + prepareGraphCache, + type GraphLoadProgress, +} from "./graph-loader/index.js"; import { operations, parseOperationInput, @@ -55,28 +52,6 @@ import type { CacheFacts, CodebaseGraph, OutputFormat } from "./types/index.js"; let activeCacheFacts: CacheFacts | null = null; -function reportIndexMigration(resolution: IndexDirectoryResolution): void { - if (resolution.migration === "migrated-legacy") { - progress(`Migrated legacy index ${resolution.legacyDir} to ${resolution.canonicalDir}`); - } - if (resolution.migration === "ignored-legacy") { - progress(`Using ${resolution.canonicalDir}; legacy index remains at ${resolution.legacyDir}`); - } -} - -function getHeadHash(targetPath: string): string { - try { - return execSync("git rev-parse HEAD", { - cwd: path.resolve(targetPath), - encoding: "utf-8", - timeout: 5000, - stdio: ["ignore", "pipe", "ignore"], - }).trim(); - } catch { - return "unknown"; - } -} - function progress(msg: string): void { process.stderr.write(`${msg}\n`); } @@ -140,59 +115,32 @@ function runCliOperation( return result.data; } -/** Load (or parse+cache) the codebase graph for a target path. */ -function loadGraph(targetPath: string, force = false): { graph: CodebaseGraph; headHash: string } { - const resolved = path.resolve(targetPath); - if (!fs.existsSync(resolved)) { - process.stderr.write(`Error: Path does not exist: ${targetPath}\n`); - process.exit(1); - } - setRoot(resolved); - - const indexResolution = prepareIndexDirectory(targetPath); - reportIndexMigration(indexResolution); - activeCacheFacts = getCacheFacts(indexResolution); - const indexDir = indexResolution.activeDir; - const headHash = getHeadHash(targetPath); - const cacheKey = getCacheKey(targetPath, { headHash, cliVersion: pkg.version }); - - if (!force && headHash !== "unknown") { - const cached = importGraph(indexDir); - if (cached?.headHash === headHash && cached.cacheKey === cacheKey) { - progress(`Using cached index (HEAD: ${headHash.slice(0, 7)})`); - setIndexedHead(cached.headHash); - return { graph: cached.graph, headHash }; - } - } - - progress(`Parsing ${targetPath}...`); - const files = parseCodebase(targetPath); - progress(`Parsed ${files.length} files`); +function reportGraphLoadProgress(event: GraphLoadProgress): void { + progress(event.message); +} - if (files.length === 0) { - process.stderr.write(`Error: No TypeScript files found at ${targetPath}\n`); +function printGraphLoadError(err: unknown): never { + if (err instanceof GraphLoadError) { + process.stderr.write(`Error: ${err.message}\n`); process.exit(1); } + throw err; +} - const built = buildGraph(files); - progress( - `Built graph: ${built.nodes.filter((n) => n.type === "file").length} files, ` + - `${built.nodes.filter((n) => n.type === "function").length} functions, ` + - `${built.edges.length} dependencies`, - ); - - const graph = analyzeGraph(built, files); - progress( - `Analysis complete: ${graph.stats.circularDeps.length} circular deps, ` + - `${graph.forceAnalysis.tensionFiles.length} tension files`, - ); - - setIndexedHead(headHash); - - exportGraph(graph, indexDir, headHash, cacheKey); - progress(`Index saved to ${indexDir}`); - - return { graph, headHash }; +function loadGraph(targetPath: string, force = false): { graph: CodebaseGraph; headHash: string } { + try { + const result = loadCodebaseGraph({ + targetPath, + force, + persist: true, + cliVersion: pkg.version, + onProgress: reportGraphLoadProgress, + }); + activeCacheFacts = result.cacheFacts; + return { graph: result.graph, headHash: result.headHash }; + } catch (err) { + printGraphLoadError(err); + } } // ── CLI Program ───────────────────────────────────────────── @@ -1218,8 +1166,6 @@ program }); async function runMcpMode(targetPath: string, options: McpOptions): Promise { - setRoot(path.resolve(targetPath)); - if (options.clean) { const removed = cleanIndexDirectories(targetPath); if (removed.length === 0) { @@ -1230,12 +1176,10 @@ async function runMcpMode(targetPath: string, options: McpOptions): Promise n.type === "file").length} files, ` + - `${built.nodes.filter((n) => n.type === "function").length} functions, ` + - `${built.edges.length} dependencies`, - ); - - const codebaseGraph = analyzeGraph(built, files); - progress( - `Analysis complete: ${codebaseGraph.stats.circularDeps.length} circular deps, ` + - `${codebaseGraph.forceAnalysis.tensionFiles.length} tension files`, - ); - - setIndexedHead(headHash); - - if (options.index) { - exportGraph(codebaseGraph, indexDir, headHash, cacheKey); - progress(`Index saved to ${indexDir}`); + try { + const result = loadCodebaseGraph({ + targetPath, + force: options.force === true, + persist: options.index === true, + cliVersion: pkg.version, + onProgress: reportGraphLoadProgress, + }); + activeCacheFacts = result.cacheFacts; + await startMcpServer(result.graph); + } catch (err) { + printGraphLoadError(err); } - - await startMcpServer(codebaseGraph); } // ── Default action: bare → MCP mode ────────────────── diff --git a/src/graph-loader/index.ts b/src/graph-loader/index.ts new file mode 100644 index 0000000..88031d9 --- /dev/null +++ b/src/graph-loader/index.ts @@ -0,0 +1,200 @@ +import fs from "fs"; +import path from "path"; +import { execSync } from "child_process"; +import { analyzeGraph } from "../analyzer/index.js"; +import { buildGraph } from "../graph/index.js"; +import { parseCodebase } from "../parser/index.js"; +import { getCacheKey } from "../persistence/cache-key.js"; +import { + getCacheFacts, + prepareIndexDirectory, + type IndexDirectoryResolution, +} from "../persistence/index-dir.js"; +import { exportGraph, importGraph } from "../persistence/index.js"; +import { setIndexedHead, setRoot } from "../server/graph-store.js"; +import type { CacheFacts, CodebaseGraph, ParsedFile } from "../types/index.js"; + +export type GraphLoadProgress = + | { step: "migration"; message: string; resolution: IndexDirectoryResolution } + | { step: "cache-hit"; message: string; headHash: string; indexDir: string } + | { step: "parse-start"; message: string; targetPath: string } + | { step: "parse-complete"; message: string; fileCount: number } + | { step: "graph-built"; message: string; fileCount: number; functionCount: number; dependencyCount: number } + | { step: "analysis-complete"; message: string; circularDependencyCount: number; tensionFileCount: number } + | { step: "cache-saved"; message: string; indexDir: string }; + +interface GraphLoadOptions { + targetPath: string; + cliVersion: string; + force?: boolean; + persist?: boolean; + onProgress?: (event: GraphLoadProgress) => void; +} + +interface GraphLoadResult { + graph: CodebaseGraph; + headHash: string; + cacheFacts: CacheFacts; + indexDir: string; + cacheKey: string; + fromCache: boolean; +} + +interface GraphCachePreparation { + cacheFacts: CacheFacts; + indexDir: string; + resolution: IndexDirectoryResolution; +} + +export class GraphLoadError extends Error { + constructor(message: string) { + super(message); + this.name = "GraphLoadError"; + } +} + +interface ProgressOptions { + onProgress?: (event: GraphLoadProgress) => void; +} + +function emit(options: ProgressOptions, event: GraphLoadProgress): void { + options.onProgress?.(event); +} + +function getHeadHash(targetPath: string): string { + try { + return execSync("git rev-parse HEAD", { + cwd: path.resolve(targetPath), + encoding: "utf-8", + timeout: 5000, + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return "unknown"; + } +} + +function reportIndexMigration(options: ProgressOptions, resolution: IndexDirectoryResolution): void { + if (resolution.migration === "migrated-legacy") { + emit(options, { + step: "migration", + message: `Migrated legacy index ${resolution.legacyDir} to ${resolution.canonicalDir}`, + resolution, + }); + } + if (resolution.migration === "ignored-legacy") { + emit(options, { + step: "migration", + message: `Using ${resolution.canonicalDir}; legacy index remains at ${resolution.legacyDir}`, + resolution, + }); + } +} + +/** + * Resolve the active cache directory, including safe legacy migration. + * + * @param options - Target path and optional progress callback. + * @returns Active cache directory and JSON cache facts. + */ +export function prepareGraphCache(options: Pick): GraphCachePreparation { + const resolution = prepareIndexDirectory(options.targetPath); + reportIndexMigration(options, resolution); + return { + cacheFacts: getCacheFacts(resolution), + indexDir: resolution.activeDir, + resolution, + }; +} + +function parseFiles(targetPath: string): ParsedFile[] { + try { + return parseCodebase(targetPath); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new GraphLoadError(message); + } +} + +/** + * Load, analyze, and optionally persist a graph through the shared cache pipeline. + * + * @param options - Target path, CLI version, cache behavior, and progress callback. + * @returns Loaded graph plus cache metadata. + */ +export function loadCodebaseGraph(options: GraphLoadOptions): GraphLoadResult { + const resolved = path.resolve(options.targetPath); + if (!fs.existsSync(resolved)) { + throw new GraphLoadError(`Path does not exist: ${options.targetPath}`); + } + + setRoot(resolved); + + const cache = prepareGraphCache(options); + const cacheFacts = cache.cacheFacts; + const indexDir = cache.indexDir; + const headHash = getHeadHash(options.targetPath); + const cacheKey = getCacheKey(options.targetPath, { headHash, cliVersion: options.cliVersion }); + + if (options.force !== true && headHash !== "unknown") { + const cached = importGraph(indexDir); + if (cached?.headHash === headHash && cached.cacheKey === cacheKey) { + emit(options, { + step: "cache-hit", + message: `Using cached index (HEAD: ${headHash.slice(0, 7)})`, + headHash, + indexDir, + }); + setIndexedHead(cached.headHash); + return { graph: cached.graph, headHash, cacheFacts, indexDir, cacheKey, fromCache: true }; + } + } + + emit(options, { + step: "parse-start", + message: `Parsing ${options.targetPath}...`, + targetPath: options.targetPath, + }); + const files = parseFiles(options.targetPath); + emit(options, { + step: "parse-complete", + message: `Parsed ${files.length} files`, + fileCount: files.length, + }); + + if (files.length === 0) { + throw new GraphLoadError(`No TypeScript files found at ${options.targetPath}`); + } + + const built = buildGraph(files); + const fileCount = built.nodes.filter((node) => node.type === "file").length; + const functionCount = built.nodes.filter((node) => node.type === "function").length; + emit(options, { + step: "graph-built", + message: `Built graph: ${fileCount} files, ${functionCount} functions, ${built.edges.length} dependencies`, + fileCount, + functionCount, + dependencyCount: built.edges.length, + }); + + const graph = analyzeGraph(built, files); + emit(options, { + step: "analysis-complete", + message: `Analysis complete: ${graph.stats.circularDeps.length} circular deps, ${graph.forceAnalysis.tensionFiles.length} tension files`, + circularDependencyCount: graph.stats.circularDeps.length, + tensionFileCount: graph.forceAnalysis.tensionFiles.length, + }); + + setIndexedHead(headHash); + + if (options.persist === true) { + exportGraph(graph, indexDir, headHash, cacheKey); + emit(options, { + step: "cache-saved", + message: `Index saved to ${indexDir}`, + indexDir, + }); + } + + return { graph, headHash, cacheFacts, indexDir, cacheKey, fromCache: false }; +} diff --git a/tests/cli-mcp-stdio.e2e.test.ts b/tests/cli-mcp-stdio.e2e.test.ts index 4be6473..5a16210 100644 --- a/tests/cli-mcp-stdio.e2e.test.ts +++ b/tests/cli-mcp-stdio.e2e.test.ts @@ -1,5 +1,8 @@ -import { execSync } from "node:child_process"; +import { execFileSync, execSync, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; +import type { Stream } from "node:stream"; import { fileURLToPath } from "node:url"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; @@ -10,6 +13,14 @@ const here = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(here, ".."); const cli = path.join(repoRoot, "dist", "cli.js"); +const GIT_ENV = { + ...process.env, + GIT_AUTHOR_NAME: "t", + GIT_AUTHOR_EMAIL: "t@example.com", + GIT_COMMITTER_NAME: "t", + GIT_COMMITTER_EMAIL: "t@example.com", +}; + beforeAll(() => { execSync("pnpm build", { cwd: repoRoot, stdio: "inherit" }); }, 120_000); @@ -33,19 +44,55 @@ function textPayload(result: unknown): Record { return parsed; } +function collectStream(stream: Stream | null): () => string { + let output = ""; + stream?.on("data", (chunk: Buffer | string) => { + output += chunk.toString(); + }); + return () => output; +} + +function fixtureRepo(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cbi-mcp-stdio-")); + fs.writeFileSync(path.join(dir, "index.ts"), "export const value = 1;\n", "utf-8"); + for (const args of [ + ["init"], + ["config", "user.email", "t@example.com"], + ["config", "user.name", "t"], + ["add", "-A"], + ["commit", "-m", "base", "--no-gpg-sign"], + ]) { + execFileSync("git", ["-c", "core.hooksPath=/dev/null", ...args], { + cwd: dir, + env: GIT_ENV, + stdio: "ignore", + }); + } + return dir; +} + +async function connectClient(args: string[]): Promise<{ + client: Client; + transport: StdioClientTransport; + stderr: () => string; +}> { + const transport = new StdioClientTransport({ + command: "node", + args: [cli, ...args], + cwd: repoRoot, + stderr: "pipe", + }); + const stderr = collectStream(transport.stderr); + const client = new Client({ name: "stdio-e2e", version: "0.1.0" }); + await client.connect(transport); + return { client, transport, stderr }; +} + describe("CLI MCP stdio lifecycle (e2e)", () => { it("starts from the compiled CLI and serves tools over stdio", async () => { - const transport = new StdioClientTransport({ - command: "node", - args: [cli, getFixtureSrcPath(), "--force"], - cwd: repoRoot, - stderr: "pipe", - }); - const client = new Client({ name: "stdio-e2e", version: "0.1.0" }); + const { client, transport } = await connectClient([getFixtureSrcPath(), "--force"]); try { - await client.connect(transport); - const tools = await client.listTools(); const names = tools.tools.map((tool) => tool.name); expect(names).toContain("codebase_overview"); @@ -64,4 +111,47 @@ describe("CLI MCP stdio lifecycle (e2e)", () => { await transport.close(); } }, 120_000); + + it("CH-P1-02: reuses the shared graph-load cache path in stdio MCP mode", async () => { + const repo = fixtureRepo(); + try { + const first = await connectClient([repo, "--index", "--force"]); + try { + await first.client.listTools(); + } finally { + await first.client.close(); + await first.transport.close(); + } + expect(first.stderr()).toContain("Index saved"); + + const second = await connectClient([repo, "--index"]); + try { + await second.client.listTools(); + } finally { + await second.client.close(); + await second.transport.close(); + } + expect(second.stderr()).toContain("Using cached index"); + expect(second.stderr()).not.toContain("Parsing"); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } + }, 120_000); + + it("CH-P1-02: reports stdio graph-load parse failures without a fatal crash envelope", () => { + const empty = fs.mkdtempSync(path.join(os.tmpdir(), "cbi-empty-mcp-")); + try { + const result = spawnSync("node", [cli, empty], { + cwd: repoRoot, + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + }); + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("Error: No TypeScript files found"); + expect(result.stderr).not.toContain("Fatal:"); + } finally { + fs.rmSync(empty, { recursive: true, force: true }); + } + }); }); diff --git a/tests/graph-load-pipeline.test.ts b/tests/graph-load-pipeline.test.ts new file mode 100644 index 0000000..1d332f0 --- /dev/null +++ b/tests/graph-load-pipeline.test.ts @@ -0,0 +1,94 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { loadCodebaseGraph, type GraphLoadProgress } from "../src/graph-loader/index.js"; +import { CANONICAL_INDEX_DIR_NAME } from "../src/persistence/cache-key.js"; + +const GIT_ENV = { + ...process.env, + GIT_AUTHOR_NAME: "t", + GIT_AUTHOR_EMAIL: "t@example.com", + GIT_COMMITTER_NAME: "t", + GIT_COMMITTER_EMAIL: "t@example.com", +}; + +function git(dir: string, args: string[]): void { + execFileSync("git", ["-c", "core.hooksPath=/dev/null", ...args], { + cwd: dir, + env: GIT_ENV, + stdio: "ignore", + }); +} + +function createRepo(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cbi-graph-loader-")); + fs.writeFileSync(path.join(dir, "index.ts"), "export const value = 1;\n", "utf-8"); + git(dir, ["init"]); + git(dir, ["config", "user.email", "t@example.com"]); + git(dir, ["config", "user.name", "t"]); + git(dir, ["add", "-A"]); + git(dir, ["commit", "-m", "base", "--no-gpg-sign"]); + return dir; +} + +describe("graph-load pipeline", () => { + it("emits progress, persists a graph, and reuses cache", () => { + const repo = createRepo(); + try { + const firstEvents: GraphLoadProgress[] = []; + const first = loadCodebaseGraph({ + targetPath: repo, + cliVersion: "test", + force: true, + persist: true, + onProgress: (event) => firstEvents.push(event), + }); + + expect(first.fromCache).toBe(false); + expect(first.graph.stats.totalFiles).toBe(1); + expect(first.cacheFacts.cacheDir).toBe(path.join(repo, CANONICAL_INDEX_DIR_NAME)); + expect(firstEvents.map((event) => event.step)).toEqual([ + "parse-start", + "parse-complete", + "graph-built", + "analysis-complete", + "cache-saved", + ]); + expect(fs.existsSync(path.join(repo, CANONICAL_INDEX_DIR_NAME, "graph.json"))).toBe(true); + + const secondEvents: GraphLoadProgress[] = []; + const second = loadCodebaseGraph({ + targetPath: repo, + cliVersion: "test", + persist: true, + onProgress: (event) => secondEvents.push(event), + }); + + expect(second.fromCache).toBe(true); + expect(second.graph.stats.totalFiles).toBe(first.graph.stats.totalFiles); + expect(secondEvents.map((event) => event.step)).toEqual(["cache-hit"]); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } + }); + + it("can analyze without persisting a new graph", () => { + const repo = createRepo(); + try { + const result = loadCodebaseGraph({ + targetPath: repo, + cliVersion: "test", + force: true, + persist: false, + }); + + expect(result.fromCache).toBe(false); + expect(result.graph.stats.totalFiles).toBe(1); + expect(fs.existsSync(path.join(repo, CANONICAL_INDEX_DIR_NAME))).toBe(false); + } finally { + fs.rmSync(repo, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/operation-registry.e2e.test.ts b/tests/operation-registry.e2e.test.ts index 00f1f80..e57f774 100644 --- a/tests/operation-registry.e2e.test.ts +++ b/tests/operation-registry.e2e.test.ts @@ -294,7 +294,7 @@ describe("operation registry chained parity", () => { } }); - it("CH-P1-02: CLI graph adapter reuses cache after a successful registry command", () => { + it("CH-P1-02: shared graph-load pipeline reuses cache after a successful registry command", () => { const first = runCli(["overview", getFixtureSrcPath()]); expect(first.status).toBe(0); expect(first.stderr).toContain("Index saved");