From 1949166132692c318f267dd73183a43177377d6d Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Fri, 3 Jul 2026 03:16:44 -0400 Subject: [PATCH 01/26] Add MCP freshness checks --- codegraph-skill/codegraph/SKILL.md | 1 + docs/library-api.md | 5 +- docs/mcp.md | 12 +- src/agent.ts | 9 +- src/agent/session.ts | 166 +++++++++++-- src/index.ts | 9 +- src/mcp/server.ts | 377 ++++++++++++++++------------- tests/agent-session.test.ts | 24 ++ tests/helpers/agent.ts | 46 ++-- tests/mcp-server.test.ts | 65 +++-- 10 files changed, 471 insertions(+), 243 deletions(-) diff --git a/codegraph-skill/codegraph/SKILL.md b/codegraph-skill/codegraph/SKILL.md index f614ddb9..16a9bea6 100644 --- a/codegraph-skill/codegraph/SKILL.md +++ b/codegraph-skill/codegraph/SKILL.md @@ -66,6 +66,7 @@ Treat duplicate leads and call-compatibility hints as review leads, not proof. If MCP tools are available, prefer them over repeated CLI invocations. Use MCP `orient`, `search`, `packet_get`, `goto`, `refs`, `deps`, `rdeps`, `path`, `impact`, and `review` first. +After edits, check MCP response `freshness`: `refreshed` means Codegraph rebuilt before answering, and `stale` means use `refresh_index` or live file reads before trusting indexed context. Fall back to CLI when MCP is unavailable. ## Discovery diff --git a/docs/library-api.md b/docs/library-api.md index e16e58a8..374fdb1d 100644 --- a/docs/library-api.md +++ b/docs/library-api.md @@ -162,7 +162,7 @@ console.log(artifact.manifestPath, artifact.artifacts); The `graph.json` artifact is self-describing (`schemaVersion: 1`, `format: "codegraph.graph-json"`) and uses project-relative file paths and portable symbol handles. `questions.json` uses the same stable handles for follow-up commands. With `force: true`, stale known Codegraph artifact files are removed before the selected outputs are written; unrelated files in the directory are preserved. -`createAgentSession()` keeps one in-process project snapshot warm for repeated orient, search, explain, packet, artifact, and MCP calls. It uses incremental indexing with disk cache by default, auto-enables native workers for large cold builds, and carries forward top-level analysis metadata from the build report. Set `buildOptions.useNativeWorkers` to `false` to opt out. Use `buildCodegraphArtifactWithSession()` when a host already has a session and wants SQLite, graph JSON, report, questions, and manifest outputs from the same snapshot. `createCodegraphMcpHandlers()` exposes the same primitives without starting stdio, which is useful for tests or host applications: +`createAgentSession()` keeps one in-process project snapshot warm for repeated orient, search, explain, packet, artifact, and MCP calls. It uses incremental indexing with disk cache by default, auto-enables native workers for large cold builds, and carries forward top-level analysis metadata from the build report. Session callers can use `freshness: { policy: "check" | "auto" | "manual" }` plus `checkFreshness()` to detect file edits before reusing a warm snapshot; MCP-created sessions use automatic freshness checks. Set `buildOptions.useNativeWorkers` to `false` to opt out. Use `buildCodegraphArtifactWithSession()` when a host already has a session and wants SQLite, graph JSON, report, questions, and manifest outputs from the same snapshot. `createCodegraphMcpHandlers()` exposes the same primitives without starting stdio, which is useful for tests or host applications: ```ts import { createCodegraphMcpHandlers } from "@lzehrung/codegraph"; @@ -178,11 +178,12 @@ const orient = await handlers.orient({ includeRoots: ["src"], budget: "small" }) const packet = await handlers.packet_get({ target: orient.focus.find((entry) => entry.file)!.file! }); const refs = await handlers.refs({ handle: search.results[0]!.handle }); const rows = await handlers.query_sqlite({ query: "select path from files", limit: 5 }); +console.log(search.freshness.state); console.log(packet.kind, refs.references, rows.rows); ``` `serveCodegraphMcp()` starts the stdio server used by `codegraph mcp serve`. MCP is an agent ergonomics and cache layer over the same analysis engine, not a separate indexer. MCP file and artifact paths are confined after realpath resolution. `query_sqlite` is read-only and row- and byte-bounded; `artifact_build` is disabled by default and requires `readOnly: false` or CLI `--allow-build`. -MCP `orient` and `packet_get` calls use the server-configured root; they do not accept per-request root overrides. +MCP `orient` and `packet_get` calls use the server-configured root; they do not accept per-request root overrides. Index-backed MCP responses include `freshness` metadata so hosts can distinguish fresh, auto-refreshed, and stale snapshots. See [MCP server](./mcp.md) for CLI server setup and client configuration examples. diff --git a/docs/mcp.md b/docs/mcp.md index 800072ed..beb46171 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -49,7 +49,8 @@ The server exposes the same bounded primitives as the CLI and library session la - `artifact_build`: artifact creation, available only with write access enabled. MCP keeps one Codegraph session warm for the configured root. That makes follow-up calls cheaper than separate CLI invocations. Startup is lazy unless `--warmup` or `--warmup-symbols` is passed. -Use `refresh_index` after changing files while the server is running, or when you need a fresh cache-backed snapshot without restarting the MCP process. +Before index-backed tool calls, MCP checks whether discovered files changed since the warm snapshot. Small changes refresh the session automatically, and responses include `freshness.state` as `fresh`, `refreshed`, or `stale` with changed file paths when applicable. +Use `refresh_index` when you need to force a rebuild, reset SQLite artifact state, or refresh after a change burst that exceeds the automatic refresh limits. Tool schemas are flat JSON objects for broad client compatibility; argument combinations such as `refs` handle-vs-position mode are validated by the server. ## Safety @@ -195,9 +196,10 @@ When Codegraph MCP tools are available to an agent: 1. Start with `orient`. 2. Use `search` to find anchors. 3. Use `packet_get`, `refs`, `goto`, `deps`, `rdeps`, or `path` for focused follow-up. -4. Use `impact` and `review` for git-range risk analysis. -5. Use `query_sqlite` only for read-only artifact inspection. -6. Use `refresh_index` after changing files while a long-running server is active. -7. Use `artifact_build` only when write access was intentionally enabled. +4. Check `freshness` on MCP responses after edits; `refreshed` means the answer used an updated snapshot, and `stale` means the tool reported changed files without silently trusting old index data. +5. Use `impact` and `review` for git-range risk analysis. +6. Use `query_sqlite` only for read-only artifact inspection. +7. Use `refresh_index` when you need an explicit rebuild. +8. Use `artifact_build` only when write access was intentionally enabled. Fall back to CLI commands when MCP tools are unavailable. diff --git a/src/agent.ts b/src/agent.ts index 65b31881..06e72765 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -1,5 +1,12 @@ export { createAgentSession } from "./agent/session.js"; -export type { AgentProjectSnapshot, AgentSession, AgentSessionOptions } from "./agent/session.js"; +export type { + AgentFreshnessPolicy, + AgentFreshnessResult, + AgentProjectSnapshot, + AgentSession, + AgentSessionFreshnessOptions, + AgentSessionOptions, +} from "./agent/session.js"; export { orientCodegraph } from "./agent/orient.js"; export type { AgentModuleSummary, diff --git a/src/agent/session.ts b/src/agent/session.ts index 67d3d9dc..33a07aed 100644 --- a/src/agent/session.ts +++ b/src/agent/session.ts @@ -1,3 +1,5 @@ +import fsp from "node:fs/promises"; +import path from "node:path"; import { buildProjectIndexIncremental } from "../indexer/build-index.js"; import type { BuildOptions, BuildReport, ProjectIndex } from "../indexer/types.js"; import { buildSymbolGraphDetailed } from "../graphs/symbol-graph-detailed.js"; @@ -23,27 +25,116 @@ export type AgentLoadProjectOptions = { symbolGraph?: "eager" | "skip"; }; +export type AgentFreshnessPolicy = "manual" | "check" | "auto"; + +export type AgentFreshnessResult = + | { state: "fresh" } + | { state: "refreshed"; changedFiles: string[] } + | { state: "stale"; changedFiles: string[]; reason: string }; + +export type AgentSessionFreshnessOptions = { + policy?: AgentFreshnessPolicy; + maxAutoRefreshFiles?: number; + maxAutoRefreshBytes?: number; +}; + export type AgentSessionOptions = { root: string; discovery?: ProjectFileDiscoveryOptions; buildOptions?: BuildOptions; useConfig?: boolean; + freshness?: AgentSessionFreshnessOptions; }; export type AgentSession = { root?: string; listFiles?: () => Promise; loadProject: (loadOptions?: AgentLoadProjectOptions) => Promise; + checkFreshness?: () => Promise; invalidate: () => void; }; -type AgentProjectBaseSnapshot = Omit; - const EMPTY_SYMBOL_GRAPH: SymbolGraph = { nodes: new Map(), edges: [], }; const AGENT_NATIVE_WORKER_AUTO_FILE_THRESHOLD = 250; +const DEFAULT_MAX_AUTO_REFRESH_FILES = 50; +const DEFAULT_MAX_AUTO_REFRESH_BYTES = 2_000_000; + +type AgentProjectBaseSnapshot = Omit; + +type AgentFileSignature = { + file: string; + size: number; + mtimeMs: number; +}; + +type AgentDiscoverySettings = { + discoveryOptions?: ProjectFileDiscoveryOptions; +}; + +type AgentFreshnessDiff = { + changedFiles: string[]; + changedBytes: number; +}; + +async function resolveAgentDiscoverySettings(options: AgentSessionOptions): Promise { + const useConfig = options.useConfig ?? true; + const config = useConfig ? await loadCodegraphConfig(options.root) : {}; + const optionDiscovery = mergeDiscoveryOptions(options.buildOptions?.discovery, options.discovery); + const discovery = mergeDiscoveryOptions(config.discovery, optionDiscovery); + const discoveryOptions = hasDiscoveryOptions(discovery) + ? { ...discovery, globRoot: discovery.globRoot ?? options.root } + : undefined; + return discoveryOptions ? { discoveryOptions } : {}; +} + +async function collectAgentFileSignatures(files: readonly string[]): Promise> { + const signatures = new Map(); + await Promise.all( + files.map(async (file) => { + const resolvedFile = path.resolve(file); + try { + const stat = await fsp.stat(resolvedFile); + if (!stat.isFile()) return; + signatures.set(resolvedFile, { + file: resolvedFile, + size: stat.size, + mtimeMs: stat.mtimeMs, + }); + } catch { + return; + } + }), + ); + return signatures; +} + +function diffAgentFileSignatures( + previous: ReadonlyMap, + current: ReadonlyMap, +): AgentFreshnessDiff { + const changedFiles: string[] = []; + let changedBytes = 0; + for (const [file, currentSignature] of current.entries()) { + const previousSignature = previous.get(file); + if ( + !previousSignature || + previousSignature.size !== currentSignature.size || + previousSignature.mtimeMs !== currentSignature.mtimeMs + ) { + changedFiles.push(file); + changedBytes += currentSignature.size; + } + } + for (const file of previous.keys()) { + if (current.has(file)) continue; + changedFiles.push(file); + } + changedFiles.sort(); + return { changedFiles, changedBytes }; +} export function createAgentSession(options: AgentSessionOptions): AgentSession { let cachedFiles: Promise | undefined; @@ -51,18 +142,21 @@ export function createAgentSession(options: AgentSessionOptions): AgentSession { let cachedSymbolGraph: Promise | undefined; let cachedEagerSnapshot: Promise | undefined; let cachedSkippedSnapshot: Promise | undefined; + let cachedFileSignatures: Map | undefined; + + const invalidate = (): void => { + cachedFiles = undefined; + cachedBase = undefined; + cachedSymbolGraph = undefined; + cachedEagerSnapshot = undefined; + cachedSkippedSnapshot = undefined; + cachedFileSignatures = undefined; + }; const loadFiles = async (): Promise => { if (cachedFiles) return cachedFiles; const loadPromise = (async () => { - const useConfig = options.useConfig ?? true; - const config = useConfig ? await loadCodegraphConfig(options.root) : {}; - const optionDiscovery = mergeDiscoveryOptions(options.buildOptions?.discovery, options.discovery); - const discovery = mergeDiscoveryOptions(config.discovery, optionDiscovery); - const discoveryOptions = hasDiscoveryOptions(discovery) - ? { ...discovery, globRoot: discovery.globRoot ?? options.root } - : undefined; - + const { discoveryOptions } = await resolveAgentDiscoverySettings(options); return await listProjectFiles(options.root, undefined, discoveryOptions); })(); cachedFiles = loadPromise; @@ -75,14 +169,9 @@ export function createAgentSession(options: AgentSessionOptions): AgentSession { const loadBase = async (): Promise => { if (cachedBase) return cachedBase; const loadPromise = (async () => { - const useConfig = options.useConfig ?? true; - const config = useConfig ? await loadCodegraphConfig(options.root) : {}; - const optionDiscovery = mergeDiscoveryOptions(options.buildOptions?.discovery, options.discovery); - const discovery = mergeDiscoveryOptions(config.discovery, optionDiscovery); - const discoveryOptions = hasDiscoveryOptions(discovery) - ? { ...discovery, globRoot: discovery.globRoot ?? options.root } - : undefined; + const { discoveryOptions } = await resolveAgentDiscoverySettings(options); const files = await loadFiles(); + cachedFileSignatures = await collectAgentFileSignatures(files); const buildOptions: BuildOptions & { files: string[] } = { ...options.buildOptions, cache: options.buildOptions?.cache ?? "disk", @@ -151,16 +240,45 @@ export function createAgentSession(options: AgentSessionOptions): AgentSession { return await cachedEagerSnapshot; }; + const checkFreshness = async (): Promise => { + const policy = options.freshness?.policy ?? "check"; + if (policy === "manual") return { state: "fresh" }; + if (!cachedBase || !cachedFileSignatures) return { state: "fresh" }; + await cachedBase; + + const { discoveryOptions } = await resolveAgentDiscoverySettings(options); + const currentFiles = await listProjectFiles(options.root, undefined, discoveryOptions); + const currentSignatures = await collectAgentFileSignatures(currentFiles); + const diff = diffAgentFileSignatures(cachedFileSignatures, currentSignatures); + if (!diff.changedFiles.length) return { state: "fresh" }; + + const changedFiles = diff.changedFiles.map((file) => { + const relativeFile = path.relative(options.root, file).replace(/\\/g, "/"); + if (!relativeFile || relativeFile.startsWith("../")) return file.replace(/\\/g, "/"); + return relativeFile; + }); + if (policy === "check") { + return { state: "stale", changedFiles, reason: "session snapshot is older than files on disk" }; + } + + const maxFiles = options.freshness?.maxAutoRefreshFiles ?? DEFAULT_MAX_AUTO_REFRESH_FILES; + const maxBytes = options.freshness?.maxAutoRefreshBytes ?? DEFAULT_MAX_AUTO_REFRESH_BYTES; + if (diff.changedFiles.length > maxFiles) { + return { state: "stale", changedFiles, reason: `changed file count exceeds ${maxFiles}` }; + } + if (diff.changedBytes > maxBytes) { + return { state: "stale", changedFiles, reason: `changed byte count exceeds ${maxBytes}` }; + } + + invalidate(); + return { state: "refreshed", changedFiles }; + }; + return { root: options.root, listFiles: loadFiles, loadProject, - invalidate: () => { - cachedFiles = undefined; - cachedBase = undefined; - cachedSymbolGraph = undefined; - cachedEagerSnapshot = undefined; - cachedSkippedSnapshot = undefined; - }, + checkFreshness, + invalidate, }; } diff --git a/src/index.ts b/src/index.ts index 7fafd763..a147ea94 100644 --- a/src/index.ts +++ b/src/index.ts @@ -226,7 +226,14 @@ export { /** Agent project snapshots and cached service-layer helpers. */ export { createAgentSession } from "./agent/session.js"; -export type { AgentProjectSnapshot, AgentSession, AgentSessionOptions } from "./agent/session.js"; +export type { + AgentFreshnessPolicy, + AgentFreshnessResult, + AgentProjectSnapshot, + AgentSession, + AgentSessionFreshnessOptions, + AgentSessionOptions, +} from "./agent/session.js"; /** Agent first-turn orientation packets with file-path follow-up targets. */ export { orientCodegraph } from "./agent/orient.js"; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 222ed7a3..d96f8fdf 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -27,8 +27,8 @@ import { buildReviewReport, type ReviewDepth, type ReviewReport } from "../revie import { queryGraphSqliteRaw, type RawSqlResult } from "../sqlite.js"; import { toProjectDisplayPath } from "../util/paths.js"; import { createAgentSession } from "../agent/session.js"; -import type { AgentSession } from "../agent/session.js"; -import type { BuildOptions } from "../indexer/types.js"; +import type { AgentFreshnessResult, AgentProjectSnapshot, AgentSession } from "../agent/session.js"; +import type { BuildOptions, GoToResult } from "../indexer/types.js"; import { assertMcpSqliteQueryResourceBounded, boundRawSqlResult, @@ -98,6 +98,8 @@ export type CodegraphMcpHttpServer = CodegraphMcpHttpServerInfo & { close: () => Promise; }; +export type CodegraphMcpFreshResult = T & { freshness: AgentFreshnessResult }; + export type CodegraphMcpHandlers = { search: (request: { query: string; @@ -105,45 +107,45 @@ export type CodegraphMcpHandlers = { from?: string | undefined; depth?: number | undefined; limit?: number | undefined; - }) => Promise; + }) => Promise>; orient: (request: { includeRoots?: string[] | undefined; budget?: AgentOrientBudget | undefined; - }) => Promise; + }) => Promise>; packet_get: (request: { target: string; maxSymbols?: number | undefined; maxSnippets?: number | undefined; maxDuplicates?: number | undefined; - }) => Promise; + }) => Promise>; get_file: (request: { file: string; maxBytes?: number | undefined; - }) => Promise<{ file: string; text: string; truncated: boolean }>; - get_symbol: (request: { handle: string }) => Promise; - goto: (request: { - file: string; - line: number; - column: number; - }) => Promise>>; + }) => Promise>; + get_symbol: (request: { handle: string }) => Promise>; + goto: (request: { file: string; line: number; column: number }) => Promise>; refs: ( request: | { handle: string; limit?: number | undefined } | { file: string; line: number; column: number; limit?: number | undefined }, - ) => Promise<{ references: AgentExplanationReference[] }>; + ) => Promise>; deps: (request: { file: string; depth?: number | undefined; limit?: number | undefined; - }) => Promise<{ dependencies: Array<{ file: string; depth: number }> }>; + }) => Promise }>>; rdeps: (request: { file: string; depth?: number | undefined; limit?: number | undefined; - }) => Promise<{ reverseDependencies: Array<{ file: string; depth: number }> }>; - path: (request: { from: string; to: string }) => Promise<{ path: string[] | null }>; - impact: (request: { base: string; head: string }) => Promise; - review: (request: { base: string; head: string; reviewDepth?: ReviewDepth | undefined }) => Promise; + }) => Promise }>>; + path: (request: { from: string; to: string }) => Promise>; + impact: (request: { base: string; head: string }) => Promise>; + review: (request: { + base: string; + head: string; + reviewDepth?: ReviewDepth | undefined; + }) => Promise>; refresh_index: (request: { warmup?: CodegraphMcpWarmupMode | undefined }) => Promise<{ refreshed: true; warmup: CodegraphMcpWarmupMode; @@ -160,7 +162,7 @@ export type CodegraphMcpHandlers = { report?: boolean | undefined; questions?: boolean | undefined; force?: boolean | undefined; - }) => Promise; + }) => Promise>; }; type McpDependencyRequest = { @@ -184,14 +186,18 @@ function createCodegraphMcpSession(options: CodegraphMcpHandlerOptions, root: st assertMcpSessionOptions(options); return ( options.session ?? - createAgentSession({ root, ...(options.buildOptions ? { buildOptions: options.buildOptions } : {}) }) + createAgentSession({ + root, + ...(options.buildOptions ? { buildOptions: options.buildOptions } : {}), + freshness: { policy: "auto" }, + }) ); } function startCodegraphMcpWarmup( session: AgentSession, warmup: CodegraphMcpWarmupMode | undefined, -): Promise>> | undefined { +): Promise | undefined { if (warmup === "base") { return session.loadProject({ symbolGraph: "skip" }); } @@ -239,10 +245,18 @@ function createCodegraphMcpHandlersForSession( if (typeof limit !== "number" || !Number.isFinite(limit)) return fallback; return Math.min(max, Math.max(0, Math.floor(limit))); }; + const withFreshness = async ( + run: () => Promise, + ): Promise => { + const freshness = session.checkFreshness ? await session.checkFreshness() : { state: "fresh" as const }; + const result = await run(); + return { ...result, freshness }; + }; + const collectMcpDependencyEntries = async ( request: McpDependencyRequest, collectEntries: ( - graph: Awaited>["fileGraph"], + graph: AgentProjectSnapshot["fileGraph"], file: string, options: { depth?: number; limit: number }, ) => DependencyNode[], @@ -264,141 +278,163 @@ function createCodegraphMcpHandlersForSession( return { search: async (request) => - await searchCodegraphWithSession(session, { - root, - query: request.query, - ...(request.mode !== undefined ? { mode: request.mode } : {}), - ...(request.from !== undefined ? { from: request.from } : {}), - ...(request.depth !== undefined ? { depth: request.depth } : {}), - ...(request.limit !== undefined ? { limit: request.limit } : {}), - }), + await withFreshness( + async () => + await searchCodegraphWithSession(session, { + root, + query: request.query, + ...(request.mode !== undefined ? { mode: request.mode } : {}), + ...(request.from !== undefined ? { from: request.from } : {}), + ...(request.depth !== undefined ? { depth: request.depth } : {}), + ...(request.limit !== undefined ? { limit: request.limit } : {}), + }), + ), orient: async (request) => - await orientCodegraphWithSession(session, { - root, - ...(request.includeRoots !== undefined ? { includeRoots: request.includeRoots } : {}), - ...(request.budget !== undefined ? { budget: request.budget } : {}), - }), + await withFreshness( + async () => + await orientCodegraphWithSession(session, { + root, + ...(request.includeRoots !== undefined ? { includeRoots: request.includeRoots } : {}), + ...(request.budget !== undefined ? { budget: request.budget } : {}), + }), + ), packet_get: async (request) => - await getCodegraphPacketWithSession(session, { - root, - target: request.target, - ...(request.maxSymbols !== undefined ? { maxSymbols: request.maxSymbols } : {}), - ...(request.maxSnippets !== undefined ? { maxSnippets: request.maxSnippets } : {}), - ...(request.maxDuplicates !== undefined ? { maxDuplicates: request.maxDuplicates } : {}), + await withFreshness( + async () => + await getCodegraphPacketWithSession(session, { + root, + target: request.target, + ...(request.maxSymbols !== undefined ? { maxSymbols: request.maxSymbols } : {}), + ...(request.maxSnippets !== undefined ? { maxSnippets: request.maxSnippets } : {}), + ...(request.maxDuplicates !== undefined ? { maxDuplicates: request.maxDuplicates } : {}), + }), + ), + + get_file: async (request) => + await withFreshness(async () => { + const resolvedFile = await resolveReadableFile(await realRoot, root, request.file); + const maxBytes = boundedLimit(request.maxBytes, DEFAULT_FILE_BYTES, MAX_FILE_BYTES); + const read = await readFilePrefix(resolvedFile.realPath, maxBytes); + return { + file: resolvedFile.displayPath, + text: read.text, + truncated: read.truncated, + }; }), - get_file: async (request) => { - const resolvedFile = await resolveReadableFile(await realRoot, root, request.file); - const maxBytes = boundedLimit(request.maxBytes, DEFAULT_FILE_BYTES, MAX_FILE_BYTES); - const read = await readFilePrefix(resolvedFile.realPath, maxBytes); - return { - file: resolvedFile.displayPath, - text: read.text, - truncated: read.truncated, - }; - }, - - get_symbol: async (request) => { - const explanation = await explainCodegraphTargetWithSession(session, { root, target: request.handle }); - return explanation.target; - }, - - goto: async (request) => { - const snapshot = await session.loadProject({ symbolGraph: "skip" }); - return await goToDefinition(snapshot.index, { - file: await resolveProjectFile(await realRoot, root, request.file), - line: request.line, - column: request.column, - }); - }, - - refs: async (request) => { - const handle = "handle" in request ? request.handle : undefined; - const file = "file" in request ? request.file : undefined; - const line = "line" in request ? request.line : undefined; - const column = "column" in request ? request.column : undefined; - const hasAnyPosition = file !== undefined || line !== undefined || column !== undefined; - const hasCompletePosition = file !== undefined && line !== undefined && column !== undefined; - if (handle !== undefined && hasAnyPosition) { - throw new Error("refs requires either handle or file, line, and column."); - } - if (handle === undefined && !hasCompletePosition) { - throw new Error("refs requires either handle or file, line, and column."); - } + get_symbol: async (request) => + await withFreshness(async () => { + const explanation = await explainCodegraphTargetWithSession(session, { root, target: request.handle }); + return explanation.target; + }), - if (handle !== undefined) { - const explanation = await explainCodegraphTargetWithSession(session, { - root, - target: handle, - maxReferences: boundedLimit(request.limit, DEFAULT_MCP_COLLECTION_LIMIT, MAX_MCP_COLLECTION_LIMIT), + goto: async (request) => + await withFreshness(async () => { + const snapshot = await session.loadProject({ symbolGraph: "skip" }); + return await goToDefinition(snapshot.index, { + file: await resolveProjectFile(await realRoot, root, request.file), + line: request.line, + column: request.column, }); - return { references: explanation.references }; - } - if (file === undefined || line === undefined || column === undefined) { - throw new Error("refs requires either handle or file, line, and column."); - } + }), - const snapshot = await session.loadProject({ symbolGraph: "skip" }); - const referenceOptions = { - maxReferences: boundedLimit(request.limit, DEFAULT_MCP_COLLECTION_LIMIT, MAX_MCP_COLLECTION_LIMIT), - }; - const result = await findReferences( - snapshot.index, - { - file: await resolveProjectFile(await realRoot, root, file), - line, - column, - }, - referenceOptions, - ); - if (result.status !== "ok") return { references: [] }; - return { - references: result.references.map((reference) => ({ - file: relative(reference.file), - range: reference.range, - })), - }; - }, + refs: async (request) => + await withFreshness(async () => { + const handle = "handle" in request ? request.handle : undefined; + const file = "file" in request ? request.file : undefined; + const line = "line" in request ? request.line : undefined; + const column = "column" in request ? request.column : undefined; + const hasAnyPosition = file !== undefined || line !== undefined || column !== undefined; + const hasCompletePosition = file !== undefined && line !== undefined && column !== undefined; + if (handle !== undefined && hasAnyPosition) { + throw new Error("refs requires either handle or file, line, and column."); + } + if (handle === undefined && !hasCompletePosition) { + throw new Error("refs requires either handle or file, line, and column."); + } + + if (handle !== undefined) { + const explanation = await explainCodegraphTargetWithSession(session, { + root, + target: handle, + maxReferences: boundedLimit(request.limit, DEFAULT_MCP_COLLECTION_LIMIT, MAX_MCP_COLLECTION_LIMIT), + }); + return { references: explanation.references }; + } + if (file === undefined || line === undefined || column === undefined) { + throw new Error("refs requires either handle or file, line, and column."); + } + + const snapshot = await session.loadProject({ symbolGraph: "skip" }); + const referenceOptions = { + maxReferences: boundedLimit(request.limit, DEFAULT_MCP_COLLECTION_LIMIT, MAX_MCP_COLLECTION_LIMIT), + }; + const result = await findReferences( + snapshot.index, + { + file: await resolveProjectFile(await realRoot, root, file), + line, + column, + }, + referenceOptions, + ); + if (result.status !== "ok") return { references: [] }; + return { + references: result.references.map((reference) => ({ + file: relative(reference.file), + range: reference.range, + })), + }; + }), - deps: async (request) => { - const dependencies = await collectMcpDependencyEntries(request, getDependencies); - return { dependencies }; - }, + deps: async (request) => + await withFreshness(async () => { + const dependencies = await collectMcpDependencyEntries(request, getDependencies); + return { dependencies }; + }), - rdeps: async (request) => { - const reverseDependencies = await collectMcpDependencyEntries(request, getReverseDependencies); - return { reverseDependencies }; - }, + rdeps: async (request) => + await withFreshness(async () => { + const reverseDependencies = await collectMcpDependencyEntries(request, getReverseDependencies); + return { reverseDependencies }; + }), - path: async (request) => { - const snapshot = await session.loadProject({ symbolGraph: "skip" }); - const result = getShortestPath( - snapshot.fileGraph, - await resolveProjectFile(await realRoot, root, request.from), - await resolveProjectFile(await realRoot, root, request.to), - ); - return { - path: result ? result.map(relative) : null, - }; - }, + path: async (request) => + await withFreshness(async () => { + const snapshot = await session.loadProject({ symbolGraph: "skip" }); + const result = getShortestPath( + snapshot.fileGraph, + await resolveProjectFile(await realRoot, root, request.from), + await resolveProjectFile(await realRoot, root, request.to), + ); + return { + path: result ? result.map(relative) : null, + }; + }), impact: async (request) => - await buildReviewReport(root, { - ...options.buildOptions, - gitBase: request.base, - gitHead: request.head, - reviewDepth: "minimal", - }), + await withFreshness( + async () => + await buildReviewReport(root, { + ...options.buildOptions, + gitBase: request.base, + gitHead: request.head, + reviewDepth: "minimal", + }), + ), review: async (request) => - await buildReviewReport(root, { - ...options.buildOptions, - gitBase: request.base, - gitHead: request.head, - ...(request.reviewDepth !== undefined ? { reviewDepth: request.reviewDepth } : {}), - }), + await withFreshness( + async () => + await buildReviewReport(root, { + ...options.buildOptions, + gitBase: request.base, + gitHead: request.head, + ...(request.reviewDepth !== undefined ? { reviewDepth: request.reviewDepth } : {}), + }), + ), query_sqlite: async (request) => { if (!sqlitePath) { @@ -420,35 +456,36 @@ function createCodegraphMcpHandlersForSession( return { refreshed: true, warmup }; }, - artifact_build: async (request) => { - if (readOnly) { - throw new Error("artifact_build is disabled in read-only MCP mode."); - } - const outDir = - request.outDir !== undefined - ? await assertWritableDirectoryRealPathWithinRoot( - await realRoot, - root, - request.outDir, - "Artifact output directory", - ) - : undefined; - const result = await buildCodegraphArtifactWithSession(session, { - root, - ...(outDir !== undefined ? { outDir } : {}), - ...(request.outDir !== undefined ? { filterOutDir: request.outDir } : {}), - ...(request.sqlite !== undefined ? { sqlite: request.sqlite } : {}), - ...(request.graphJson !== undefined ? { graphJson: request.graphJson } : {}), - ...(request.report !== undefined ? { report: request.report } : {}), - ...(request.questions !== undefined ? { questions: request.questions } : {}), - ...(request.force !== undefined ? { force: request.force } : {}), - }); - const sqliteArtifact = result.artifacts.sqlite; - if (sqliteArtifact) { - sqlitePath = path.join(result.outDir, sqliteArtifact); - } - return result; - }, + artifact_build: async (request) => + await withFreshness(async () => { + if (readOnly) { + throw new Error("artifact_build is disabled in read-only MCP mode."); + } + const outDir = + request.outDir !== undefined + ? await assertWritableDirectoryRealPathWithinRoot( + await realRoot, + root, + request.outDir, + "Artifact output directory", + ) + : undefined; + const result = await buildCodegraphArtifactWithSession(session, { + root, + ...(outDir !== undefined ? { outDir } : {}), + ...(request.outDir !== undefined ? { filterOutDir: request.outDir } : {}), + ...(request.sqlite !== undefined ? { sqlite: request.sqlite } : {}), + ...(request.graphJson !== undefined ? { graphJson: request.graphJson } : {}), + ...(request.report !== undefined ? { report: request.report } : {}), + ...(request.questions !== undefined ? { questions: request.questions } : {}), + ...(request.force !== undefined ? { force: request.force } : {}), + }); + const sqliteArtifact = result.artifacts.sqlite; + if (sqliteArtifact) { + sqlitePath = path.join(result.outDir, sqliteArtifact); + } + return result; + }), }; } diff --git a/tests/agent-session.test.ts b/tests/agent-session.test.ts index 976c76f9..9e614ac2 100644 --- a/tests/agent-session.test.ts +++ b/tests/agent-session.test.ts @@ -156,4 +156,28 @@ describe("agent session", () => { expect(files).toContain(keptFile.replace(/\\/g, "/")); expect(files).not.toContain(ignoredFile.replace(/\\/g, "/")); }); + + it("reports stale file edits in check policy without invalidating cached project state", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-agent-session-check-fresh-")); + const filePath = path.join(root, "auth.ts"); + await fs.writeFile(filePath, "export function oldSymbol() { return 1; }\n", "utf8"); + const buildSpy = vi.spyOn(indexerBuild, "buildProjectIndexIncremental"); + const session = createAgentSession({ root, freshness: { policy: "check" } }); + const cached = await session.loadProject({ symbolGraph: "skip" }); + if (!session.checkFreshness) { + throw new Error("agent session should expose freshness checks"); + } + + await fs.writeFile(filePath, "export function editedSymbol() { return 22; }\n", "utf8"); + const freshness = await session.checkFreshness(); + const afterCheck = await session.loadProject({ symbolGraph: "skip" }); + + expect(freshness).toEqual({ + state: "stale", + changedFiles: ["auth.ts"], + reason: "session snapshot is older than files on disk", + }); + expect(afterCheck).toBe(cached); + expect(buildSpy).toHaveBeenCalledTimes(1); + }); }); diff --git a/tests/helpers/agent.ts b/tests/helpers/agent.ts index 90dbc799..6a5d4a7c 100644 --- a/tests/helpers/agent.ts +++ b/tests/helpers/agent.ts @@ -4,25 +4,37 @@ export function countingSession(session: AgentSession): { session: AgentSession; let cached: Promise | undefined; let cachedMode: AgentLoadProjectOptions["symbolGraph"] | undefined; let loadCount = 0; - return { - session: { - ...(session.root ? { root: session.root } : {}), - ...(session.listFiles ? { listFiles: session.listFiles } : {}), - loadProject: async (options) => { - const mode = options?.symbolGraph ?? "eager"; - if (!cached || cachedMode !== mode) { - loadCount += 1; - cachedMode = mode; - cached = session.loadProject(options); - } - return await cached; - }, - invalidate: () => { + const countedSession: AgentSession = { + ...(session.root ? { root: session.root } : {}), + ...(session.listFiles ? { listFiles: session.listFiles } : {}), + loadProject: async (options) => { + const mode = options?.symbolGraph ?? "eager"; + if (!cached || cachedMode !== mode) { + loadCount += 1; + cachedMode = mode; + cached = session.loadProject(options); + } + return await cached; + }, + invalidate: () => { + cached = undefined; + cachedMode = undefined; + session.invalidate(); + }, + }; + const checkFreshness = session.checkFreshness; + if (checkFreshness) { + countedSession.checkFreshness = async () => { + const freshness = await checkFreshness(); + if (freshness.state === "refreshed") { cached = undefined; cachedMode = undefined; - session.invalidate(); - }, - }, + } + return freshness; + }; + } + return { + session: countedSession, loads: () => loadCount, }; } diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index fa1c8484..26a74853 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -4,7 +4,12 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { createAgentSession } from "../src/agent/session.js"; -import { createCodegraphMcpHandlers, listCodegraphMcpTools, startCodegraphMcpHttpServer } from "../src/mcp/server.js"; +import { + createCodegraphMcpHandlers, + listCodegraphMcpTools, + startCodegraphMcpHttpServer, + type CodegraphMcpHandlers, +} from "../src/mcp/server.js"; import * as symbolGraphBuild from "../src/graphs/symbol-graph-detailed.js"; import { countingSession } from "./helpers/agent.js"; import { createArtifactOutputWithStaleFile, createLinkedTempRoot, isSymlinkUnavailable } from "./helpers/filesystem.js"; @@ -509,7 +514,7 @@ describe("codegraph MCP handlers", () => { const result = await handlers.get_file({ file: "large.txt", maxBytes: 5 }); - expect(result).toEqual({ + expect(result).toMatchObject({ file: "large.txt", text: "abcde", truncated: true, @@ -523,7 +528,7 @@ describe("codegraph MCP handlers", () => { const result = await handlers.get_file({ file: "unicode.txt", maxBytes: 5 }); - expect(result).toEqual({ + expect(result).toMatchObject({ file: "unicode.txt", text: "abc", truncated: true, @@ -662,7 +667,7 @@ describe("codegraph MCP handlers", () => { const scenarios: Array<{ name: string; - run: (handlers: ReturnType) => Promise; + run: (handlers: CodegraphMcpHandlers) => Promise; }> = [ { name: "goto", @@ -728,37 +733,51 @@ describe("codegraph MCP handlers", () => { expect(counted.loads()).toBe(0); }); - it("refresh_index invalidates stale MCP session snapshots", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-refresh-index-")); + it("auto-refreshes added files before MCP search", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-auto-fresh-add-")); await fs.writeFile(path.join(root, "auth.ts"), "export const ok = 1;\n", "utf8"); const handlers = createCodegraphMcpHandlers({ root }); const before = await handlers.search({ query: "lateSymbol", mode: "symbol", limit: 5 }); - await fs.writeFile(path.join(root, "late.ts"), "export const lateSymbol = 1;\n", "utf8"); - const stale = await handlers.search({ query: "lateSymbol", mode: "symbol", limit: 5 }); - const refresh = await handlers.refresh_index({ warmup: "base" }); - const fresh = await handlers.search({ query: "lateSymbol", mode: "symbol", limit: 5 }); + await fs.writeFile(path.join(root, "late.ts"), "export function lateSymbol() { return 1; }\n", "utf8"); + const after = await handlers.search({ query: "lateSymbol", mode: "symbol", limit: 5 }); expect(before.results.some((result) => result.label === "lateSymbol")).toBe(false); - expect(stale.results.some((result) => result.label === "lateSymbol")).toBe(false); - expect(refresh).toEqual({ refreshed: true, warmup: "base" }); - expect(fresh.results.some((result) => result.label === "lateSymbol")).toBe(true); + expect(after.results.some((result) => result.label === "lateSymbol")).toBe(true); + expect(after.freshness).toEqual({ state: "refreshed", changedFiles: ["late.ts"] }); }); - it("refresh_index reloads edited files in stale MCP session snapshots", async () => { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-refresh-edit-")); + it("auto-refreshes edited files before MCP search and reports root-relative changed paths", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-auto-fresh-edit-")); const filePath = path.join(root, "auth.ts"); - await fs.writeFile(filePath, "export const oldSymbol = 1;\n", "utf8"); + await fs.writeFile(filePath, "export function oldSymbol() { return 1; }\n", "utf8"); const handlers = createCodegraphMcpHandlers({ root }); - await handlers.search({ query: "oldSymbol", mode: "symbol", limit: 5 }); - await fs.writeFile(filePath, "export const editedSymbol = 1;\n", "utf8"); - const stale = await handlers.search({ query: "editedSymbol", mode: "symbol", limit: 5 }); - await handlers.refresh_index({}); - const fresh = await handlers.search({ query: "editedSymbol", mode: "symbol", limit: 5 }); + const before = await handlers.search({ query: "oldSymbol", mode: "symbol", limit: 5 }); + await fs.writeFile(filePath, "export function editedSymbol() { return 2; }\n", "utf8"); + const after = await handlers.search({ query: "editedSymbol", mode: "symbol", limit: 5 }); + + expect(before.results.some((result) => result.label === "oldSymbol")).toBe(true); + expect(after.results.some((result) => result.label === "editedSymbol")).toBe(true); + expect(after.freshness).toEqual({ state: "refreshed", changedFiles: ["auth.ts"] }); + }); + + it("returns live file bytes and freshness metadata after MCP file edits", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-auto-fresh-read-")); + const filePath = path.join(root, "auth.ts"); + await fs.writeFile(filePath, "export function readBefore() { return 'old'; }\n", "utf8"); + const handlers = createCodegraphMcpHandlers({ root }); - expect(stale.results.some((result) => result.label === "editedSymbol")).toBe(false); - expect(fresh.results.some((result) => result.label === "editedSymbol")).toBe(true); + await handlers.search({ query: "readBefore", mode: "symbol", limit: 5 }); + await fs.writeFile(filePath, "export function readAfter() { return 'new bytes'; }\n", "utf8"); + const read = await handlers.get_file({ file: "auth.ts" }); + + expect(read).toEqual({ + file: "auth.ts", + text: "export function readAfter() { return 'new bytes'; }\n", + truncated: false, + freshness: { state: "refreshed", changedFiles: ["auth.ts"] }, + }); }); it("refresh_index clears stale SQLite artifact state", async () => { From b8bb529fa8027100c7944d58a562d318c3ee7a3d Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Fri, 3 Jul 2026 03:30:54 -0400 Subject: [PATCH 02/26] Fix MCP freshness review findings --- codegraph-skill/codegraph/SKILL.md | 4 +-- docs/library-api.md | 11 +++--- docs/mcp.md | 12 +++---- src/agent/session.ts | 51 ++++++++++++++++++++++---- src/mcp/server.ts | 58 +++++++++++++++++++++++++++--- tests/agent-session.test.ts | 2 ++ tests/mcp-server.test.ts | 54 ++++++++++++++++++++++++++++ 7 files changed, 170 insertions(+), 22 deletions(-) diff --git a/codegraph-skill/codegraph/SKILL.md b/codegraph-skill/codegraph/SKILL.md index 16a9bea6..a49a93d6 100644 --- a/codegraph-skill/codegraph/SKILL.md +++ b/codegraph-skill/codegraph/SKILL.md @@ -65,8 +65,8 @@ Treat duplicate leads and call-compatibility hints as review leads, not proof. ## MCP If MCP tools are available, prefer them over repeated CLI invocations. -Use MCP `orient`, `search`, `packet_get`, `goto`, `refs`, `deps`, `rdeps`, `path`, `impact`, and `review` first. -After edits, check MCP response `freshness`: `refreshed` means Codegraph rebuilt before answering, and `stale` means use `refresh_index` or live file reads before trusting indexed context. +Use MCP `orient`, `search`, `packet_get`, `goto`, `refs`, `deps`, `rdeps`, `path`, `impact`, `review`, and `query_sqlite` first. +After edits, check MCP response `freshness`: `refreshed` means Codegraph rebuilt before answering, and `stale` includes a reason plus bounded changed-file metadata before indexed context is trusted. Fall back to CLI when MCP is unavailable. ## Discovery diff --git a/docs/library-api.md b/docs/library-api.md index 374fdb1d..3feacdde 100644 --- a/docs/library-api.md +++ b/docs/library-api.md @@ -162,7 +162,9 @@ console.log(artifact.manifestPath, artifact.artifacts); The `graph.json` artifact is self-describing (`schemaVersion: 1`, `format: "codegraph.graph-json"`) and uses project-relative file paths and portable symbol handles. `questions.json` uses the same stable handles for follow-up commands. With `force: true`, stale known Codegraph artifact files are removed before the selected outputs are written; unrelated files in the directory are preserved. -`createAgentSession()` keeps one in-process project snapshot warm for repeated orient, search, explain, packet, artifact, and MCP calls. It uses incremental indexing with disk cache by default, auto-enables native workers for large cold builds, and carries forward top-level analysis metadata from the build report. Session callers can use `freshness: { policy: "check" | "auto" | "manual" }` plus `checkFreshness()` to detect file edits before reusing a warm snapshot; MCP-created sessions use automatic freshness checks. Set `buildOptions.useNativeWorkers` to `false` to opt out. Use `buildCodegraphArtifactWithSession()` when a host already has a session and wants SQLite, graph JSON, report, questions, and manifest outputs from the same snapshot. `createCodegraphMcpHandlers()` exposes the same primitives without starting stdio, which is useful for tests or host applications: +`createAgentSession()` keeps one in-process project snapshot warm for repeated orient, search, explain, packet, artifact, and MCP calls. It uses incremental indexing with disk cache by default, auto-enables native workers for large cold builds, and carries forward top-level analysis metadata from the build report. +Session callers can use `freshness: { policy: "check" | "auto" | "manual" }` plus `checkFreshness()` to detect file edits before reusing a warm snapshot. `check` reports stale state without invalidating, `auto` invalidates for bounded changes, and stale results include `changedFileCount`, `omittedChangedFileCount`, `reason`, and a bounded changed-file sample. +Set `buildOptions.useNativeWorkers` to `false` to opt out. Use `buildCodegraphArtifactWithSession()` when a host already has a session and wants SQLite, graph JSON, report, questions, and manifest outputs from the same snapshot. `createCodegraphMcpHandlers()` exposes the same primitives without starting stdio, which is useful for tests or host applications: ```ts import { createCodegraphMcpHandlers } from "@lzehrung/codegraph"; @@ -179,11 +181,12 @@ const packet = await handlers.packet_get({ target: orient.focus.find((entry) => const refs = await handlers.refs({ handle: search.results[0]!.handle }); const rows = await handlers.query_sqlite({ query: "select path from files", limit: 5 }); console.log(search.freshness.state); -console.log(packet.kind, refs.references, rows.rows); +console.log(packet.kind, refs.references, rows.rows, rows.freshness.state); ``` -`serveCodegraphMcp()` starts the stdio server used by `codegraph mcp serve`. MCP is an agent ergonomics and cache layer over the same analysis engine, not a separate indexer. MCP file and artifact paths are confined after realpath resolution. `query_sqlite` is read-only and row- and byte-bounded; `artifact_build` is disabled by default and requires `readOnly: false` or CLI `--allow-build`. -MCP `orient` and `packet_get` calls use the server-configured root; they do not accept per-request root overrides. Index-backed MCP responses include `freshness` metadata so hosts can distinguish fresh, auto-refreshed, and stale snapshots. +`serveCodegraphMcp()` starts the stdio server used by `codegraph mcp serve`. MCP is an agent ergonomics and cache layer over the same analysis engine, not a separate indexer. MCP file and artifact paths are confined after realpath resolution. +`query_sqlite` is read-only and row- and byte-bounded. It returns freshness metadata for fresh artifact reads, refreshes Codegraph-owned SQLite artifacts after small edits when write access is enabled, and rejects stale artifact queries it cannot refresh safely. +`artifact_build` is disabled by default and requires `readOnly: false` or CLI `--allow-build`. MCP `orient` and `packet_get` calls use the server-configured root; they do not accept per-request root overrides. See [MCP server](./mcp.md) for CLI server setup and client configuration examples. diff --git a/docs/mcp.md b/docs/mcp.md index beb46171..f2ef908b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -44,13 +44,13 @@ The server exposes the same bounded primitives as the CLI and library session la - `refs`: references by handle or file position. - `deps`, `rdeps`, `path`: dependency navigation. - `impact`, `review`: git-range risk and review context. -- `query_sqlite`: bounded read-only SQLite artifact query. +- `query_sqlite`: bounded read-only SQLite artifact query with freshness metadata. - `refresh_index`: invalidate the in-memory session and optionally rebuild the base or symbol snapshot. - `artifact_build`: artifact creation, available only with write access enabled. MCP keeps one Codegraph session warm for the configured root. That makes follow-up calls cheaper than separate CLI invocations. Startup is lazy unless `--warmup` or `--warmup-symbols` is passed. -Before index-backed tool calls, MCP checks whether discovered files changed since the warm snapshot. Small changes refresh the session automatically, and responses include `freshness.state` as `fresh`, `refreshed`, or `stale` with changed file paths when applicable. -Use `refresh_index` when you need to force a rebuild, reset SQLite artifact state, or refresh after a change burst that exceeds the automatic refresh limits. +Before index-backed tool calls, MCP checks whether discovered files changed since the warm snapshot. Small changes refresh the session automatically, and responses include `freshness.state` as `fresh`, `refreshed`, or `stale`; stale responses also include `changedFileCount`, `omittedChangedFileCount`, and a bounded changed-file sample. +Use `refresh_index` when you need to force a rebuild, reset SQLite artifact state, or refresh after a change burst that exceeds the automatic refresh limits. `query_sqlite` refreshes Codegraph-owned SQLite artifacts after small edits when write access is enabled; otherwise it refuses to serve stale artifact rows. Tool schemas are flat JSON objects for broad client compatibility; argument combinations such as `refs` handle-vs-position mode are validated by the server. ## Safety @@ -59,7 +59,7 @@ Tool schemas are flat JSON objects for broad client compatibility; argument comb - Tool calls do not accept per-request root overrides. - Tools are read-only by default. - `artifact_build` requires `--allow-build`. -- `query_sqlite` rejects mutating SQL, recursive queries, and synthetic payload functions. +- `query_sqlite` rejects mutating SQL, recursive queries, synthetic payload functions, and stale artifact queries it cannot refresh safely. - SQLite responses are row- and byte-bounded. ## Client Configuration Examples @@ -196,9 +196,9 @@ When Codegraph MCP tools are available to an agent: 1. Start with `orient`. 2. Use `search` to find anchors. 3. Use `packet_get`, `refs`, `goto`, `deps`, `rdeps`, or `path` for focused follow-up. -4. Check `freshness` on MCP responses after edits; `refreshed` means the answer used an updated snapshot, and `stale` means the tool reported changed files without silently trusting old index data. +4. Check `freshness` on MCP responses after edits; `refreshed` means the answer used an updated snapshot, and `stale` includes a reason plus a bounded changed-file sample. 5. Use `impact` and `review` for git-range risk analysis. -6. Use `query_sqlite` only for read-only artifact inspection. +6. Use `query_sqlite` only for read-only artifact inspection; rebuild the artifact when it reports stale state. 7. Use `refresh_index` when you need an explicit rebuild. 8. Use `artifact_build` only when write access was intentionally enabled. diff --git a/src/agent/session.ts b/src/agent/session.ts index 33a07aed..3f2972cb 100644 --- a/src/agent/session.ts +++ b/src/agent/session.ts @@ -30,7 +30,13 @@ export type AgentFreshnessPolicy = "manual" | "check" | "auto"; export type AgentFreshnessResult = | { state: "fresh" } | { state: "refreshed"; changedFiles: string[] } - | { state: "stale"; changedFiles: string[]; reason: string }; + | { + state: "stale"; + changedFiles: string[]; + changedFileCount: number; + omittedChangedFileCount: number; + reason: string; + }; export type AgentSessionFreshnessOptions = { policy?: AgentFreshnessPolicy; @@ -61,6 +67,7 @@ const EMPTY_SYMBOL_GRAPH: SymbolGraph = { const AGENT_NATIVE_WORKER_AUTO_FILE_THRESHOLD = 250; const DEFAULT_MAX_AUTO_REFRESH_FILES = 50; const DEFAULT_MAX_AUTO_REFRESH_BYTES = 2_000_000; +const DEFAULT_MAX_FRESHNESS_CHANGED_FILES = 25; type AgentProjectBaseSnapshot = Omit; @@ -90,6 +97,25 @@ async function resolveAgentDiscoverySettings(options: AgentSessionOptions): Prom return discoveryOptions ? { discoveryOptions } : {}; } +function isMissingStatRace(error: unknown): boolean { + if (!(error instanceof Error)) return false; + if (!("code" in error)) return false; + return error.code === "ENOENT" || error.code === "ENOTDIR"; +} + +function summarizeChangedFiles(files: readonly string[]): { + changedFiles: string[]; + changedFileCount: number; + omittedChangedFileCount: number; +} { + const changedFiles = files.slice(0, DEFAULT_MAX_FRESHNESS_CHANGED_FILES); + return { + changedFiles, + changedFileCount: files.length, + omittedChangedFileCount: Math.max(0, files.length - changedFiles.length), + }; +} + async function collectAgentFileSignatures(files: readonly string[]): Promise> { const signatures = new Map(); await Promise.all( @@ -103,8 +129,9 @@ async function collectAgentFileSignatures(files: readonly string[]): Promise maxFiles) { - return { state: "stale", changedFiles, reason: `changed file count exceeds ${maxFiles}` }; + return { + state: "stale", + ...summarizeChangedFiles(changedFiles), + reason: `changed file count exceeds ${maxFiles}`, + }; } if (diff.changedBytes > maxBytes) { - return { state: "stale", changedFiles, reason: `changed byte count exceeds ${maxBytes}` }; + return { + state: "stale", + ...summarizeChangedFiles(changedFiles), + reason: `changed byte count exceeds ${maxBytes}`, + }; } invalidate(); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index d96f8fdf..668637b0 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -154,7 +154,7 @@ export type CodegraphMcpHandlers = { query: string; params?: Array | undefined; limit?: number | undefined; - }) => Promise; + }) => Promise>; artifact_build: (request: { outDir?: string | undefined; sqlite?: boolean | undefined; @@ -239,19 +239,66 @@ function createCodegraphMcpHandlersForSession( ? resolveArtifactSqlitePathCandidate(root, options.artifactPath) : undefined; let sqlitePath = configuredSqlitePath; + let sqliteOutDir = configuredSqlitePath ? path.dirname(configuredSqlitePath) : undefined; const relative = (file: string): string => toProjectDisplayPath(root, file); const boundedLimit = (limit: number | undefined, fallback: number, max: number): number => { if (typeof limit !== "number" || !Number.isFinite(limit)) return fallback; return Math.min(max, Math.max(0, Math.floor(limit))); }; + const checkMcpFreshness = async (): Promise => { + if (session.checkFreshness) return await session.checkFreshness(); + return { + state: "stale", + changedFiles: [], + changedFileCount: 0, + omittedChangedFileCount: 0, + reason: "freshness check unavailable", + }; + }; const withFreshness = async ( run: () => Promise, ): Promise => { - const freshness = session.checkFreshness ? await session.checkFreshness() : { state: "fresh" as const }; + const freshness = await checkMcpFreshness(); const result = await run(); return { ...result, freshness }; }; + const formatSqliteFreshnessError = (freshness: AgentFreshnessResult): string => { + if (freshness.state === "fresh") return "SQLite artifact freshness check unexpectedly failed."; + const reason = freshness.state === "stale" ? freshness.reason : "workspace changed after artifact build"; + const changed = freshness.changedFiles.length ? ` Changed files: ${freshness.changedFiles.join(", ")}.` : ""; + const omitted = + freshness.state === "stale" && freshness.omittedChangedFileCount + ? ` Omitted changed files: ${freshness.omittedChangedFileCount}.` + : ""; + return `SQLite artifact is stale; run artifact_build before query_sqlite. ${reason}.${changed}${omitted}`; + }; + const refreshSqliteArtifactForQuery = async (freshness: AgentFreshnessResult): Promise => { + if (freshness.state === "fresh") return; + if (!sqlitePath || !sqliteOutDir || readOnly || path.basename(sqlitePath) !== "codegraph.sqlite") { + throw new Error(formatSqliteFreshnessError(freshness)); + } + if (freshness.state === "stale") { + throw new Error(formatSqliteFreshnessError(freshness)); + } + const outDir = await assertWritableDirectoryRealPathWithinRoot( + await realRoot, + root, + sqliteOutDir, + "Artifact output directory", + ); + const result = await buildCodegraphArtifactWithSession(session, { + root, + outDir, + filterOutDir: outDir, + sqlite: true, + force: true, + }); + const sqliteArtifact = result.artifacts.sqlite; + if (!sqliteArtifact) throw new Error("SQLite artifact refresh did not produce a SQLite file."); + sqlitePath = path.join(result.outDir, sqliteArtifact); + sqliteOutDir = result.outDir; + }; const collectMcpDependencyEntries = async ( request: McpDependencyRequest, @@ -440,12 +487,14 @@ function createCodegraphMcpHandlersForSession( if (!sqlitePath) { throw new Error("No SQLite artifact is available. Run artifact_build first or pass artifactPath."); } - const realSqlitePath = await assertRealPathCandidateWithinRoot(await realRoot, sqlitePath, "SQLite artifact"); assertMcpSqliteQueryResourceBounded(request.query); + const freshness = await checkMcpFreshness(); + await refreshSqliteArtifactForQuery(freshness); + const realSqlitePath = await assertRealPathCandidateWithinRoot(await realRoot, sqlitePath, "SQLite artifact"); const result = await queryGraphSqliteRaw(realSqlitePath, request.query, request.params ?? [], { maxRows: normalizeSqliteRowLimit(request.limit), }); - return boundRawSqlResult(result, DEFAULT_SQLITE_BYTE_LIMIT); + return { ...boundRawSqlResult(result, DEFAULT_SQLITE_BYTE_LIMIT), freshness }; }, refresh_index: async (request) => { @@ -483,6 +532,7 @@ function createCodegraphMcpHandlersForSession( const sqliteArtifact = result.artifacts.sqlite; if (sqliteArtifact) { sqlitePath = path.join(result.outDir, sqliteArtifact); + sqliteOutDir = result.outDir; } return result; }), diff --git a/tests/agent-session.test.ts b/tests/agent-session.test.ts index 9e614ac2..071d0cf0 100644 --- a/tests/agent-session.test.ts +++ b/tests/agent-session.test.ts @@ -175,6 +175,8 @@ describe("agent session", () => { expect(freshness).toEqual({ state: "stale", changedFiles: ["auth.ts"], + changedFileCount: 1, + omittedChangedFileCount: 0, reason: "session snapshot is older than files on disk", }); expect(afterCheck).toBe(cached); diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index 26a74853..0030018a 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -395,6 +395,20 @@ describe("codegraph MCP handlers", () => { expect(result.rowLimit).toBe(1); }); + it("refreshes the SQLite artifact before query_sqlite after small workspace edits", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-fresh-")); + await fs.writeFile(path.join(root, "one.ts"), "export const one = 1;\n"); + const handlers = createCodegraphMcpHandlers({ root, readOnly: false }); + await handlers.artifact_build({ outDir: path.join(root, "out"), sqlite: true }); + + await fs.writeFile(path.join(root, "two.ts"), "export const two = 2;\n"); + const result = await handlers.query_sqlite({ query: "SELECT path FROM files ORDER BY path;" }); + const paths = result.rows.map((row) => normalizeSqlitePath(row[0])); + + expect(paths.some((file) => file.endsWith("two.ts"))).toBe(true); + expect(result.freshness).toEqual({ state: "refreshed", changedFiles: ["two.ts"] }); + }); + it("bounds query_sqlite bytes for MCP responses", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-bytes-")); await fs.writeFile(path.join(root, "one.ts"), "export const one = 1;\n"); @@ -762,6 +776,46 @@ describe("codegraph MCP handlers", () => { expect(after.freshness).toEqual({ state: "refreshed", changedFiles: ["auth.ts"] }); }); + it("auto-refreshes deleted files before MCP search", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-auto-fresh-delete-")); + const filePath = path.join(root, "remove.ts"); + await fs.writeFile(filePath, "export function removedSymbol() { return 1; }\n", "utf8"); + const handlers = createCodegraphMcpHandlers({ root }); + + const before = await handlers.search({ query: "removedSymbol", mode: "symbol", limit: 5 }); + await fs.unlink(filePath); + const after = await handlers.search({ query: "removedSymbol", mode: "symbol", limit: 5 }); + + expect(before.results.some((result) => result.label === "removedSymbol")).toBe(true); + expect(after.results.some((result) => result.label === "removedSymbol")).toBe(false); + expect(after.freshness).toEqual({ state: "refreshed", changedFiles: ["remove.ts"] }); + }); + + it("reports bounded stale metadata when MCP auto-refresh thresholds are exceeded", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-auto-fresh-burst-")); + await fs.writeFile(path.join(root, "auth.ts"), "export function cachedSymbol() { return 1; }\n", "utf8"); + const session = createAgentSession({ root, freshness: { policy: "auto", maxAutoRefreshFiles: 1 } }); + const handlers = createCodegraphMcpHandlers({ root, session }); + + await handlers.search({ query: "cachedSymbol", mode: "symbol", limit: 5 }); + await Promise.all( + Array.from({ length: 30 }, async (_, index) => { + const suffix = String(index).padStart(2, "0"); + await fs.writeFile(path.join(root, `late-${suffix}.ts`), `export const late${suffix} = ${index};\n`, "utf8"); + }), + ); + const after = await handlers.search({ query: "cachedSymbol", mode: "symbol", limit: 5 }); + + expect(after.results.some((result) => result.label === "cachedSymbol")).toBe(true); + expect(after.freshness).toEqual({ + state: "stale", + changedFiles: Array.from({ length: 25 }, (_, index) => `late-${String(index).padStart(2, "0")}.ts`), + changedFileCount: 30, + omittedChangedFileCount: 5, + reason: "changed file count exceeds 1", + }); + }); + it("returns live file bytes and freshness metadata after MCP file edits", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-auto-fresh-read-")); const filePath = path.join(root, "auth.ts"); From 054a213d9d4542b2ed4c1f299a2dc40a905f1a90 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Fri, 3 Jul 2026 03:46:47 -0400 Subject: [PATCH 03/26] Harden SQLite artifact freshness --- docs/cli.md | 5 +- src/mcp/server.ts | 150 ++++++++++++++++++++++++++++++++++----- src/sqlite.ts | 2 +- src/sqlite/write.ts | 31 ++++++++ tests/mcp-server.test.ts | 36 ++++++++++ 5 files changed, 202 insertions(+), 22 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 413d3304..d5d95387 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -263,11 +263,12 @@ For SQL, prefer handles or schema-qualified names when basenames may be ambiguou - `mcp serve` exposes navigation, search, impact, review, SQLite query, session refresh, and artifact-build tools. - MCP uses stdio by default or Streamable HTTP with `--port `. - Startup is lazy by default; `--warmup` builds the base session cache before serving requests, and `--warmup-symbols` also builds the detailed symbol graph. -- Use `refresh_index` after changing files while a long-running server is active. +- Index-backed responses include `freshness`; small file changes auto-refresh, while stale responses include a reason, total changed-file count, and a bounded changed-file sample. +- Use `refresh_index` to force a rebuild, reset SQLite artifact state, or recover after stale change bursts. - HTTP serves `/mcp`, validates Host headers, and binds to `127.0.0.1` unless `--host ` is passed. - MCP file and artifact paths are confined to `--root` after realpath resolution. - MCP tools are read-only by default; `--allow-build` enables artifact output only. -- `query_sqlite` is row- and byte-bounded and rejects synthetic payload functions. +- `query_sqlite` is row- and byte-bounded, returns freshness metadata, rejects synthetic payload functions, and refuses stale artifact rows it cannot refresh safely. See [docs/mcp.md](./mcp.md) for client configuration examples. diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 668637b0..ab94c8f3 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -24,7 +24,8 @@ import type { AgentSearchMode, AgentSearchResponse } from "../agent/search.js"; import { getDependencies, getReverseDependencies, getShortestPath, type DependencyNode } from "../graphs/queries.js"; import { findReferences, goToDefinition } from "../indexer/navigation.js"; import { buildReviewReport, type ReviewDepth, type ReviewReport } from "../review.js"; -import { queryGraphSqliteRaw, type RawSqlResult } from "../sqlite.js"; +import { SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY, queryGraphSqliteRaw, type RawSqlResult } from "../sqlite.js"; +import { isPlainRecord } from "../util/guards.js"; import { toProjectDisplayPath } from "../util/paths.js"; import { createAgentSession } from "../agent/session.js"; import type { AgentFreshnessResult, AgentProjectSnapshot, AgentSession } from "../agent/session.js"; @@ -176,6 +177,14 @@ type McpDependencyEntry = { depth: number; }; +type SqliteArtifactFileSignature = { + path: string; + size: number; + mtimeMs: number; +}; + +const MAX_MCP_FRESHNESS_CHANGED_FILES = 25; + function assertMcpSessionOptions(options: CodegraphMcpHandlerOptions): void { if (options.session !== undefined && options.buildOptions !== undefined) { throw new Error("MCP server options cannot combine a prebuilt session with buildOptions."); @@ -238,24 +247,30 @@ function createCodegraphMcpHandlersForSession( const configuredSqlitePath = options.artifactPath ? resolveArtifactSqlitePathCandidate(root, options.artifactPath) : undefined; + const configuredSqliteOutDir = configuredSqlitePath ? path.dirname(configuredSqlitePath) : undefined; let sqlitePath = configuredSqlitePath; - let sqliteOutDir = configuredSqlitePath ? path.dirname(configuredSqlitePath) : undefined; + let sqliteOutDir = configuredSqliteOutDir; const relative = (file: string): string => toProjectDisplayPath(root, file); const boundedLimit = (limit: number | undefined, fallback: number, max: number): number => { if (typeof limit !== "number" || !Number.isFinite(limit)) return fallback; return Math.min(max, Math.max(0, Math.floor(limit))); }; - const checkMcpFreshness = async (): Promise => { - if (session.checkFreshness) return await session.checkFreshness(); + const staleFreshness = (files: readonly string[], reason: string): AgentFreshnessResult => { + const changedFiles = [...files].sort(); + const boundedChangedFiles = changedFiles.slice(0, MAX_MCP_FRESHNESS_CHANGED_FILES); return { state: "stale", - changedFiles: [], - changedFileCount: 0, - omittedChangedFileCount: 0, - reason: "freshness check unavailable", + changedFiles: boundedChangedFiles, + changedFileCount: changedFiles.length, + omittedChangedFileCount: Math.max(0, changedFiles.length - boundedChangedFiles.length), + reason, }; }; + const checkMcpFreshness = async (): Promise => { + if (session.checkFreshness) return await session.checkFreshness(); + return staleFreshness([], "freshness check unavailable"); + }; const withFreshness = async ( run: () => Promise, ): Promise => { @@ -273,14 +288,12 @@ function createCodegraphMcpHandlersForSession( : ""; return `SQLite artifact is stale; run artifact_build before query_sqlite. ${reason}.${changed}${omitted}`; }; - const refreshSqliteArtifactForQuery = async (freshness: AgentFreshnessResult): Promise => { - if (freshness.state === "fresh") return; - if (!sqlitePath || !sqliteOutDir || readOnly || path.basename(sqlitePath) !== "codegraph.sqlite") { - throw new Error(formatSqliteFreshnessError(freshness)); - } - if (freshness.state === "stale") { - throw new Error(formatSqliteFreshnessError(freshness)); - } + const canRefreshSqliteArtifact = (): boolean => { + if (!sqlitePath || !sqliteOutDir || readOnly) return false; + return path.basename(sqlitePath) === "codegraph.sqlite"; + }; + const rebuildSqliteArtifactForQuery = async (): Promise => { + if (!sqliteOutDir) throw new Error("SQLite artifact output directory is unavailable."); const outDir = await assertWritableDirectoryRealPathWithinRoot( await realRoot, root, @@ -299,6 +312,99 @@ function createCodegraphMcpHandlersForSession( sqlitePath = path.join(result.outDir, sqliteArtifact); sqliteOutDir = result.outDir; }; + const refreshSqliteArtifactForQuery = async ( + freshness: AgentFreshnessResult, + options?: { allowStaleRebuild?: boolean }, + ): Promise => { + if (freshness.state === "fresh") return freshness; + if (freshness.state === "stale" && !options?.allowStaleRebuild) { + throw new Error(formatSqliteFreshnessError(freshness)); + } + if (!canRefreshSqliteArtifact()) throw new Error(formatSqliteFreshnessError(freshness)); + await rebuildSqliteArtifactForQuery(); + return { state: "refreshed", changedFiles: freshness.changedFiles }; + }; + const readSqliteArtifactSignatures = async ( + realSqlitePath: string, + ): Promise => { + const result = await queryGraphSqliteRaw( + realSqlitePath, + "SELECT value FROM graph_metadata WHERE key = ?;", + [SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY], + { maxRows: 1 }, + ); + const rawValue = result.rows[0]?.[0]; + if (typeof rawValue !== "string") return null; + const parsed: unknown = JSON.parse(rawValue); + if (!Array.isArray(parsed)) return null; + const signatures: SqliteArtifactFileSignature[] = []; + for (const value of parsed) { + if (!isPlainRecord(value)) continue; + if (typeof value.path !== "string") continue; + if (typeof value.size !== "number" || typeof value.mtimeMs !== "number") continue; + signatures.push({ path: value.path, size: value.size, mtimeMs: value.mtimeMs }); + } + return signatures; + }; + const isFileInsideDirectory = (file: string, directory: string): boolean => { + const relativeFile = path.relative(directory, file); + if (!relativeFile) return true; + return !relativeFile.startsWith("..") && !path.isAbsolute(relativeFile); + }; + const collectCurrentSqliteArtifactSignatures = async (): Promise> => { + if (!session.listFiles) throw new Error("MCP session does not expose file discovery for SQLite freshness."); + const outputDirectories: string[] = []; + if (sqliteOutDir) { + outputDirectories.push(sqliteOutDir); + const lexicalOutDir = path.resolve(root, path.relative(await realRoot, sqliteOutDir)); + outputDirectories.push(lexicalOutDir); + } + const currentFiles = (await session.listFiles()).filter( + (file) => !outputDirectories.some((directory) => isFileInsideDirectory(file, directory)), + ); + const signatures = new Map(); + await Promise.all( + currentFiles.map(async (file) => { + try { + const stat = await fs.stat(file); + if (!stat.isFile()) return; + signatures.set(relative(file), { path: file, size: stat.size, mtimeMs: stat.mtimeMs }); + } catch (error) { + if (error instanceof Error && "code" in error && (error.code === "ENOENT" || error.code === "ENOTDIR")) { + return; + } + throw error; + } + }), + ); + return signatures; + }; + const checkSqliteArtifactFreshness = async (realSqlitePath: string): Promise => { + const storedSignatures = await readSqliteArtifactSignatures(realSqlitePath); + if (!storedSignatures) return staleFreshness([], "SQLite artifact has no freshness baseline"); + const storedByFile = new Map(); + for (const signature of storedSignatures) { + storedByFile.set(relative(signature.path), signature); + } + const currentByFile = await collectCurrentSqliteArtifactSignatures(); + const changedFiles: string[] = []; + for (const [file, currentSignature] of currentByFile.entries()) { + const storedSignature = storedByFile.get(file); + if (!storedSignature) { + changedFiles.push(file); + continue; + } + if (storedSignature.size !== currentSignature.size || storedSignature.mtimeMs !== currentSignature.mtimeMs) { + changedFiles.push(file); + } + } + for (const file of storedByFile.keys()) { + if (currentByFile.has(file)) continue; + changedFiles.push(file); + } + if (!changedFiles.length) return { state: "fresh" }; + return staleFreshness(changedFiles, "SQLite artifact is older than files on disk"); + }; const collectMcpDependencyEntries = async ( request: McpDependencyRequest, @@ -488,9 +594,14 @@ function createCodegraphMcpHandlersForSession( throw new Error("No SQLite artifact is available. Run artifact_build first or pass artifactPath."); } assertMcpSqliteQueryResourceBounded(request.query); - const freshness = await checkMcpFreshness(); - await refreshSqliteArtifactForQuery(freshness); - const realSqlitePath = await assertRealPathCandidateWithinRoot(await realRoot, sqlitePath, "SQLite artifact"); + let freshness = await checkMcpFreshness(); + freshness = await refreshSqliteArtifactForQuery(freshness); + let realSqlitePath = await assertRealPathCandidateWithinRoot(await realRoot, sqlitePath, "SQLite artifact"); + const artifactFreshness = await checkSqliteArtifactFreshness(realSqlitePath); + if (artifactFreshness.state !== "fresh") { + freshness = await refreshSqliteArtifactForQuery(artifactFreshness, { allowStaleRebuild: true }); + realSqlitePath = await assertRealPathCandidateWithinRoot(await realRoot, sqlitePath, "SQLite artifact"); + } const result = await queryGraphSqliteRaw(realSqlitePath, request.query, request.params ?? [], { maxRows: normalizeSqliteRowLimit(request.limit), }); @@ -501,6 +612,7 @@ function createCodegraphMcpHandlersForSession( const warmup = request.warmup ?? "off"; session.invalidate(); sqlitePath = configuredSqlitePath; + sqliteOutDir = configuredSqliteOutDir; await startCodegraphMcpWarmup(session, warmup); return { refreshed: true, warmup }; }, diff --git a/src/sqlite.ts b/src/sqlite.ts index 49bb8b0b..ab8f0a23 100644 --- a/src/sqlite.ts +++ b/src/sqlite.ts @@ -1,3 +1,3 @@ export type { GraphQueryResult, RawSqlResult, SqliteGraphOptions, SqliteGraphUpdateOptions } from "./sqlite/types.js"; -export { writeGraphSqlite, updateGraphSqlite } from "./sqlite/write.js"; +export { SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY, writeGraphSqlite, updateGraphSqlite } from "./sqlite/write.js"; export { queryGraphSqlite, queryGraphSqliteRaw } from "./sqlite/query.js"; diff --git a/src/sqlite/write.ts b/src/sqlite/write.ts index 5162f59b..146b24c1 100644 --- a/src/sqlite/write.ts +++ b/src/sqlite/write.ts @@ -1,3 +1,4 @@ +import fs from "node:fs/promises"; import { type SymbolGraph, type SymbolNode } from "../graphs/symbol-graph.js"; import type { Graph } from "../types.js"; import type { SqliteDatabase } from "../sqlite-driver.js"; @@ -5,6 +6,34 @@ import type { SqliteGraphOptions, SqliteGraphUpdateOptions } from "./types.js"; import { execRowsParams } from "./common.js"; import { withSqliteDatabase } from "./database.js"; +export const SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY = "artifact_file_signatures_v1"; + +type SqliteArtifactFileSignature = { + path: string; + size: number; + mtimeMs: number; +}; + +async function collectSqliteArtifactFileSignatures(files: Iterable): Promise { + const signatures: SqliteArtifactFileSignature[] = []; + await Promise.all( + [...files].map(async (file) => { + const stat = await fs.stat(file); + if (!stat.isFile()) return; + signatures.push({ path: file, size: stat.size, mtimeMs: stat.mtimeMs }); + }), + ); + signatures.sort((left, right) => left.path.localeCompare(right.path)); + return signatures; +} + +function writeArtifactFileSignatures(db: SqliteDatabase, signatures: readonly SqliteArtifactFileSignature[]): void { + db.prepare("INSERT OR REPLACE INTO graph_metadata (key, value) VALUES (?, ?);").run([ + SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY, + JSON.stringify(signatures), + ]); +} + const collectSymbolIdsForFiles = (symbolGraph: SymbolGraph, changedSet: Set): Set => { const ids = new Set(); for (const [id, node] of symbolGraph.nodes.entries()) { @@ -212,6 +241,7 @@ const deleteUnreferencedExternalFiles = (db: SqliteDatabase) => { }; export async function writeGraphSqlite(options: SqliteGraphOptions): Promise { + const fileSignatures = await collectSqliteArtifactFileSignatures(options.fileGraph.nodes); await withSqliteDatabase(options.outputPath, (db) => { const runInsert = db.transaction(() => { clearCurrentGraphState(db); @@ -239,6 +269,7 @@ export async function writeGraphSqlite(options: SqliteGraphOptions): Promise { expect(result.freshness).toEqual({ state: "refreshed", changedFiles: ["two.ts"] }); }); + it("refuses stale configured SQLite artifacts in read-only mode", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-stale-artifact-")); + const outDir = path.join(root, "out"); + await fs.writeFile(path.join(root, "one.ts"), "export const one = 1;\n"); + const buildHandlers = createCodegraphMcpHandlers({ root, readOnly: false }); + await buildHandlers.artifact_build({ outDir, sqlite: true }); + + await fs.writeFile(path.join(root, "two.ts"), "export const two = 2;\n"); + const readHandlers = createCodegraphMcpHandlers({ root, artifactPath: outDir }); + + await expect(readHandlers.query_sqlite({ query: "SELECT path FROM files ORDER BY path;" })).rejects.toThrow( + /SQLite artifact is stale[\s\S]*two\.ts/, + ); + }); + it("bounds query_sqlite bytes for MCP responses", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-bytes-")); await fs.writeFile(path.join(root, "one.ts"), "export const one = 1;\n"); @@ -816,6 +831,27 @@ describe("codegraph MCP handlers", () => { }); }); + it("reports stale metadata when MCP auto-refresh byte thresholds are exceeded", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-auto-fresh-bytes-")); + const filePath = path.join(root, "auth.ts"); + await fs.writeFile(filePath, "export function cachedSymbol() { return 1; }\n", "utf8"); + const session = createAgentSession({ root, freshness: { policy: "auto", maxAutoRefreshBytes: 5 } }); + const handlers = createCodegraphMcpHandlers({ root, session }); + + await handlers.search({ query: "cachedSymbol", mode: "symbol", limit: 5 }); + await fs.writeFile(filePath, `export function largeSymbol() { return "${"x".repeat(64)}"; }\n`, "utf8"); + const after = await handlers.search({ query: "cachedSymbol", mode: "symbol", limit: 5 }); + + expect(after.results.some((result) => result.label === "cachedSymbol")).toBe(true); + expect(after.freshness).toEqual({ + state: "stale", + changedFiles: ["auth.ts"], + changedFileCount: 1, + omittedChangedFileCount: 0, + reason: "changed byte count exceeds 5", + }); + }); + it("returns live file bytes and freshness metadata after MCP file edits", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-auto-fresh-read-")); const filePath = path.join(root, "auth.ts"); From 2216acdda0b1d6048bff5a0a7d878e82207d5c2c Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Fri, 3 Jul 2026 04:03:06 -0400 Subject: [PATCH 04/26] Fix SQLite artifact freshness edge cases --- src/agent/artifact.ts | 10 ++++++++++ src/agent/session.ts | 14 +++++++++----- src/mcp/server.ts | 22 ++++++++++++++-------- src/sqlite/types.ts | 1 + src/sqlite/write.ts | 16 +++++++++++++++- tests/mcp-server.test.ts | 40 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 89 insertions(+), 14 deletions(-) diff --git a/src/agent/artifact.ts b/src/agent/artifact.ts index 606b0a82..2ede538c 100644 --- a/src/agent/artifact.ts +++ b/src/agent/artifact.ts @@ -114,11 +114,21 @@ export async function buildCodegraphArtifactWithSession( const artifacts: CodegraphArtifactBuildResult["artifacts"] = {}; if (selected.sqlite) { + let fileSignatures: Array<{ path: string; size: number; mtimeMs: number }> | undefined; + if (snapshot.fileSignatures) { + fileSignatures = []; + for (const file of snapshot.fileGraph.nodes) { + const signature = snapshot.fileSignatures.get(file); + if (!signature) continue; + fileSignatures.push({ path: signature.file, size: signature.size, mtimeMs: signature.mtimeMs }); + } + } const outputPath = path.join(outDir, SQLITE_FILE); await writeGraphSqlite({ fileGraph: snapshot.fileGraph, symbolGraph: snapshot.symbolGraph, outputPath, + ...(fileSignatures ? { fileSignatures } : {}), }); artifacts.sqlite = SQLITE_FILE; } diff --git a/src/agent/session.ts b/src/agent/session.ts index 3f2972cb..222b1bf1 100644 --- a/src/agent/session.ts +++ b/src/agent/session.ts @@ -19,6 +19,7 @@ export type AgentProjectSnapshot = { symbolGraph: SymbolGraph; buildReport?: BuildReport; analysis: AnalysisSummary; + fileSignatures?: ReadonlyMap; }; export type AgentLoadProjectOptions = { @@ -71,7 +72,7 @@ const DEFAULT_MAX_FRESHNESS_CHANGED_FILES = 25; type AgentProjectBaseSnapshot = Omit; -type AgentFileSignature = { +export type AgentFileSignature = { file: string; size: number; mtimeMs: number; @@ -97,6 +98,11 @@ async function resolveAgentDiscoverySettings(options: AgentSessionOptions): Prom return discoveryOptions ? { discoveryOptions } : {}; } +export async function listAgentSessionFiles(options: AgentSessionOptions): Promise { + const { discoveryOptions } = await resolveAgentDiscoverySettings(options); + return await listProjectFiles(options.root, undefined, discoveryOptions); +} + function isMissingStatRace(error: unknown): boolean { if (!(error instanceof Error)) return false; if (!("code" in error)) return false; @@ -182,10 +188,7 @@ export function createAgentSession(options: AgentSessionOptions): AgentSession { const loadFiles = async (): Promise => { if (cachedFiles) return cachedFiles; - const loadPromise = (async () => { - const { discoveryOptions } = await resolveAgentDiscoverySettings(options); - return await listProjectFiles(options.root, undefined, discoveryOptions); - })(); + const loadPromise = listAgentSessionFiles(options); cachedFiles = loadPromise; loadPromise.catch(() => { if (cachedFiles === loadPromise) cachedFiles = undefined; @@ -223,6 +226,7 @@ export function createAgentSession(options: AgentSessionOptions): AgentSession { fileLookup: createAgentFileLookup(files), index, fileGraph, + fileSignatures: cachedFileSignatures, buildReport, analysis: summarizeAnalysis({ index, report: buildReport }), }; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index ab94c8f3..ed81fdfd 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -27,7 +27,7 @@ import { buildReviewReport, type ReviewDepth, type ReviewReport } from "../revie import { SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY, queryGraphSqliteRaw, type RawSqlResult } from "../sqlite.js"; import { isPlainRecord } from "../util/guards.js"; import { toProjectDisplayPath } from "../util/paths.js"; -import { createAgentSession } from "../agent/session.js"; +import { createAgentSession, listAgentSessionFiles } from "../agent/session.js"; import type { AgentFreshnessResult, AgentProjectSnapshot, AgentSession } from "../agent/session.js"; import type { BuildOptions, GoToResult } from "../indexer/types.js"; import { @@ -248,8 +248,10 @@ function createCodegraphMcpHandlersForSession( ? resolveArtifactSqlitePathCandidate(root, options.artifactPath) : undefined; const configuredSqliteOutDir = configuredSqlitePath ? path.dirname(configuredSqlitePath) : undefined; + const configuredSqliteCanRefresh = options.artifactPath ? !/\.(sqlite|db)$/i.test(options.artifactPath) : false; let sqlitePath = configuredSqlitePath; let sqliteOutDir = configuredSqliteOutDir; + let sqliteCanRefresh = configuredSqliteCanRefresh; const relative = (file: string): string => toProjectDisplayPath(root, file); const boundedLimit = (limit: number | undefined, fallback: number, max: number): number => { @@ -289,7 +291,7 @@ function createCodegraphMcpHandlersForSession( return `SQLite artifact is stale; run artifact_build before query_sqlite. ${reason}.${changed}${omitted}`; }; const canRefreshSqliteArtifact = (): boolean => { - if (!sqlitePath || !sqliteOutDir || readOnly) return false; + if (!sqlitePath || !sqliteOutDir || readOnly || !sqliteCanRefresh) return false; return path.basename(sqlitePath) === "codegraph.sqlite"; }; const rebuildSqliteArtifactForQuery = async (): Promise => { @@ -314,10 +316,10 @@ function createCodegraphMcpHandlersForSession( }; const refreshSqliteArtifactForQuery = async ( freshness: AgentFreshnessResult, - options?: { allowStaleRebuild?: boolean }, + refreshOptions?: { allowStaleRebuild?: boolean }, ): Promise => { if (freshness.state === "fresh") return freshness; - if (freshness.state === "stale" && !options?.allowStaleRebuild) { + if (freshness.state === "stale" && !refreshOptions?.allowStaleRebuild) { throw new Error(formatSqliteFreshnessError(freshness)); } if (!canRefreshSqliteArtifact()) throw new Error(formatSqliteFreshnessError(freshness)); @@ -352,16 +354,18 @@ function createCodegraphMcpHandlersForSession( return !relativeFile.startsWith("..") && !path.isAbsolute(relativeFile); }; const collectCurrentSqliteArtifactSignatures = async (): Promise> => { - if (!session.listFiles) throw new Error("MCP session does not expose file discovery for SQLite freshness."); const outputDirectories: string[] = []; if (sqliteOutDir) { outputDirectories.push(sqliteOutDir); const lexicalOutDir = path.resolve(root, path.relative(await realRoot, sqliteOutDir)); outputDirectories.push(lexicalOutDir); } - const currentFiles = (await session.listFiles()).filter( - (file) => !outputDirectories.some((directory) => isFileInsideDirectory(file, directory)), - ); + const currentFiles = ( + await listAgentSessionFiles({ + root, + ...(options.buildOptions ? { buildOptions: options.buildOptions } : {}), + }) + ).filter((file) => !outputDirectories.some((directory) => isFileInsideDirectory(file, directory))); const signatures = new Map(); await Promise.all( currentFiles.map(async (file) => { @@ -613,6 +617,7 @@ function createCodegraphMcpHandlersForSession( session.invalidate(); sqlitePath = configuredSqlitePath; sqliteOutDir = configuredSqliteOutDir; + sqliteCanRefresh = configuredSqliteCanRefresh; await startCodegraphMcpWarmup(session, warmup); return { refreshed: true, warmup }; }, @@ -645,6 +650,7 @@ function createCodegraphMcpHandlersForSession( if (sqliteArtifact) { sqlitePath = path.join(result.outDir, sqliteArtifact); sqliteOutDir = result.outDir; + sqliteCanRefresh = true; } return result; }), diff --git a/src/sqlite/types.ts b/src/sqlite/types.ts index ea8a5430..b360064e 100644 --- a/src/sqlite/types.ts +++ b/src/sqlite/types.ts @@ -5,6 +5,7 @@ export type SqliteGraphOptions = { fileGraph: Graph; symbolGraph: SymbolGraph; outputPath: string; + fileSignatures?: Iterable<{ path: string; size: number; mtimeMs: number }>; }; export type SqliteGraphUpdateOptions = { diff --git a/src/sqlite/write.ts b/src/sqlite/write.ts index 146b24c1..ccb2c2ff 100644 --- a/src/sqlite/write.ts +++ b/src/sqlite/write.ts @@ -27,6 +27,18 @@ async function collectSqliteArtifactFileSignatures(files: Iterable): Pro return signatures; } +function normalizeSqliteArtifactFileSignatures( + signatures: Iterable, +): SqliteArtifactFileSignature[] { + const normalized = [...signatures].map((signature) => ({ + path: signature.path, + size: signature.size, + mtimeMs: signature.mtimeMs, + })); + normalized.sort((left, right) => left.path.localeCompare(right.path)); + return normalized; +} + function writeArtifactFileSignatures(db: SqliteDatabase, signatures: readonly SqliteArtifactFileSignature[]): void { db.prepare("INSERT OR REPLACE INTO graph_metadata (key, value) VALUES (?, ?);").run([ SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY, @@ -241,7 +253,9 @@ const deleteUnreferencedExternalFiles = (db: SqliteDatabase) => { }; export async function writeGraphSqlite(options: SqliteGraphOptions): Promise { - const fileSignatures = await collectSqliteArtifactFileSignatures(options.fileGraph.nodes); + const fileSignatures = options.fileSignatures + ? normalizeSqliteArtifactFileSignatures(options.fileSignatures) + : await collectSqliteArtifactFileSignatures(options.fileGraph.nodes); await withSqliteDatabase(options.outputPath, (db) => { const runInsert = db.transaction(() => { clearCurrentGraphState(db); diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index 29a77ede..534ad71c 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import { request as httpRequest } from "node:http"; import os from "node:os"; import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; import { describe, expect, it, vi } from "vitest"; import { createAgentSession } from "../src/agent/session.js"; import { @@ -409,6 +410,26 @@ describe("codegraph MCP handlers", () => { expect(result.freshness).toEqual({ state: "refreshed", changedFiles: ["two.ts"] }); }); + it("refreshes the SQLite artifact before query_sqlite after edits and deletions", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-fresh-edit-delete-")); + const keptFile = path.join(root, "one.ts"); + const removedFile = path.join(root, "remove.ts"); + await fs.writeFile(keptFile, "export function oldName() { return 1; }\n"); + await fs.writeFile(removedFile, "export function removedName() { return 2; }\n"); + const handlers = createCodegraphMcpHandlers({ root, readOnly: false }); + await handlers.artifact_build({ outDir: path.join(root, "out"), sqlite: true }); + + await fs.writeFile(keptFile, "export function editedName() { return 3; }\n"); + await fs.unlink(removedFile); + const result = await handlers.query_sqlite({ query: "SELECT name FROM symbols ORDER BY name;" }); + const names = result.rows.map((row) => String(row[0])); + + expect(names).toContain("editedName"); + expect(names).not.toContain("oldName"); + expect(names).not.toContain("removedName"); + expect(result.freshness).toEqual({ state: "refreshed", changedFiles: ["one.ts", "remove.ts"] }); + }); + it("refuses stale configured SQLite artifacts in read-only mode", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-stale-artifact-")); const outDir = path.join(root, "out"); @@ -424,6 +445,25 @@ describe("codegraph MCP handlers", () => { ); }); + it("refuses older SQLite artifacts without freshness metadata in read-only mode", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-legacy-artifact-")); + const outDir = path.join(root, "out"); + await fs.writeFile(path.join(root, "one.ts"), "export const one = 1;\n"); + const buildHandlers = createCodegraphMcpHandlers({ root, readOnly: false }); + await buildHandlers.artifact_build({ outDir, sqlite: true }); + const db = new DatabaseSync(path.join(outDir, "codegraph.sqlite")); + try { + db.exec("DELETE FROM graph_metadata WHERE key = 'artifact_file_signatures_v1';"); + } finally { + db.close(); + } + const readHandlers = createCodegraphMcpHandlers({ root, artifactPath: outDir }); + + await expect(readHandlers.query_sqlite({ query: "SELECT path FROM files ORDER BY path;" })).rejects.toThrow( + /SQLite artifact has no freshness baseline/, + ); + }); + it("bounds query_sqlite bytes for MCP responses", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-bytes-")); await fs.writeFile(path.join(root, "one.ts"), "export const one = 1;\n"); From 84531fbb68c7be9708365934f1e5ec4a2db27cf7 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Fri, 3 Jul 2026 04:25:41 -0400 Subject: [PATCH 05/26] Complete SQLite artifact freshness hardening --- src/agent/artifact.ts | 16 +++++++----- src/agent/session.ts | 2 ++ src/mcp/server.ts | 16 +++++++----- src/sqlite/write.ts | 6 +++-- tests/helpers/agent.ts | 1 + tests/mcp-server.test.ts | 56 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 82 insertions(+), 15 deletions(-) diff --git a/src/agent/artifact.ts b/src/agent/artifact.ts index 2ede538c..511f3578 100644 --- a/src/agent/artifact.ts +++ b/src/agent/artifact.ts @@ -114,14 +114,16 @@ export async function buildCodegraphArtifactWithSession( const artifacts: CodegraphArtifactBuildResult["artifacts"] = {}; if (selected.sqlite) { - let fileSignatures: Array<{ path: string; size: number; mtimeMs: number }> | undefined; - if (snapshot.fileSignatures) { - fileSignatures = []; - for (const file of snapshot.fileGraph.nodes) { - const signature = snapshot.fileSignatures.get(file); - if (!signature) continue; - fileSignatures.push({ path: signature.file, size: signature.size, mtimeMs: signature.mtimeMs }); + if (!snapshot.fileSignatures) { + throw new Error("SQLite artifact freshness signatures are unavailable."); + } + const fileSignatures: Array<{ path: string; size: number; mtimeMs: number }> = []; + for (const file of snapshot.fileGraph.nodes) { + const signature = snapshot.fileSignatures.get(file); + if (!signature) { + throw new Error(`SQLite artifact freshness signature is missing for ${file}.`); } + fileSignatures.push({ path: signature.file, size: signature.size, mtimeMs: signature.mtimeMs }); } const outputPath = path.join(outDir, SQLITE_FILE); await writeGraphSqlite({ diff --git a/src/agent/session.ts b/src/agent/session.ts index 222b1bf1..616c10fe 100644 --- a/src/agent/session.ts +++ b/src/agent/session.ts @@ -55,6 +55,7 @@ export type AgentSessionOptions = { export type AgentSession = { root?: string; + discoverFiles?: () => Promise; listFiles?: () => Promise; loadProject: (loadOptions?: AgentLoadProjectOptions) => Promise; checkFreshness?: () => Promise; @@ -319,6 +320,7 @@ export function createAgentSession(options: AgentSessionOptions): AgentSession { return { root: options.root, + discoverFiles: () => listAgentSessionFiles(options), listFiles: loadFiles, loadProject, checkFreshness, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index ed81fdfd..054ba593 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -360,12 +360,16 @@ function createCodegraphMcpHandlersForSession( const lexicalOutDir = path.resolve(root, path.relative(await realRoot, sqliteOutDir)); outputDirectories.push(lexicalOutDir); } - const currentFiles = ( - await listAgentSessionFiles({ - root, - ...(options.buildOptions ? { buildOptions: options.buildOptions } : {}), - }) - ).filter((file) => !outputDirectories.some((directory) => isFileInsideDirectory(file, directory))); + const discoverFiles = + session.discoverFiles ?? + (async (): Promise => + await listAgentSessionFiles({ + root, + ...(options.buildOptions ? { buildOptions: options.buildOptions } : {}), + })); + const currentFiles = (await discoverFiles()).filter( + (file) => !outputDirectories.some((directory) => isFileInsideDirectory(file, directory)), + ); const signatures = new Map(); await Promise.all( currentFiles.map(async (file) => { diff --git a/src/sqlite/write.ts b/src/sqlite/write.ts index ccb2c2ff..e64897f6 100644 --- a/src/sqlite/write.ts +++ b/src/sqlite/write.ts @@ -255,7 +255,7 @@ const deleteUnreferencedExternalFiles = (db: SqliteDatabase) => { export async function writeGraphSqlite(options: SqliteGraphOptions): Promise { const fileSignatures = options.fileSignatures ? normalizeSqliteArtifactFileSignatures(options.fileSignatures) - : await collectSqliteArtifactFileSignatures(options.fileGraph.nodes); + : undefined; await withSqliteDatabase(options.outputPath, (db) => { const runInsert = db.transaction(() => { clearCurrentGraphState(db); @@ -283,7 +283,9 @@ export async function writeGraphSqlite(options: SqliteGraphOptions): Promise { const mode = options?.symbolGraph ?? "eager"; if (!cached || cachedMode !== mode) { diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index 534ad71c..00bfec0b 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -464,6 +464,62 @@ describe("codegraph MCP handlers", () => { ); }); + it("rebuilds stale configured SQLite artifact bundles in writable mode", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-writable-bundle-")); + const outDir = path.join(root, "out"); + await fs.writeFile(path.join(root, "one.ts"), "export const one = 1;\n"); + const buildHandlers = createCodegraphMcpHandlers({ root, readOnly: false }); + await buildHandlers.artifact_build({ outDir, sqlite: true }); + const db = new DatabaseSync(path.join(outDir, "codegraph.sqlite")); + try { + db.exec("DELETE FROM graph_metadata WHERE key = 'artifact_file_signatures_v1';"); + } finally { + db.close(); + } + await fs.writeFile(path.join(root, "two.ts"), "export const two = 2;\n"); + const readHandlers = createCodegraphMcpHandlers({ root, artifactPath: outDir, readOnly: false }); + const result = await readHandlers.query_sqlite({ query: "SELECT path FROM files ORDER BY path;" }); + const paths = result.rows.map((row) => normalizeSqlitePath(row[0])); + + expect(paths.some((file) => file.endsWith("two.ts"))).toBe(true); + expect(result.freshness).toEqual({ state: "refreshed", changedFiles: [] }); + }); + + it("refuses stale explicit SQLite artifact files in writable mode", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-explicit-file-")); + const outDir = path.join(root, "out"); + await fs.writeFile(path.join(root, "one.ts"), "export const one = 1;\n"); + const buildHandlers = createCodegraphMcpHandlers({ root, readOnly: false }); + await buildHandlers.artifact_build({ outDir, sqlite: true }); + + await fs.writeFile(path.join(root, "two.ts"), "export const two = 2;\n"); + const readHandlers = createCodegraphMcpHandlers({ + root, + artifactPath: path.join(outDir, "codegraph.sqlite"), + readOnly: false, + }); + + await expect(readHandlers.query_sqlite({ query: "SELECT path FROM files ORDER BY path;" })).rejects.toThrow( + /SQLite artifact is stale[\s\S]*two\.ts/, + ); + }); + + it("uses prebuilt session discovery for SQLite artifact freshness", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-session-discovery-")); + await fs.writeFile(path.join(root, "keep.ts"), "export const keep = 1;\n"); + await fs.writeFile(path.join(root, "ignored.ts"), "export const ignored = 2;\n"); + const session = createAgentSession({ root, discovery: { ignoreGlobs: ["ignored.ts"] } }); + const handlers = createCodegraphMcpHandlers({ root, session, readOnly: false }); + await handlers.artifact_build({ outDir: path.join(root, "out"), sqlite: true }); + + const result = await handlers.query_sqlite({ query: "SELECT path FROM files ORDER BY path;" }); + const paths = result.rows.map((row) => normalizeSqlitePath(row[0])); + + expect(paths.some((file) => file.endsWith("keep.ts"))).toBe(true); + expect(paths.some((file) => file.endsWith("ignored.ts"))).toBe(false); + expect(result.freshness).toEqual({ state: "fresh" }); + }); + it("bounds query_sqlite bytes for MCP responses", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-bytes-")); await fs.writeFile(path.join(root, "one.ts"), "export const one = 1;\n"); From 1ee87803f76e1f2861f6a5b86bc1597e5c961277 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Fri, 3 Jul 2026 04:35:26 -0400 Subject: [PATCH 06/26] Close SQLite freshness review gaps --- src/mcp/server.ts | 20 ++++++++++++-------- src/sqlite/write.ts | 6 ++++++ tests/mcp-server.test.ts | 24 +++++++++++++++++------- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 054ba593..4612a149 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -360,14 +360,18 @@ function createCodegraphMcpHandlersForSession( const lexicalOutDir = path.resolve(root, path.relative(await realRoot, sqliteOutDir)); outputDirectories.push(lexicalOutDir); } - const discoverFiles = - session.discoverFiles ?? - (async (): Promise => - await listAgentSessionFiles({ - root, - ...(options.buildOptions ? { buildOptions: options.buildOptions } : {}), - })); - const currentFiles = (await discoverFiles()).filter( + let discoveredFiles: string[]; + if (session.discoverFiles) { + discoveredFiles = await session.discoverFiles(); + } else if (options.session) { + throw new Error("MCP session does not expose live file discovery for SQLite freshness."); + } else { + discoveredFiles = await listAgentSessionFiles({ + root, + ...(options.buildOptions ? { buildOptions: options.buildOptions } : {}), + }); + } + const currentFiles = discoveredFiles.filter( (file) => !outputDirectories.some((directory) => isFileInsideDirectory(file, directory)), ); const signatures = new Map(); diff --git a/src/sqlite/write.ts b/src/sqlite/write.ts index e64897f6..5d5ace99 100644 --- a/src/sqlite/write.ts +++ b/src/sqlite/write.ts @@ -46,6 +46,10 @@ function writeArtifactFileSignatures(db: SqliteDatabase, signatures: readonly Sq ]); } +function clearArtifactFileSignatures(db: SqliteDatabase): void { + db.prepare("DELETE FROM graph_metadata WHERE key = ?;").run([SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY]); +} + const collectSymbolIdsForFiles = (symbolGraph: SymbolGraph, changedSet: Set): Set => { const ids = new Set(); for (const [id, node] of symbolGraph.nodes.entries()) { @@ -285,6 +289,8 @@ export async function writeGraphSqlite(options: SqliteGraphOptions): Promise { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-session-discovery-")); await fs.writeFile(path.join(root, "keep.ts"), "export const keep = 1;\n"); await fs.writeFile(path.join(root, "ignored.ts"), "export const ignored = 2;\n"); - const session = createAgentSession({ root, discovery: { ignoreGlobs: ["ignored.ts"] } }); + const session = createAgentSession({ + root, + discovery: { ignoreGlobs: ["ignored.ts"] }, + freshness: { policy: "auto" }, + }); const handlers = createCodegraphMcpHandlers({ root, session, readOnly: false }); await handlers.artifact_build({ outDir: path.join(root, "out"), sqlite: true }); - const result = await handlers.query_sqlite({ query: "SELECT path FROM files ORDER BY path;" }); - const paths = result.rows.map((row) => normalizeSqlitePath(row[0])); - - expect(paths.some((file) => file.endsWith("keep.ts"))).toBe(true); - expect(paths.some((file) => file.endsWith("ignored.ts"))).toBe(false); - expect(result.freshness).toEqual({ state: "fresh" }); + const initial = await handlers.query_sqlite({ query: "SELECT path FROM files ORDER BY path;" }); + await fs.writeFile(path.join(root, "late.ts"), "export const late = 3;\n"); + const refreshed = await handlers.query_sqlite({ query: "SELECT path FROM files ORDER BY path;" }); + const initialPaths = initial.rows.map((row) => normalizeSqlitePath(row[0])); + const refreshedPaths = refreshed.rows.map((row) => normalizeSqlitePath(row[0])); + + expect(initialPaths.some((file) => file.endsWith("keep.ts"))).toBe(true); + expect(initialPaths.some((file) => file.endsWith("ignored.ts"))).toBe(false); + expect(initial.freshness).toEqual({ state: "fresh" }); + expect(refreshedPaths.some((file) => file.endsWith("late.ts"))).toBe(true); + expect(refreshedPaths.some((file) => file.endsWith("ignored.ts"))).toBe(false); + expect(refreshed.freshness).toEqual({ state: "refreshed", changedFiles: ["late.ts"] }); }); it("bounds query_sqlite bytes for MCP responses", async () => { From 1a072465ca481eafcaae985a617bd0354e22547a Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Fri, 3 Jul 2026 04:46:22 -0400 Subject: [PATCH 07/26] Cover SQLite freshness edge cases --- tests/mcp-server.test.ts | 21 ++++++++++++++++++++- tests/sqlite.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index c3f65ef6..50fc7511 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -4,7 +4,7 @@ import os from "node:os"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import { describe, expect, it, vi } from "vitest"; -import { createAgentSession } from "../src/agent/session.js"; +import { createAgentSession, type AgentSession } from "../src/agent/session.js"; import { createCodegraphMcpHandlers, listCodegraphMcpTools, @@ -530,6 +530,25 @@ describe("codegraph MCP handlers", () => { expect(refreshed.freshness).toEqual({ state: "refreshed", changedFiles: ["late.ts"] }); }); + it("rejects prebuilt SQLite sessions without live discovery", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-no-discovery-")); + const outDir = path.join(root, "out"); + await fs.writeFile(path.join(root, "one.ts"), "export const one = 1;\n"); + const buildHandlers = createCodegraphMcpHandlers({ root, readOnly: false }); + await buildHandlers.artifact_build({ outDir, sqlite: true }); + const backingSession = createAgentSession({ root }); + const session: AgentSession = { + loadProject: backingSession.loadProject, + checkFreshness: async () => ({ state: "fresh" }), + invalidate: backingSession.invalidate, + }; + const readHandlers = createCodegraphMcpHandlers({ root, artifactPath: outDir, session }); + + await expect(readHandlers.query_sqlite({ query: "SELECT path FROM files ORDER BY path;" })).rejects.toThrow( + /does not expose live file discovery/, + ); + }); + it("bounds query_sqlite bytes for MCP responses", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "cg-mcp-sqlite-bytes-")); await fs.writeFile(path.join(root, "one.ts"), "export const one = 1;\n"); diff --git a/tests/sqlite.test.ts b/tests/sqlite.test.ts index 1c698d95..7041241d 100644 --- a/tests/sqlite.test.ts +++ b/tests/sqlite.test.ts @@ -10,6 +10,7 @@ import { updateGraphSqlite, queryGraphSqlite, queryGraphSqliteRaw, + SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY, } from "../src/index.js"; import { mkTmpDir, normalizeTestPath } from "./helpers/filesystem.js"; @@ -70,6 +71,36 @@ export function run() { helper(); new Widget(); } db.close(); }); + it("clears artifact freshness metadata on unsigned full rewrites", async () => { + const root = await mkTmpDir("dg-sqlite-freshness-rewrite-"); + const mainPath = path.join(root, "main.ts"); + await fsp.writeFile(mainPath, "export const one = 1;\n", "utf8"); + const index = await buildProjectIndex(root); + const sgraph = await buildSymbolGraphDetailed(index); + const dbPath = path.join(root, "graph.sqlite"); + const stat = await fsp.stat(mainPath); + await writeGraphSqlite({ + fileGraph: index.graph, + symbolGraph: sgraph, + outputPath: dbPath, + fileSignatures: [{ path: mainPath, size: stat.size, mtimeMs: stat.mtimeMs }], + }); + let db = new DatabaseSync(dbPath); + expect(dbQuery(db, "SELECT value FROM graph_metadata WHERE key = 'artifact_file_signatures_v1';")).toHaveLength(1); + db.close(); + + await writeGraphSqlite({ + fileGraph: index.graph, + symbolGraph: sgraph, + outputPath: dbPath, + }); + db = new DatabaseSync(dbPath); + expect( + dbQuery(db, `SELECT value FROM graph_metadata WHERE key = '${SQLITE_ARTIFACT_FILE_SIGNATURES_METADATA_KEY}';`), + ).toHaveLength(0); + db.close(); + }); + it("keeps full SQLite exports idempotent across repeated writes", async () => { const root = await mkTmpDir("dg-sqlite-idempotent-"); await fsp.writeFile( From 5c1aa8e3ba26db07ead5ec481aad0d339f79dc18 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Fri, 3 Jul 2026 05:01:28 -0400 Subject: [PATCH 08/26] Add explore facade --- README.md | 18 +- codegraph-skill/codegraph/SKILL.md | 8 +- docs/agent-workflows.md | 15 +- docs/cli.md | 10 +- docs/library-api.md | 16 +- docs/mcp.md | 9 +- src/agent.ts | 8 + src/agent/explore.ts | 486 +++++++++++++++++++++++++++++ src/cli.ts | 16 + src/cli/explore.ts | 30 ++ src/cli/help.ts | 16 + src/cli/options.ts | 10 + src/index.ts | 10 + src/mcp/server.ts | 31 ++ src/mcp/tools.ts | 15 + tests/agent-explore.test.ts | 236 ++++++++++++++ 16 files changed, 915 insertions(+), 19 deletions(-) create mode 100644 src/agent/explore.ts create mode 100644 src/cli/explore.ts create mode 100644 tests/agent-explore.test.ts diff --git a/README.md b/README.md index 40d21e39..6eb01d56 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,9 @@ Use Codegraph when you need fast structural answers about a repo without relying - Export graph data as JSON, Mermaid, DOT, or SQLite, then inspect it from scripts, Markdown renderers, Graphviz, or SQL tools. - Keep one workflow across source languages, monorepos, and graph-first document and template formats instead of stitching together separate tools. -For unfamiliar repos, start with `orient --root . --budget small --pretty`, then use `search` and `explain` to land on one concrete code anchor. +For unfamiliar repos with a concrete question, start with `explore "how does auth reach db?" --root . --pretty`; use `orient --root . --budget small --pretty` when you need a map before asking a question. For daily change work, start with `review --base HEAD --head WORKTREE --summary`; use `impact --base HEAD --head WORKTREE --pretty` as the broader blast-radius map when needed. -Search is code-first by default in hybrid mode, and search, explain, and review packets now include analysis labels so reduced-mode or mixed-semantics runs stay visible. +Search is code-first by default in hybrid mode, and explore, search, explain, and review packets include analysis labels so reduced-mode or mixed-semantics runs stay visible. Detailed command contracts and JSON shapes live in [docs/cli.md](./docs/cli.md). ## Features @@ -39,7 +39,7 @@ Detailed command contracts and JSON shapes live in [docs/cli.md](./docs/cli.md). - Multi-language dependency graphs, including imports, re-exports, `require()`, dynamic imports, workspace resolution, document links, stylesheet imports, and SFC script dependencies. - Per-file symbol indexes with locals, exports, docstrings, line spans, and lightweight complexity metadata. - Cross-file go-to-definition and find-references support across the shared source-language pipeline. -- Deterministic agent orientation, packet retrieval, search, bounded explanations, portable artifact bundles, and MCP tools across files, symbols, chunks, SQL objects, graph neighborhoods, and review ranges with stable follow-up targets. +- Deterministic agent exploration, orientation, packet retrieval, search, bounded explanations, portable artifact bundles, and MCP tools across files, symbols, chunks, SQL objects, graph neighborhoods, and review ranges with stable follow-up targets. - Semantic chunking for code and text files, including Vue and Svelte single-file component block splitting. - Duplicate and near-duplicate detection over indexed symbols, semantic chunks, text chunks, token fingerprints, and AST shape hashes when parser context is available. - AST grep, public API summaries, unresolved import reports, hotspot analysis, cycle detection, and shortest dependency paths. @@ -86,6 +86,9 @@ node ./dist/cli.js review --base HEAD --head WORKTREE --summary # broader blast-radius map when the review packet needs expansion node ./dist/cli.js impact --base HEAD --head WORKTREE --pretty +# one-call answer for a concrete repo question +node ./dist/cli.js explore "how does auth reach db?" --root . --pretty + # bounded repo orientation with next-step suggestions node ./dist/cli.js orient --root . --budget small --pretty @@ -131,7 +134,8 @@ Use these as starting points, then see [docs/cli.md](./docs/cli.md) for all flag codegraph review --base HEAD --head WORKTREE --summary codegraph impact --base HEAD --head WORKTREE --pretty -# repo orientation and bounded follow-up +# repo question, orientation, and bounded follow-up +codegraph explore "how does auth reach db?" --root . --pretty codegraph orient --root . --budget small --pretty codegraph search "build review report" --json codegraph explain src/review.ts @@ -340,7 +344,7 @@ For a custom location, use `codegraph skill install --target /skills/codeg ## Using as a library -Use the TypeScript API when another program needs deterministic file packs, review packets, or model prompts. CLI `--pretty` and `--summary` output is also useful for model-readable triage, but library callers should keep structured fields until the final UI or prompt boundary. For repeated calls, prefer one warm `createCodeReviewSession()` or one agent/MCP session over rebuilding ad hoc indexes. +Use the TypeScript API when another program needs deterministic explore responses, file packs, review packets, or model prompts. CLI `--pretty` and `--summary` output is also useful for model-readable triage, but library callers should keep structured fields until the final UI or prompt boundary. For repeated calls, prefer one warm `createCodeReviewSession()` or one agent/MCP session over rebuilding ad hoc indexes. ```ts import { @@ -430,8 +434,8 @@ For the full capability matrix, limitations, and fixture coverage, see [docs/lan - [docs/installation.md](./docs/installation.md): source checkout, scoped registry, release tarball, native runtime modes, and reduced-mode behavior - [docs/cli.md](./docs/cli.md): command reference, output formats, SQLite schema, review bundles, and graph export usage -- [docs/library-api.md](./docs/library-api.md): agent orientation/packet/search/explain/artifacts, semantic chunking, indexing, graph APIs, read-only SQL, impact examples, and programmatic review output -- [docs/agent-workflows.md](./docs/agent-workflows.md): orientation packets, search anchors, MCP, sessions, streaming, tool wrappers, review bundles, and agent-oriented review recipes +- [docs/library-api.md](./docs/library-api.md): agent explore/orientation/packet/search/explain/artifacts, semantic chunking, indexing, graph APIs, read-only SQL, impact examples, and programmatic review output +- [docs/agent-workflows.md](./docs/agent-workflows.md): explore, orientation packets, search anchors, MCP, sessions, streaming, tool wrappers, review bundles, and agent-oriented review recipes - [docs/mcp.md](./docs/mcp.md): MCP server setup, tool list, safety model, and client configuration examples - [docs/how-it-works.md](./docs/how-it-works.md): performance, caching, native runtime behavior, architecture, and testing guidance - [docs/language-parity.md](./docs/language-parity.md): per-language capability matrix diff --git a/codegraph-skill/codegraph/SKILL.md b/codegraph-skill/codegraph/SKILL.md index a49a93d6..f3b83c86 100644 --- a/codegraph-skill/codegraph/SKILL.md +++ b/codegraph-skill/codegraph/SKILL.md @@ -11,7 +11,7 @@ Use Codegraph for structure-aware repo questions: - symbol navigation with definitions, references, dependencies, and paths - PR or worktree impact review with candidate tests and risk signals - duplicate cleanup and refactor-risk triage -- bounded agent context through orientation, search, packets, explain, and MCP +- bounded agent context through explore, orientation, search, packets, explain, and MCP Prefer plain text search for raw strings, logs, config keys, secrets, and exact literals. Do not use Codegraph as the only evidence for runtime behavior; pair it with tests or execution. @@ -24,10 +24,11 @@ For PR, worktree, or sweeping review tasks, start with the compact reviewer hand codegraph review --base HEAD --head WORKTREE --summary ``` -Use `codegraph impact --base HEAD --head WORKTREE --pretty` when you need the broader blast-radius map. For unfamiliar repos without a diff, start bounded with `codegraph orient --root . --budget small --pretty`. +Use `codegraph impact --base HEAD --head WORKTREE --pretty` when you need the broader blast-radius map. For unfamiliar repos without a diff, start bounded with `codegraph explore "how does auth reach db?" --root . --pretty` or `codegraph orient --root . --budget small --pretty` when no concrete question exists. Use `doctor` only when install, native-runtime, or artifact health is the task. Then choose the smallest useful follow-up: +- explore: `codegraph explore "how does auth reach db?" --pretty` - packet: `codegraph packet get --pretty` - search: `codegraph search "auth user" --json` - explain: `codegraph explain ` @@ -54,6 +55,7 @@ Hybrid search is code-first by default, and search/explain packets include analy Current high-value surfaces: +- `explore --pretty`: one-call question answer with anchors, packets, paths, blast radius, candidate tests, and follow-ups - `orient --pretty`: ranked first-turn focus targets with copyable follow-ups - `impact --pretty`: ranked "what could this break?" map - `review --summary`: compact reviewer handoff @@ -65,7 +67,7 @@ Treat duplicate leads and call-compatibility hints as review leads, not proof. ## MCP If MCP tools are available, prefer them over repeated CLI invocations. -Use MCP `orient`, `search`, `packet_get`, `goto`, `refs`, `deps`, `rdeps`, `path`, `impact`, `review`, and `query_sqlite` first. +Use MCP `explore`, `orient`, `search`, `packet_get`, `goto`, `refs`, `deps`, `rdeps`, `path`, `impact`, `review`, and `query_sqlite` first. After edits, check MCP response `freshness`: `refreshed` means Codegraph rebuilt before answering, and `stale` includes a reason plus bounded changed-file metadata before indexed context is trusted. Fall back to CLI when MCP is unavailable. diff --git a/docs/agent-workflows.md b/docs/agent-workflows.md index 8d29be63..1f02a8cb 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -21,6 +21,7 @@ codegraph impact --base HEAD --head WORKTREE --pretty For an unfamiliar repo, keep the first loop bounded and actionable: ```bash +codegraph explore "how does auth reach db?" --root . --pretty codegraph orient --root . --budget small --pretty codegraph search "auth user" --json codegraph explain --json @@ -29,7 +30,7 @@ codegraph explain --json For PR, worktree, or sweeping review tasks, prefer `review` first; use `impact` when you need the broader blast radius map instead of the reviewer handoff. Use `doctor` only when package/runtime state or an existing artifact path is the question. -Use `search` when the agent has a query but no handle, `explain` when it already knows a file/symbol/SQL object/handle, and `inspect` for a human-readable architecture summary. +Use `explore` when the agent has a broad question and needs search anchors, packets, paths, blast radius, candidate tests, and follow-ups in one bounded response. Use `search` when it only needs anchors, `explain` when it already knows a file/symbol/SQL object/handle, and `inspect` for a human-readable architecture summary. Use `artifact build` for durable handoff directories and `mcp serve` when repeated follow-up calls should share one warm repo session. Choose output by the next consumer: @@ -42,6 +43,18 @@ For durable repo-local scan scope, add `codegraph.config.json` at the project ro For raw command flags and output contracts, see [docs/cli.md](./cli.md). For library types and wrappers, see [docs/library-api.md](./library-api.md). +## Explore facade + +Start with `explore` when an agent can ask a concrete repo question: + +```bash +codegraph explore "how does auth reach db?" --root . --pretty +codegraph explore src/auth.ts --json --limit 5 --max-packets 3 +``` + +Explore orchestrates existing search, packet, path, reverse-dependency, and candidate-test surfaces. It returns `schemaVersion: 1`, the query, analysis metadata, summary bullets, anchors, bounded packets, dependency paths, blast radius, candidate tests, follow-ups, flat limits, and omission counts. +Use `--no-source` when the caller only needs anchors, paths, and follow-up commands. + ## Orientation packets Start with `orient` when an agent needs compact repo context without flooding the first prompt: diff --git a/docs/cli.md b/docs/cli.md index d5d95387..df74afa4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -14,7 +14,8 @@ Default workflow: - code review: `codegraph review --base HEAD --head WORKTREE --summary` - blast-radius follow-up: `codegraph impact --base HEAD --head WORKTREE --pretty` -- unfamiliar repo: `codegraph orient --root . --budget small --pretty` +- unfamiliar repo: `codegraph explore "how does auth reach db?" --root . --pretty` +- first-turn map: `codegraph orient --root . --budget small --pretty` - targeted follow-up: `codegraph search "" --json` then `codegraph explain ` ## Runtime selection @@ -128,6 +129,8 @@ codegraph index --workers --threads 8 --cache disk # Search for agent-ready anchors across symbols, paths, chunks, SQL objects, and graph context codegraph orient --root . --budget small --pretty codegraph orient --root . ./src --budget medium --json +codegraph explore "how does auth reach db?" --root . --pretty +codegraph explore src/auth.ts --json codegraph search "build review report" --json codegraph explain src/review.ts --json codegraph packet get src/cli.ts --pretty @@ -240,6 +243,7 @@ Short JSON shape: #### Agent orientation and packets +- Use `explore --pretty` for a one-call repo question that combines search anchors, bounded packets, dependency paths, reverse dependencies, candidate tests, limits, omissions, and follow-ups. Use `--limit`, `--max-packets`, `--max-paths`, or `--no-source` to keep output small. - Use `orient --pretty` as the compact first-turn reading surface for people or models; it prints the ranked `focus` targets and their follow-up commands before the scope sketch. - Use `orient --json` when follow-up tools need exact focus reasons, limits, and omitted counts. Orient suppresses index rebuild warnings so stdout stays parseable. - Small orientation budgets default to `--health skip`. Medium and large default to `--health summary`, which counts cycles and unresolved imports while omitting duplicate health; use `--health full` when exhaustive duplicate counts matter. @@ -248,6 +252,8 @@ Short JSON shape: `search` is deterministic and vectorless. Hybrid search is code-first by default: source symbols and implementation files outrank docs unless `--mode text` is explicit or docs are the strongest remaining evidence. Search JSON now includes top-level `analysis` metadata plus per-result `provenance` so mixed or reduced runs stay visible. `explain` resolves file paths, symbol names, SQL object names, and search handles into bounded packets with symbols, graph context, references, snippets, duplicate context, SQL facts, review tasks, candidate tests, analysis metadata, limits, omissions, and follow-ups. Use `--max-duplicates` to tune duplicate context in `explain` and `packet get`; duplicate context also uses an internal pair budget and reports skipped duplicate work through omission counts. +`explore` is a facade over existing primitives, not a second search engine. It returns `schemaVersion: 1`, the original query, `analysis`, summary bullets, anchors, packets, dependency paths, blast radius, candidate tests, follow-ups, flat limits, and omission counts. + For SQL, prefer handles or schema-qualified names when basenames may be ambiguous. Reference and snippet omission counts are lower bounds after bounded navigation reaches its cap. #### Artifact bundles @@ -260,7 +266,7 @@ For SQL, prefer handles or schema-qualified names when basenames may be ambiguou #### MCP server -- `mcp serve` exposes navigation, search, impact, review, SQLite query, session refresh, and artifact-build tools. +- `mcp serve` exposes explore, navigation, search, impact, review, SQLite query, session refresh, and artifact-build tools. - MCP uses stdio by default or Streamable HTTP with `--port `. - Startup is lazy by default; `--warmup` builds the base session cache before serving requests, and `--warmup-symbols` also builds the detailed symbol graph. - Index-backed responses include `freshness`; small file changes auto-refresh, while stale responses include a reason, total changed-file count, and a bounded changed-file sample. diff --git a/docs/library-api.md b/docs/library-api.md index 3feacdde..bc92ef03 100644 --- a/docs/library-api.md +++ b/docs/library-api.md @@ -115,7 +115,7 @@ Small orientation budgets default to `health: "skip"` and set health fields to ` `searchCodegraph()` builds a project snapshot and returns deterministic, agent-ready anchors across files, symbols, chunks, SQL objects, and optional graph neighborhoods. Hybrid search is code-first by default, so implementation files and symbols outrank docs unless `mode: "text"` is explicit or docs are the strongest remaining evidence. Identifier-like queries stay symbol-first. Pure `path` and `text` searches skip detailed symbol graph construction; hybrid, symbol, SQL, and graph searches keep symbol-aware ranking and neighbors. Handles are project-relative and explainable; result packets include top-level `analysis`, per-result `provenance`, `resultCount`, `totalCandidates`, `limits`, and `omittedCounts`. ```ts -import { buildCodegraphArtifact, explainCodegraphTarget, searchCodegraph } from "@lzehrung/codegraph"; +import { buildCodegraphArtifact, explainCodegraphTarget, exploreCodegraph, searchCodegraph } from "@lzehrung/codegraph"; const response = await searchCodegraph({ root: process.cwd(), @@ -126,8 +126,20 @@ const response = await searchCodegraph({ const first = response.results[0]; console.log(first?.handle, first?.rankReasons, first?.omittedCounts, first?.followUps); + +const explored = await exploreCodegraph({ + root: process.cwd(), + query: "how does auth reach db?", + limit: 5, + maxPackets: 3, + maxPaths: 3, +}); + +console.log(explored.summary, explored.paths, explored.followUps); ``` +Use `exploreCodegraph()` when the caller has a broad question and needs one bounded response over the existing search, packet, path, reverse-dependency, and candidate-test surfaces. The response has `schemaVersion: 1`, the original query, `analysis`, summary bullets, anchors, packets, paths, blast radius, candidate tests, follow-ups, flat limits, and omission counts. + Use `mode: "sql"` for SQL objects, or pass `from` plus `depth` with `mode: "graph"` to boost matches near a file path, file/chunk/graph handle, symbol handle, SQL handle, or symbol name. `explainCodegraphTarget()` resolves a file path, symbol name, SQL object name, or search handle into a bounded packet for follow-up agent work. Explanations include the same top-level `analysis` label as search so reduced or mixed runs stay visible. SQL object names resolve by exact name first; unqualified basenames resolve only when unique. File and symbol explanations also include bounded medium-or-higher duplicate context that touches the target, with stable handles and conservative repair hints. SQL related objects include a `relation` such as `incoming:reads_from`, `outgoing:writes_to`, or `same_file`. With changed context enabled, the packet includes compact review tasks and candidate tests: @@ -162,7 +174,7 @@ console.log(artifact.manifestPath, artifact.artifacts); The `graph.json` artifact is self-describing (`schemaVersion: 1`, `format: "codegraph.graph-json"`) and uses project-relative file paths and portable symbol handles. `questions.json` uses the same stable handles for follow-up commands. With `force: true`, stale known Codegraph artifact files are removed before the selected outputs are written; unrelated files in the directory are preserved. -`createAgentSession()` keeps one in-process project snapshot warm for repeated orient, search, explain, packet, artifact, and MCP calls. It uses incremental indexing with disk cache by default, auto-enables native workers for large cold builds, and carries forward top-level analysis metadata from the build report. +`createAgentSession()` keeps one in-process project snapshot warm for repeated explore, orient, search, explain, packet, artifact, and MCP calls. It uses incremental indexing with disk cache by default, auto-enables native workers for large cold builds, and carries forward top-level analysis metadata from the build report. Session callers can use `freshness: { policy: "check" | "auto" | "manual" }` plus `checkFreshness()` to detect file edits before reusing a warm snapshot. `check` reports stale state without invalidating, `auto` invalidates for bounded changes, and stale results include `changedFileCount`, `omittedChangedFileCount`, `reason`, and a bounded changed-file sample. Set `buildOptions.useNativeWorkers` to `false` to opt out. Use `buildCodegraphArtifactWithSession()` when a host already has a session and wants SQLite, graph JSON, report, questions, and manifest outputs from the same snapshot. `createCodegraphMcpHandlers()` exposes the same primitives without starting stdio, which is useful for tests or host applications: diff --git a/docs/mcp.md b/docs/mcp.md index f2ef908b..c78bf193 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2,7 +2,7 @@ Codegraph can run as a Model Context Protocol server so tool-capable agents can query repo structure without spawning a new CLI process for every follow-up. -Use MCP when an agent will make repeated navigation, search, packet, review, or artifact queries. Use normal CLI commands for one-off local inspection or when your agent runtime does not expose MCP tools. +Use MCP when an agent will make repeated explore, navigation, search, packet, review, or artifact queries. Use normal CLI commands for one-off local inspection or when your agent runtime does not expose MCP tools. ## Start the server @@ -35,6 +35,7 @@ Use stdio for a client-owned subprocess. Use HTTP for one long-running Codegraph The server exposes the same bounded primitives as the CLI and library session layer: +- `explore`: recommended first tool for broad repo questions; returns bounded anchors, packets, paths, blast radius, candidate tests, and follow-ups. - `orient`: compact first-turn repo context. - `packet_get`: bounded evidence packet by file path, symbol name, SQL object name, or stable target. - `search`: deterministic ranked search across paths, symbols, chunks, SQL objects, and graph context. @@ -193,9 +194,9 @@ OpenCode uses the `mcp` object in `opencode.json`: When Codegraph MCP tools are available to an agent: -1. Start with `orient`. -2. Use `search` to find anchors. -3. Use `packet_get`, `refs`, `goto`, `deps`, `rdeps`, or `path` for focused follow-up. +1. Start with `explore` for a broad question. +2. Use `orient` when you need a compact first-turn map rather than a question answer. +3. Use `search` to find anchors and `packet_get`, `refs`, `goto`, `deps`, `rdeps`, or `path` for focused follow-up. 4. Check `freshness` on MCP responses after edits; `refreshed` means the answer used an updated snapshot, and `stale` includes a reason plus a bounded changed-file sample. 5. Use `impact` and `review` for git-range risk analysis. 6. Use `query_sqlite` only for read-only artifact inspection; rebuild the artifact when it reports stale state. diff --git a/src/agent.ts b/src/agent.ts index 06e72765..50663ba8 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -7,6 +7,14 @@ export type { AgentSessionFreshnessOptions, AgentSessionOptions, } from "./agent/session.js"; +export { exploreCodegraph, exploreCodegraphWithSession, formatAgentExploreResponse } from "./agent/explore.js"; +export type { + AgentExploreBlastRadiusSummary, + AgentExploreDependencyPathSummary, + AgentExplorePacketSummary, + AgentExploreRequest, + AgentExploreResponse, +} from "./agent/explore.js"; export { orientCodegraph } from "./agent/orient.js"; export type { AgentModuleSummary, diff --git a/src/agent/explore.ts b/src/agent/explore.ts new file mode 100644 index 00000000..ad536d65 --- /dev/null +++ b/src/agent/explore.ts @@ -0,0 +1,486 @@ +import path from "node:path"; +import type { AnalysisSummary } from "../analysisSummary.js"; +import { getReverseDependencies, getShortestPath, type DependencyNode } from "../graphs/traversal.js"; +import type { BuildOptions } from "../indexer/types.js"; +import { toProjectDisplayPath } from "../util/paths.js"; +import { getCodegraphPacketWithSession, type AgentPacketResponse } from "./packet.js"; +import { searchCodegraphWithSession, type AgentSearchResponse, type AgentSearchResult } from "./search.js"; +import { createAgentSession, type AgentProjectSnapshot, type AgentSession } from "./session.js"; +import { quoteShellArg } from "./shell.js"; + +export type AgentExploreRequest = { + root: string; + query: string; + buildOptions?: BuildOptions; + limit?: number; + maxPackets?: number; + maxPaths?: number; + includeSource?: boolean; +}; + +export type AgentExplorePacketSummary = AgentPacketResponse; + +export type AgentExploreDependencyPathSummary = { + from: string; + to: string; + path: string[]; +}; + +export type AgentExploreBlastRadiusSummary = { + file: string; + reverseDependencies: Array<{ file: string; depth: number }>; + omittedCount: number; +}; + +export type AgentExploreResponse = { + schemaVersion: 1; + query: string; + analysis: AnalysisSummary; + summary: string[]; + anchors: AgentSearchResult[]; + packets: AgentExplorePacketSummary[]; + paths: AgentExploreDependencyPathSummary[]; + blastRadius: AgentExploreBlastRadiusSummary[]; + candidateTests: string[]; + followUps: string[]; + limits: Record; + omittedCounts: Record; +}; + +const DEFAULT_ANCHOR_LIMIT = 5; +const DEFAULT_MAX_PACKETS = 3; +const DEFAULT_MAX_PATHS = 3; +const DEFAULT_MAX_REVERSE_DEPENDENCIES = 20; +const DEFAULT_MAX_CANDIDATE_TESTS = 10; +const MAX_ANCHOR_LIMIT = 50; +const MAX_PACKET_LIMIT = 10; +const MAX_PATH_LIMIT = 10; + +export async function exploreCodegraph(request: AgentExploreRequest): Promise { + const session = createAgentSession({ + root: request.root, + ...(request.buildOptions ? { buildOptions: request.buildOptions } : {}), + }); + return await exploreCodegraphWithSession(session, request); +} + +export async function exploreCodegraphWithSession( + session: AgentSession, + request: AgentExploreRequest, +): Promise { + const anchorLimit = boundedPositiveInteger(request.limit, DEFAULT_ANCHOR_LIMIT, MAX_ANCHOR_LIMIT); + const maxPackets = boundedPositiveInteger(request.maxPackets, DEFAULT_MAX_PACKETS, MAX_PACKET_LIMIT); + const maxPaths = boundedPositiveInteger(request.maxPaths, DEFAULT_MAX_PATHS, MAX_PATH_LIMIT); + const includeSource = request.includeSource ?? true; + const search = await searchCodegraphWithSession(session, { + root: request.root, + query: request.query, + limit: anchorLimit, + includeSnippets: includeSource, + }); + const snapshot = await session.loadProject({ symbolGraph: "skip" }); + const anchors = search.results.slice(0, anchorLimit); + const anchorFiles = collectAnchorFiles(snapshot, request.query, anchors); + const packetTargets = includeSource ? collectPacketTargets(anchors, maxPackets) : []; + const packets = await collectPackets(session, request.root, packetTargets); + const paths = collectDependencyPaths(snapshot, request.query, anchorFiles, maxPaths); + const blastRadius = collectBlastRadius(snapshot, anchorFiles, DEFAULT_MAX_REVERSE_DEPENDENCIES); + const candidateTests = collectCandidateTests(snapshot, anchorFiles, DEFAULT_MAX_CANDIDATE_TESTS); + const followUps = collectFollowUps(request.root, request.query, anchors, packets, anchorFiles, includeSource); + + return { + schemaVersion: 1, + query: request.query, + analysis: search.analysis, + summary: buildSummary(search, packets, paths, blastRadius, candidateTests), + anchors, + packets, + paths, + blastRadius, + candidateTests, + followUps, + limits: { + anchors: anchorLimit, + packets: maxPackets, + paths: maxPaths, + reverseDependencies: DEFAULT_MAX_REVERSE_DEPENDENCIES, + candidateTests: DEFAULT_MAX_CANDIDATE_TESTS, + }, + omittedCounts: { + anchors: search.omittedCounts.results, + packets: Math.max(0, collectPacketTargets(anchors, Number.POSITIVE_INFINITY).length - packetTargets.length), + paths: countOmittedPaths(snapshot, request.query, anchorFiles, maxPaths), + blastRadius: blastRadius.reduce((sum, entry) => sum + entry.omittedCount, 0), + candidateTests: Math.max(0, countCandidateTests(snapshot, anchorFiles) - candidateTests.length), + }, + }; +} + +export function formatAgentExploreResponse(response: AgentExploreResponse): string { + const lines: string[] = ["Summary"]; + if (response.summary.length) { + lines.push(...response.summary.map((entry) => `- ${entry}`)); + } else { + lines.push("- No summary available."); + } + + lines.push("", "Anchors"); + if (response.anchors.length) { + for (const anchor of response.anchors) { + lines.push(`- ${anchor.label} [${anchor.kind}] ${anchor.file}`); + } + } else { + lines.push("- No anchors found."); + } + + lines.push("", "Relevant source"); + if (response.packets.length) { + for (const packet of response.packets) { + lines.push(`- ${packet.target} [${packet.kind}]`); + for (const summary of packetSummaryLines(packet).slice(0, 3)) { + lines.push(` - ${summary}`); + } + } + } else { + lines.push("- Not included."); + } + + lines.push("", "Paths"); + if (response.paths.length) { + for (const entry of response.paths) { + lines.push(`- ${entry.from} -> ${entry.to}: ${entry.path.join(" -> ")}`); + } + } else { + lines.push("- No dependency paths found."); + } + + lines.push("", "Blast radius"); + if (response.blastRadius.length) { + for (const entry of response.blastRadius) { + const files = entry.reverseDependencies.map((dependency) => dependency.file).join(", "); + const suffix = entry.omittedCount ? `, ${entry.omittedCount} omitted` : ""; + lines.push(`- ${entry.file}: ${files || "no reverse dependencies"}${suffix}`); + } + } else { + lines.push("- No reverse dependencies found."); + } + + lines.push("", "Candidate tests"); + if (response.candidateTests.length) { + lines.push(...response.candidateTests.map((file) => `- ${file}`)); + } else { + lines.push("- None detected."); + } + + lines.push("", "Follow-ups"); + lines.push(...response.followUps.map((entry) => `- ${entry}`)); + + lines.push("", "Limits"); + for (const [name, value] of Object.entries(response.limits)) { + lines.push(`- ${name}: ${value}`); + } + return lines.join("\n"); +} + +function packetSummaryLines(packet: AgentPacketResponse): string[] { + const payload = packet.packet; + if (!("summary" in payload)) return []; + return Array.isArray(payload.summary) ? payload.summary : []; +} + +function boundedPositiveInteger(value: number | undefined, fallback: number, max: number): number { + if (value === undefined) return fallback; + if (!Number.isFinite(value)) return fallback; + return Math.min(max, Math.max(0, Math.floor(value))); +} + +function collectPackets( + session: AgentSession, + root: string, + targets: readonly string[], +): Promise { + return Promise.all( + targets.map( + async (target) => + await getCodegraphPacketWithSession(session, { + root, + target, + }), + ), + ); +} + +function collectPacketTargets(anchors: readonly AgentSearchResult[], limit: number): string[] { + const targets: string[] = []; + const seen = new Set(); + for (const anchor of anchors) { + const target = anchor.kind === "file" ? anchor.file : anchor.handle; + if (seen.has(target)) continue; + seen.add(target); + targets.push(target); + if (targets.length >= limit) break; + } + return targets; +} + +function collectAnchorFiles( + snapshot: AgentProjectSnapshot, + query: string, + anchors: readonly AgentSearchResult[], +): string[] { + const files = new Set(); + for (const file of extractFileMentions(snapshot, query)) { + files.add(file); + } + for (const anchor of anchors) { + const absolute = path.resolve(snapshot.root, anchor.file); + if (snapshot.fileGraph.nodes.has(absolute)) { + files.add(absolute); + } + } + return [...files]; +} + +function extractFileMentions(snapshot: AgentProjectSnapshot, query: string): string[] { + const normalizedQuery = normalizeQueryPathText(query); + const explicitFiles: string[] = []; + const basenameMatches = new Map(); + for (const file of snapshot.fileGraph.nodes) { + const relative = toProjectDisplayPath(snapshot.root, file); + const normalizedRelative = normalizeQueryPathText(relative); + if (normalizedQuery.includes(normalizedRelative)) { + explicitFiles.push(file); + continue; + } + const basename = path.basename(relative); + const bucket = basenameMatches.get(basename) ?? []; + bucket.push(file); + basenameMatches.set(basename, bucket); + } + + for (const token of tokenizeQuery(query)) { + const matches = basenameMatches.get(token); + if (matches?.length === 1) { + explicitFiles.push(matches[0]!); + } + } + return uniqueFiles(explicitFiles); +} + +function normalizeQueryPathText(input: string): string { + return input.replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase(); +} + +function tokenizeQuery(query: string): string[] { + return query + .split(/\s+/) + .map((token) => token.replace(/^["'`([{]+|["'`\])},.:;]+$/g, "")) + .filter((token) => token.length > 0); +} + +function uniqueFiles(files: readonly string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const file of files) { + if (seen.has(file)) continue; + seen.add(file); + out.push(file); + } + return out; +} + +function collectDependencyPaths( + snapshot: AgentProjectSnapshot, + query: string, + anchorFiles: readonly string[], + maxPaths: number, +): AgentExploreDependencyPathSummary[] { + if (!shouldCollectPaths(query, anchorFiles)) return []; + const paths: AgentExploreDependencyPathSummary[] = []; + for (let fromIndex = 0; fromIndex < anchorFiles.length; fromIndex += 1) { + for (let toIndex = 0; toIndex < anchorFiles.length; toIndex += 1) { + if (fromIndex === toIndex) continue; + const from = anchorFiles[fromIndex]!; + const to = anchorFiles[toIndex]!; + const pathResult = getShortestPath(snapshot.fileGraph, from, to); + if (!pathResult || pathResult.length < 2) continue; + paths.push({ + from: toProjectDisplayPath(snapshot.root, from), + to: toProjectDisplayPath(snapshot.root, to), + path: pathResult.map((file) => toProjectDisplayPath(snapshot.root, file)), + }); + if (paths.length >= maxPaths) return paths; + } + } + return paths; +} + +function countOmittedPaths( + snapshot: AgentProjectSnapshot, + query: string, + anchorFiles: readonly string[], + maxPaths: number, +): number { + if (!shouldCollectPaths(query, anchorFiles)) return 0; + let count = 0; + for (let fromIndex = 0; fromIndex < anchorFiles.length; fromIndex += 1) { + for (let toIndex = 0; toIndex < anchorFiles.length; toIndex += 1) { + if (fromIndex === toIndex) continue; + const pathResult = getShortestPath(snapshot.fileGraph, anchorFiles[fromIndex]!, anchorFiles[toIndex]!); + if (pathResult && pathResult.length > 1) { + count += 1; + } + } + } + return Math.max(0, count - maxPaths); +} + +function shouldCollectPaths(query: string, anchorFiles: readonly string[]): boolean { + if (anchorFiles.length < 2) return false; + const normalized = query.toLowerCase(); + return /\b(reach|flow|call|through|path|depend|from|to)\b/.test(normalized); +} + +function collectBlastRadius( + snapshot: AgentProjectSnapshot, + anchorFiles: readonly string[], + limit: number, +): AgentExploreBlastRadiusSummary[] { + const summaries: AgentExploreBlastRadiusSummary[] = []; + for (const file of anchorFiles.slice(0, DEFAULT_ANCHOR_LIMIT)) { + const dependencies = getReverseDependencies(snapshot.fileGraph, file, { limit: limit + 1, depth: 2 }); + const visible = dependencies.slice(0, limit).map((dependency) => formatDependency(snapshot, dependency)); + summaries.push({ + file: toProjectDisplayPath(snapshot.root, file), + reverseDependencies: visible, + omittedCount: Math.max(0, dependencies.length - limit), + }); + } + return summaries; +} + +function formatDependency(snapshot: AgentProjectSnapshot, dependency: DependencyNode): { file: string; depth: number } { + return { + file: toProjectDisplayPath(snapshot.root, dependency.file), + depth: dependency.depth, + }; +} + +function collectCandidateTests( + snapshot: AgentProjectSnapshot, + anchorFiles: readonly string[], + limit: number, +): string[] { + return candidateTestsForAnchors(snapshot, anchorFiles).slice(0, limit); +} + +function countCandidateTests(snapshot: AgentProjectSnapshot, anchorFiles: readonly string[]): number { + return candidateTestsForAnchors(snapshot, anchorFiles).length; +} + +function candidateTestsForAnchors(snapshot: AgentProjectSnapshot, anchorFiles: readonly string[]): string[] { + const candidateNames = new Set(); + for (const file of anchorFiles) { + candidateNames.add(normalizeStem(path.basename(file))); + for (const dependency of getReverseDependencies(snapshot.fileGraph, file, { + depth: 2, + limit: DEFAULT_MAX_REVERSE_DEPENDENCIES, + })) { + candidateNames.add(normalizeStem(path.basename(dependency.file))); + } + } + const candidateNameList = [...candidateNames]; + const tests: string[] = []; + for (const file of snapshot.fileGraph.nodes) { + const relative = toProjectDisplayPath(snapshot.root, file); + if (!looksLikeTestFile(relative)) continue; + const testStem = normalizeStem(path.basename(relative)); + if (!candidateNameList.length || candidateNameList.some((candidateName) => testStem.includes(candidateName))) { + tests.push(relative); + } + } + return tests.sort(); +} + +function looksLikeTestFile(file: string): boolean { + const normalized = file.toLowerCase(); + return /(^|[/.])(test|tests|__tests__)([/.]|$)/.test(normalized) || /\.(test|spec)\.[^.]+$/.test(normalized); +} + +function normalizeStem(name: string): string { + return name + .replace(/\.(test|spec)\.[^.]+$/i, "") + .replace(/\.[^.]+$/, "") + .toLowerCase(); +} + +function collectFollowUps( + root: string, + query: string, + anchors: readonly AgentSearchResult[], + packets: readonly AgentPacketResponse[], + anchorFiles: readonly string[], + includeSource: boolean, +): string[] { + const followUps: string[] = []; + for (const file of anchorFiles.slice(0, 3)) { + const relative = toProjectDisplayPath(root, file); + followUps.push(`codegraph packet get ${quoteShellArg(relative)} --pretty`); + } + for (const anchor of anchors) { + followUps.push(...anchor.followUps); + } + for (const packet of packets) { + followUps.push(...packet.followUps); + } + for (const file of anchorFiles.slice(0, 3)) { + const relative = toProjectDisplayPath(root, file); + followUps.push(`codegraph refs --file ${quoteShellArg(relative)} --line 1 --col 0`); + } + if (!includeSource) { + followUps.push(`codegraph explore ${quoteShellArg(query)} --root ${quoteShellArg(root)} --pretty`); + } + if (!anchors.length) { + followUps.push(`codegraph search ${quoteShellArg(query)} --root ${quoteShellArg(root)} --json`); + followUps.push(`codegraph orient --root ${quoteShellArg(root)} --budget small --pretty`); + } + return dedupeStrings(followUps).slice(0, 12); +} + +function dedupeStrings(values: readonly string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const value of values) { + if (seen.has(value)) continue; + seen.add(value); + out.push(value); + } + return out; +} + +function buildSummary( + search: AgentSearchResponse, + packets: readonly AgentPacketResponse[], + paths: readonly AgentExploreDependencyPathSummary[], + blastRadius: readonly AgentExploreBlastRadiusSummary[], + candidateTests: readonly string[], +): string[] { + if (!search.results.length) { + return [`No anchors matched "${search.query}".`, "Use follow-ups to broaden the search or orient the repository."]; + } + const summary = [`Found ${search.results.length} anchor(s) for "${search.query}".`]; + if (packets.length) { + summary.push(`Included ${packets.length} bounded source packet(s).`); + } + if (paths.length) { + summary.push(`Found ${paths.length} dependency path(s).`); + } + if (blastRadius.length) { + const reverseCount = blastRadius.reduce((sum, entry) => sum + entry.reverseDependencies.length, 0); + summary.push(`Found ${reverseCount} reverse dependenc${reverseCount === 1 ? "y" : "ies"} across primary anchors.`); + } + if (candidateTests.length) { + summary.push(`Detected ${candidateTests.length} candidate test file(s).`); + } + return summary; +} diff --git a/src/cli.ts b/src/cli.ts index f26b6812..f4bf3d1a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -31,6 +31,7 @@ import { buildDoctorReport } from "./cli/doctor.js"; import { handleDriftCommand } from "./cli/drift.js"; import { handleDuplicatesCommand } from "./cli/duplicates.js"; import { handleExplainCommand } from "./cli/explain.js"; +import { handleExploreCommand } from "./cli/explore.js"; import { handleGraphDeltaCommand } from "./cli/graphDelta.js"; import { handleGraphQueryCommand } from "./cli/graphQueries.js"; import { handleGrepCommand } from "./cli/grep.js"; @@ -522,6 +523,21 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { return await resolveFilesFromRoots(); }; + if (cmd === "explore") { + await handleExploreCommand({ + positionals: parsed.positionals, + root: projectRootFs, + buildOptions: buildAgentOptions(), + getOpt, + hasFlag, + writeJSONLine, + writeStdoutLine, + writeStderrLine, + exit: exitCli, + }); + return; + } + if (cmd === "search") { await handleSearchCommand({ positionals: parsed.positionals, diff --git a/src/cli/explore.ts b/src/cli/explore.ts new file mode 100644 index 00000000..31cf732c --- /dev/null +++ b/src/cli/explore.ts @@ -0,0 +1,30 @@ +import { exploreCodegraph, formatAgentExploreResponse } from "../agent/explore.js"; +import type { CliAgentCommandContext } from "./context.js"; +import { EXPLORE_HELP_TEXT } from "./help.js"; +import { parsePositiveIntegerOption } from "./options.js"; + +export type ExploreCommandContext = CliAgentCommandContext; + +export async function handleExploreCommand(context: ExploreCommandContext): Promise { + const query = context.positionals.join(" ").trim(); + if (!query) { + context.writeStderrLine(EXPLORE_HELP_TEXT.trimEnd()); + context.exit(2); + } + + const response = await exploreCodegraph({ + root: context.root, + query, + ...(context.buildOptions ? { buildOptions: context.buildOptions } : {}), + limit: parsePositiveIntegerOption(context.getOpt("--limit"), "--limit", 5), + maxPackets: parsePositiveIntegerOption(context.getOpt("--max-packets"), "--max-packets", 3), + maxPaths: parsePositiveIntegerOption(context.getOpt("--max-paths"), "--max-paths", 3), + includeSource: !context.hasFlag("--no-source"), + }); + + if (context.hasFlag("--json") || !context.hasFlag("--pretty")) { + context.writeJSONLine(response); + } else { + context.writeStdoutLine(formatAgentExploreResponse(response)); + } +} diff --git a/src/cli/help.ts b/src/cli/help.ts index 6d9297a8..1380c03c 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -4,6 +4,7 @@ Usage: codegraph [options] [path] Commands: orient Build a compact first-turn packet for agent repo context + explore Answer a broad repo question with search, packets, paths, and blast radius review Generate code review report packet Retrieve bounded evidence packets by file path or stable target search Ranked agent search across files, symbols, chunks, SQL, and graph context @@ -72,11 +73,13 @@ Recommended review commands: Unfamiliar repo: codegraph orient --root . --budget small --pretty + codegraph explore "how does auth reach db?" --root . --pretty Examples: codegraph review --base HEAD --head WORKTREE --summary codegraph orient ./src --budget small --pretty codegraph search "auth user" --json + codegraph explore "how does auth reach db?" --pretty codegraph explain src/auth.ts --json codegraph impact --provider git --base HEAD --head WORKTREE codegraph packet get file:src%2Fcli.ts --json @@ -113,6 +116,7 @@ const knownCliCommands = new Set([ "duplicates", "dumpmod", "explain", + "explore", "goto", "graph", "graph-delta", @@ -139,6 +143,18 @@ export function isKnownCliCommand(command: string): boolean { return knownCliCommands.has(command); } +export const EXPLORE_HELP_TEXT = `codegraph explore - Answer a broad repo question with bounded repo context + +Usage: codegraph explore "" [--root ] [--limit ] [--max-packets ] [--max-paths ] [--no-source] [--json | --pretty] + +Output: + Explore orchestrates search, packet retrieval, dependency paths, reverse dependencies, candidate tests, and follow-up commands. + JSON is the default. Use --pretty for concise model-readable sections. + +Index options: + Supports shared --cache, --cache-strict, --cache-verify, --threads, --native, --workers, --include-glob, --ignore-glob, and --no-gitignore options. +`; + export const SEARCH_HELP_TEXT = `codegraph search - Ranked agent search across project context Usage: codegraph search "" [--root ] [--mode hybrid|symbol|path|text|graph|sql] [--limit ] [--from ] [--depth ] [--no-snippets] [--json] diff --git a/src/cli/options.ts b/src/cli/options.ts index 5734c582..8730c706 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -77,6 +77,8 @@ const CLI_VALUE_OPTIONS = new Set([ "--max-snippets", "--max-symbols", "--max-duplicates", + "--max-packets", + "--max-paths", "--artifact", "--host", "--port", @@ -277,6 +279,14 @@ const CLI_COMMAND_SCHEMAS = new Map([ { kind: "any" }, ), ], + [ + "explore", + commandSchema( + [...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS, "--no-source"], + [...SHARED_BUILD_OPTIONS, "--limit", "--max-packets", "--max-paths"], + { kind: "any" }, + ), + ], [ "goto", commandSchema([...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS], SHARED_BUILD_OPTIONS, { diff --git a/src/index.ts b/src/index.ts index a147ea94..8383fd60 100644 --- a/src/index.ts +++ b/src/index.ts @@ -235,6 +235,16 @@ export type { AgentSessionOptions, } from "./agent/session.js"; +/** Agent one-call exploration facade over search, packets, paths, and blast radius. */ +export { exploreCodegraph, exploreCodegraphWithSession, formatAgentExploreResponse } from "./agent/explore.js"; +export type { + AgentExploreBlastRadiusSummary, + AgentExploreDependencyPathSummary, + AgentExplorePacketSummary, + AgentExploreRequest, + AgentExploreResponse, +} from "./agent/explore.js"; + /** Agent first-turn orientation packets with file-path follow-up targets. */ export { orientCodegraph } from "./agent/orient.js"; export type { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 4612a149..d4de0a1d 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -17,6 +17,7 @@ import { buildCodegraphArtifactWithSession } from "../agent/artifact.js"; import type { CodegraphArtifactBuildResult } from "../agent/artifact.js"; import { explainCodegraphTargetWithSession } from "../agent/explain.js"; import type { AgentExplanation, AgentExplanationReference } from "../agent/explain.js"; +import { exploreCodegraphWithSession, type AgentExploreResponse } from "../agent/explore.js"; import { orientCodegraphWithSession, type AgentOrientBudget, type AgentOrientResponse } from "../agent/orient.js"; import { getCodegraphPacketWithSession, type AgentPacketResponse } from "../agent/packet.js"; import { searchCodegraphWithSession } from "../agent/search.js"; @@ -109,6 +110,13 @@ export type CodegraphMcpHandlers = { depth?: number | undefined; limit?: number | undefined; }) => Promise>; + explore: (request: { + query: string; + limit?: number | undefined; + maxPackets?: number | undefined; + maxPaths?: number | undefined; + includeSource?: boolean | undefined; + }) => Promise>; orient: (request: { includeRoots?: string[] | undefined; budget?: AgentOrientBudget | undefined; @@ -455,6 +463,19 @@ function createCodegraphMcpHandlersForSession( }), ), + explore: async (request) => + await withFreshness( + async () => + await exploreCodegraphWithSession(session, { + root, + query: request.query, + ...(request.limit !== undefined ? { limit: request.limit } : {}), + ...(request.maxPackets !== undefined ? { maxPackets: request.maxPackets } : {}), + ...(request.maxPaths !== undefined ? { maxPaths: request.maxPaths } : {}), + ...(request.includeSource !== undefined ? { includeSource: request.includeSource } : {}), + }), + ), + orient: async (request) => await withFreshness( async () => @@ -875,6 +896,8 @@ async function callMcpTool(handlers: CodegraphMcpHandlers, name: string, input: switch (name) { case "search": return await handlers.search(searchSchema.parse(input)); + case "explore": + return await handlers.explore(exploreSchema.parse(input)); case "orient": return await handlers.orient(orientSchema.parse(input)); case "packet_get": @@ -949,6 +972,14 @@ const searchSchema = z.object({ limit: z.number().int().nonnegative().optional(), }); +const exploreSchema = z.object({ + query: z.string(), + limit: z.number().int().nonnegative().optional(), + maxPackets: z.number().int().nonnegative().optional(), + maxPaths: z.number().int().nonnegative().optional(), + includeSource: z.boolean().optional(), +}); + const orientSchema = z.object({ includeRoots: z.array(z.string()).optional(), budget: z.enum(["small", "medium", "large"]).optional(), diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 9a05d28e..63b6d986 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -46,6 +46,21 @@ export const MCP_TOOLS: Tool[] = [ ["query"], ), }, + { + name: "explore", + description: + "Recommended first tool for broad repo questions; returns bounded anchors, source packets, paths, blast radius, tests, and follow-ups.", + inputSchema: objectSchema( + { + query: stringProperty, + limit: { type: "integer", minimum: 0, maximum: 50, default: 5 }, + maxPackets: { type: "integer", minimum: 0, maximum: 10, default: 3 }, + maxPaths: { type: "integer", minimum: 0, maximum: 10, default: 3 }, + includeSource: { type: "boolean", default: true }, + }, + ["query"], + ), + }, { name: "orient", description: "Build a compact first-turn packet for agent repo context.", diff --git a/tests/agent-explore.test.ts b/tests/agent-explore.test.ts new file mode 100644 index 00000000..d8005c76 --- /dev/null +++ b/tests/agent-explore.test.ts @@ -0,0 +1,236 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { exploreCodegraph } from "../src/index.js"; +import { createCodegraphMcpHandlers, listCodegraphMcpTools } from "../src/mcp/server.js"; +import { captureCli } from "./helpers/cli.js"; +import { mkTmpDir } from "./helpers/filesystem.js"; + +type JsonRecord = Record; + +async function writeFile(root: string, relativePath: string, content: string): Promise { + const filePath = path.join(root, relativePath); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content, "utf8"); +} + +async function mkExploreRepo(): Promise { + const root = await mkTmpDir("cg-agent-explore-"); + await writeFile( + root, + "src/db.ts", + [ + "export type UserRecord = { id: string; active: boolean };", + "", + "export function readUser(userId: string): UserRecord {", + " return { id: userId, active: userId.length > 0 };", + "}", + "", + ].join("\n"), + ); + await writeFile( + root, + "src/auth.ts", + [ + "import { readUser } from './db';", + "", + "export function validateUser(userId: string) {", + " const user = readUser(userId);", + " return user.active;", + "}", + "", + ].join("\n"), + ); + await writeFile( + root, + "src/routes.ts", + [ + "import { validateUser } from './auth';", + "", + "export function handleRequest(userId: string) {", + " return validateUser(userId) ? 'ok' : 'denied';", + "}", + "", + ].join("\n"), + ); + await writeFile( + root, + "tests/routes.test.ts", + ["import { handleRequest } from '../src/routes';", "", "handleRequest('alice');", ""].join("\n"), + ); + return root; +} + +function readRecord(value: unknown, label: string): JsonRecord { + expect(value, label).toBeTypeOf("object"); + expect(value, label).not.toBeNull(); + return value as JsonRecord; +} + +function readArray(value: unknown, label: string): unknown[] { + expect(Array.isArray(value), label).toBeTruthy(); + return value as unknown[]; +} + +function textOf(value: unknown): string { + return JSON.stringify(value); +} + +function expectExploreEnvelope(response: unknown, query: string): JsonRecord { + const record = readRecord(response, "explore response"); + expect(record.schemaVersion).toBe(1); + expect(record.query).toBe(query); + expect(record.analysis).toBeTypeOf("object"); + expect(Array.isArray(record.summary)).toBeTruthy(); + expect(Array.isArray(record.anchors)).toBeTruthy(); + expect(Array.isArray(record.packets)).toBeTruthy(); + expect(Array.isArray(record.paths)).toBeTruthy(); + expect(Array.isArray(record.blastRadius)).toBeTruthy(); + expect(Array.isArray(record.candidateTests)).toBeTruthy(); + expect(Array.isArray(record.followUps)).toBeTruthy(); + const limits = readRecord(record.limits, "limits"); + expect(limits.anchors).toBeTypeOf("number"); + expect(limits.packets).toBeTypeOf("number"); + expect(limits.paths).toBeTypeOf("number"); + expect(limits.reverseDependencies).toBeTypeOf("number"); + expect(limits.candidateTests).toBeTypeOf("number"); + expect(record.omittedCounts).toBeTypeOf("object"); + return record; +} + +describe("agent explore", () => { + it("returns a file packet and reverse dependency blast radius for a file-path query", async () => { + const root = await mkExploreRepo(); + const query = "src/auth.ts"; + + const response = expectExploreEnvelope(await exploreCodegraph({ root, query }), query); + const packets = readArray(response.packets, "packets"); + const blastRadius = readArray(response.blastRadius, "blastRadius"); + + expect(packets.some((packet) => textOf(packet).includes("src/auth.ts"))).toBeTruthy(); + expect(blastRadius.some((entry) => textOf(entry).includes("src/routes.ts"))).toBeTruthy(); + expect(readArray(response.followUps, "followUps").length).toBeGreaterThan(0); + }); + + it("returns symbol anchors and evidence packets for a symbol query", async () => { + const root = await mkExploreRepo(); + const query = "validateUser"; + + const response = expectExploreEnvelope(await exploreCodegraph({ root, query }), query); + const anchors = readArray(response.anchors, "anchors"); + const packets = readArray(response.packets, "packets"); + + expect(anchors.some((anchor) => textOf(anchor).includes("validateUser"))).toBeTruthy(); + expect(anchors.some((anchor) => textOf(anchor).includes("src/auth.ts"))).toBeTruthy(); + expect(packets.some((packet) => textOf(packet).includes("validateUser"))).toBeTruthy(); + }); + + it("includes the dependency path for a flow-style query between connected files", async () => { + const root = await mkExploreRepo(); + const query = "flow src/routes.ts to src/db.ts"; + + const response = expectExploreEnvelope(await exploreCodegraph({ root, query }), query); + const paths = readArray(response.paths, "paths"); + const pathText = textOf(paths); + + expect(pathText).toContain("src/routes.ts"); + expect(pathText).toContain("src/auth.ts"); + expect(pathText).toContain("src/db.ts"); + }); + + it("returns follow-ups instead of throwing when a query has no graph matches", async () => { + const root = await mkExploreRepo(); + const query = "definitelyMissingPaymentWebhook"; + + const response = expectExploreEnvelope(await exploreCodegraph({ root, query }), query); + const anchors = readArray(response.anchors, "anchors"); + const packets = readArray(response.packets, "packets"); + const followUps = readArray(response.followUps, "followUps"); + + expect(anchors).toHaveLength(0); + expect(packets).toHaveLength(0); + expect(followUps.length).toBeGreaterThan(0); + expect(textOf(followUps)).toContain("definitelyMissingPaymentWebhook"); + }); + + it("applies per-section limits and reports omitted counts", async () => { + const root = await mkExploreRepo(); + await writeFile( + root, + "src/admin.ts", + "import { validateUser } from './auth';\nexport const admin = validateUser('admin');\n", + ); + await writeFile( + root, + "src/audit.ts", + "import { validateUser } from './auth';\nexport const audit = validateUser('auditor');\n", + ); + const query = "validateUser"; + + const response = expectExploreEnvelope( + await exploreCodegraph({ + root, + query, + limit: 1, + maxPackets: 1, + maxPaths: 1, + }), + query, + ); + const omittedCounts = readRecord(response.omittedCounts, "omittedCounts"); + const limits = readRecord(response.limits, "limits"); + + expect(readArray(response.anchors, "anchors")).toHaveLength(1); + expect(readArray(response.packets, "packets")).toHaveLength(1); + expect(readArray(response.blastRadius, "blastRadius")).toHaveLength(1); + expect(readArray(response.followUps, "followUps").length).toBeGreaterThan(0); + expect(limits.anchors).toBe(1); + expect(limits.packets).toBe(1); + expect(limits.paths).toBe(1); + expect(omittedCounts.anchors).toBeGreaterThan(0); + }); + + it("prints the same bounded JSON envelope from the CLI explore command", async () => { + const root = await mkExploreRepo(); + const query = "validateUser"; + + const result = await captureCli(["explore", query, "--root", root, "--json"]); + + expect(result.exitCode).toBeUndefined(); + expect(result.stderr).toBe(""); + expectExploreEnvelope(JSON.parse(result.stdout) as unknown, query); + }); + + it("advertises a flat MCP explore schema and invokes the facade", async () => { + const root = await mkExploreRepo(); + const exploreTool = listCodegraphMcpTools().find((tool) => tool.name === "explore"); + expect(exploreTool).toBeTruthy(); + const schema = readRecord(exploreTool!.inputSchema, "explore input schema"); + const properties = readRecord(schema.properties, "explore schema properties"); + + expect(schema.type).toBe("object"); + expect(schema.required).toEqual(["query"]); + expect(schema.oneOf).toBeUndefined(); + expect(schema.anyOf).toBeUndefined(); + expect(schema.allOf).toBeUndefined(); + expect(properties.query).toEqual(expect.objectContaining({ type: "string" })); + expect(properties.root).toBeUndefined(); + expect(properties.limits).toBeUndefined(); + expect(properties.limit).toEqual(expect.objectContaining({ type: "integer", minimum: 0 })); + expect(properties.maxPackets).toEqual(expect.objectContaining({ type: "integer", minimum: 0 })); + expect(properties.maxPaths).toEqual(expect.objectContaining({ type: "integer", minimum: 0 })); + expect(properties.includeSource).toEqual(expect.objectContaining({ type: "boolean" })); + + const handlers = createCodegraphMcpHandlers({ root }); + const query = "validateUser"; + const response = expectExploreEnvelope( + await handlers.explore({ query, limit: 1, maxPackets: 1, maxPaths: 1 }), + query, + ); + + expect(readArray(response.anchors, "anchors")).toHaveLength(1); + expect(readArray(response.packets, "packets")).toHaveLength(1); + expect(readArray(response.blastRadius, "blastRadius")).toHaveLength(1); + expect(response.freshness).toBeTypeOf("object"); + }); +}); From 86a708393410cc9a5a9946240d1189bd6aacabd7 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Fri, 3 Jul 2026 05:12:48 -0400 Subject: [PATCH 09/26] Add agent installer workflow --- README.md | 15 +- codegraph-skill/codegraph/SKILL.md | 2 + docs/agent-workflows.md | 12 + docs/cli.md | 13 + docs/installation.md | 13 + docs/mcp.md | 12 + src/cli.ts | 15 + src/cli/help.ts | 28 ++ src/cli/install.ts | 61 ++++ src/cli/options.ts | 17 + src/installer/registry.ts | 487 +++++++++++++++++++++++++++++ tests/installer.test.ts | 108 +++++++ 12 files changed, 781 insertions(+), 2 deletions(-) create mode 100644 src/cli/install.ts create mode 100644 src/installer/registry.ts create mode 100644 tests/installer.test.ts diff --git a/README.md b/README.md index 6eb01d56..159ad057 100644 --- a/README.md +++ b/README.md @@ -318,7 +318,18 @@ codegraph graph --root . ./src --dot --output graph.dot ## Agent setup -Using a skill-aware agent? Install the bundled skill so repo navigation, semantic references, dependency tracing, and PR impact questions route to Codegraph automatically. The installer uses safe per-agent defaults and creates the target skills directory as needed: +Using a local agent client? The top-level installer configures Codegraph-owned MCP entries and marker files for supported clients, while preserving existing user config: + +```bash +codegraph install --target codex,claude --dry-run +codegraph install --target codex,claude --yes +codegraph install --print-config codex +codegraph uninstall --target codex --yes +``` + +Supported installer targets are `codex`, `claude`, `cursor`, `gemini`, `opencode`, and `agents`. Writes require `--yes`; `--dry-run` previews files, and `uninstall` removes only Codegraph-owned marker blocks, marker files, or MCP entries whose command is `codegraph`. + +Using a skill-aware agent only? Install the bundled skill directly so repo navigation, semantic references, dependency tracing, and PR impact questions route to Codegraph automatically: ```bash # Codex CLI: ${CODEX_HOME:-~/.codex}/skills/codegraph @@ -340,7 +351,7 @@ codegraph skill install --agent gemini codegraph skill install --agent opencode ``` -For a custom location, use `codegraph skill install --target /skills/codegraph`; the target must end with `skills/codegraph`, and the installer creates the directory as needed. Cursor CLI now supports native skills directories too, so `.cursor/skills/codegraph` works alongside the universal `~/.agents/skills/codegraph` location. To inspect the packaged skill paths and target health, run `codegraph skill doctor`. +For a custom skill location, use `codegraph skill install --target /skills/codegraph`; the target must end with `skills/codegraph`, and the installer creates the directory as needed. Cursor CLI supports native skills directories too, so `.cursor/skills/codegraph` works alongside the universal `~/.agents/skills/codegraph` location. To inspect the packaged skill paths and target health, run `codegraph skill doctor`. ## Using as a library diff --git a/codegraph-skill/codegraph/SKILL.md b/codegraph-skill/codegraph/SKILL.md index f3b83c86..06f0e4ad 100644 --- a/codegraph-skill/codegraph/SKILL.md +++ b/codegraph-skill/codegraph/SKILL.md @@ -42,9 +42,11 @@ Then choose the smallest useful follow-up: - impact: `codegraph impact --base HEAD --head WORKTREE --pretty` - review: `codegraph review --base HEAD --head WORKTREE --summary` - drift: `codegraph drift ./src --base origin/main --head HEAD --pretty --graph-edges summary --public-api removals` +- installer: `codegraph install --target codex,claude --dry-run` Use `--root` to define the project boundary for config lookup, cache scope, path confinement, and output normalization. For `orient`, `drift`, and positional graph commands, positional paths are include roots inside that project. +Use `codegraph install --target --yes` to configure supported local agent clients. Use `--dry-run` or `--print-config ` first; uninstall removes only Codegraph-owned marker blocks, marker files, or MCP entries whose command is `codegraph`. ## Output Choice diff --git a/docs/agent-workflows.md b/docs/agent-workflows.md index 1f02a8cb..ba2a1a37 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -99,6 +99,18 @@ codegraph drift ./src --base origin/main --head HEAD --compact-json Drift compares structural signals over time: dependency cycles, hotspots, unresolved imports, API surface changes, duplicate group counts, and graph edges. It is review and CI evidence, not runtime validation or compiler diagnostics. Use compact JSON for CI or agent handoff, and use graph-edge/API filters to keep human review output bounded. +## Agent client installer + +Use `install` when setting up Codegraph for supported local agent clients: + +```bash +codegraph install --target codex,claude --dry-run +codegraph install --target codex,claude --yes +codegraph uninstall --target codex --yes +``` + +Writes require `--yes`, and `--print-config ` prints the MCP snippet without touching disk. `uninstall` removes only Codegraph-owned marker blocks, marker files, or MCP entries whose command is `codegraph`. + ## MCP server Use `codegraph mcp serve --root . --stdio` when an agent can spawn and own a stdio MCP subprocess. diff --git a/docs/cli.md b/docs/cli.md index df74afa4..9606c6c6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -157,6 +157,12 @@ codegraph mcp serve --root . --stdio --allow-build codegraph mcp serve --root . --port 7331 codegraph mcp serve --root . --stdio --warmup codegraph mcp serve --root . --port 7331 --warmup-symbols + +# Install or preview agent client integration +codegraph install --target codex,claude --dry-run +codegraph install --target codex,claude --yes +codegraph install --print-config codex +codegraph uninstall --target codex --yes codegraph mcp --help # Chunk a file for LLM processing @@ -264,6 +270,13 @@ For SQL, prefer handles or schema-qualified names when basenames may be ambiguou - Use `--force` to replace recognizable stale Codegraph artifacts while preserving unrelated files. - Artifact contents exclude their own output directory and linked outside-root files. +#### Agent client installer + +- `install` configures Codegraph-owned MCP entries and marker files for supported local agent clients: `codex`, `claude`, `cursor`, `gemini`, `opencode`, and `agents`. +- Writes require `--yes`; use `--dry-run` to preview exact file changes or `--print-config ` to print a copyable MCP snippet without writing. +- `uninstall` removes only Codegraph-owned marker blocks, marker files, or MCP entries whose command is `codegraph`. +- `skill install` remains the lower-level primitive for copying the bundled skill directly. + #### MCP server - `mcp serve` exposes explore, navigation, search, impact, review, SQLite query, session refresh, and artifact-build tools. diff --git a/docs/installation.md b/docs/installation.md index 4a3caf36..89bd348f 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -86,6 +86,19 @@ Explicit CLI, library, and tool `native` options take precedence over `CODEGRAPH Reduced mode preserves graph-only and regex-backed recovery where available; it does not provide a non-native Tree-sitter parser. +## Agent client setup + +After installing the CLI, `codegraph install` can configure Codegraph-owned MCP entries and skill marker files for supported agent clients: + +```bash +codegraph install --target codex,claude --dry-run +codegraph install --target codex,claude --yes +codegraph install --print-config codex +``` + +Supported targets are `codex`, `claude`, `cursor`, `gemini`, `opencode`, and `agents`. Writes require `--yes`; `--dry-run` reports the files that would change, and `uninstall` removes only Codegraph-owned marker blocks, marker files, or MCP entries whose command is `codegraph`. +The lower-level `codegraph skill install --agent ` command remains available when you only want to copy the bundled skill. + ## Next steps - For CLI commands and examples, see [docs/cli.md](./cli.md). diff --git a/docs/mcp.md b/docs/mcp.md index c78bf193..65936119 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -63,6 +63,18 @@ Tool schemas are flat JSON objects for broad client compatibility; argument comb - `query_sqlite` rejects mutating SQL, recursive queries, synthetic payload functions, and stale artifact queries it cannot refresh safely. - SQLite responses are row- and byte-bounded. +## Installer + +Use `codegraph install` to configure supported local clients without manually editing MCP config files: + +```bash +codegraph install --target codex,claude --dry-run +codegraph install --target codex,claude --yes +codegraph install --print-config codex +``` + +The installer writes only Codegraph-owned marker blocks, marker files, or `codegraph` MCP entries. `codegraph uninstall --target --yes` removes only those owned entries. + ## Client Configuration Examples Use `command: "codegraph"` when the CLI is on `PATH`. Use the full executable path when the client runs with a narrower environment. diff --git a/src/cli.ts b/src/cli.ts index f4bf3d1a..3ed0a4ff 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -37,6 +37,7 @@ import { handleGraphQueryCommand } from "./cli/graphQueries.js"; import { handleGrepCommand } from "./cli/grep.js"; import { CLI_HELP_TEXT, helpTextForCommand, isKnownCliCommand } from "./cli/help.js"; import { handleImpactCommand } from "./cli/impact.js"; +import { handleInstallerCommand } from "./cli/install.js"; import { handleIndexCommand } from "./cli/index.js"; import { handleHotspotsCommand, handleInspectCommand } from "./cli/inspect.js"; import { handleOrientCommand } from "./cli/orient.js"; @@ -285,6 +286,20 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { return; } + if (cmd === "install" || cmd === "uninstall") { + await handleInstallerCommand({ + command: cmd, + positionals: parsed.positionals, + getOpt, + hasFlag, + writeJSONLine, + writeStdoutLine, + writeStderrLine, + exit: exitCli, + }); + return; + } + if (cmd === "sql") { const { handleSqlCommand } = await import("./cli/sql.js"); await handleSqlCommand({ diff --git a/src/cli/help.ts b/src/cli/help.ts index 1380c03c..a1ca6122 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -31,6 +31,8 @@ Commands: sql Query a SQLite graph export read-only chunk Chunk file for embeddings doctor Inspect backend/runtime state and local graph artifacts + install Configure Codegraph MCP and skill integration for agent clients + uninstall Remove Codegraph-owned installer configuration skill Install or inspect the bundled agent skill version Print the installed codegraph version @@ -85,6 +87,9 @@ Examples: codegraph packet get file:src%2Fcli.ts --json codegraph artifact build --root . --out codegraph-out --json codegraph mcp serve --root . --stdio + codegraph install --target codex,claude --yes + codegraph install --print-config codex + codegraph uninstall --target codex --yes codegraph inspect ./src --limit 20 codegraph duplicates ./src --min-confidence medium codegraph graph ./src @@ -124,6 +129,7 @@ const knownCliCommands = new Set([ "hotspots", "impact", "index", + "install", "inspect", "mcp", "orient", @@ -137,12 +143,32 @@ const knownCliCommands = new Set([ "sql", "unresolved", "version", + "uninstall", ]); export function isKnownCliCommand(command: string): boolean { return knownCliCommands.has(command); } +export const INSTALL_HELP_TEXT = `codegraph install - Configure Codegraph for supported agent clients + +Usage: codegraph install [target] [--target ] [--yes | --dry-run] [--print-config ] [--detect] [--json] + +Targets: + codex, claude, cursor, gemini, opencode, agents + +Safety: + Writes require --yes. Use --dry-run to preview changed files or --print-config to print the MCP snippet. +`; + +export const UNINSTALL_HELP_TEXT = `codegraph uninstall - Remove Codegraph-owned installer configuration + +Usage: codegraph uninstall [target] [--target ] [--yes | --dry-run] [--detect] [--json] + +Safety: + Removes only Codegraph-owned marker blocks, marker files, or MCP entries whose command is codegraph. +`; + export const EXPLORE_HELP_TEXT = `codegraph explore - Answer a broad repo question with bounded repo context Usage: codegraph explore "" [--root ] [--limit ] [--max-packets ] [--max-paths ] [--no-source] [--json | --pretty] @@ -360,6 +386,8 @@ export function helpTextForCommand(command: string, positionals: readonly string if (command === "orient") return ORIENT_HELP_TEXT; if (command === "packet") return PACKET_HELP_TEXT; if (command === "explain") return EXPLAIN_HELP_TEXT; + if (command === "install") return INSTALL_HELP_TEXT; + if (command === "uninstall") return UNINSTALL_HELP_TEXT; if (command === "drift") return DRIFT_HELP_TEXT; if (command === "duplicates") return DUPLICATES_HELP_TEXT; if (command === "artifact") return ARTIFACT_HELP_TEXT; diff --git a/src/cli/install.ts b/src/cli/install.ts new file mode 100644 index 00000000..4a6fe26f --- /dev/null +++ b/src/cli/install.ts @@ -0,0 +1,61 @@ +import { + detectInstallTargets, + installCodegraphTargets, + parseInstallTargetId, + parseInstallTargetIds, + printInstallConfig, + uninstallCodegraphTargets, + type InstallTargetId, +} from "../installer/registry.js"; + +export type InstallerCommandContext = { + command: "install" | "uninstall"; + positionals: string[]; + getOpt: (name: string) => string | undefined; + hasFlag: (name: string) => boolean; + writeJSONLine: (value: unknown) => void; + writeStdoutLine: (message: string) => void; + writeStderrLine: (message: string) => void; + exit: (code: number) => never; +}; + +export async function handleInstallerCommand(context: InstallerCommandContext): Promise { + const printConfigTarget = context.getOpt("--print-config"); + const targetIds = parseInstallerTargets(context); + const options = { + ...(targetIds !== undefined ? { targetIds } : {}), + yes: context.hasFlag("--yes"), + dryRun: context.hasFlag("--dry-run"), + }; + + if (printConfigTarget !== undefined) { + const targetId = parseInstallTargetId(printConfigTarget); + context.writeStdoutLine(printInstallConfig({ targetId }).trimEnd()); + return; + } + + if (context.hasFlag("--detect")) { + context.writeJSONLine({ targets: await detectInstallTargets(options) }); + return; + } + + if (context.command === "install") { + context.writeJSONLine(await installCodegraphTargets(options)); + return; + } + + context.writeJSONLine(await uninstallCodegraphTargets(options)); +} + +function parseInstallerTargets(context: InstallerCommandContext): InstallTargetId[] | undefined { + const targetOpt = context.getOpt("--target"); + const positionalTarget = context.positionals[0]; + if (context.positionals.length > 1) { + context.writeStderrLine(`Unexpected positional argument for ${context.command}: ${context.positionals[1]!}`); + context.exit(2); + } + if (targetOpt !== undefined && positionalTarget !== undefined) { + throw new Error("Use either --target or a positional target, not both."); + } + return parseInstallTargetIds(targetOpt ?? positionalTarget); +} diff --git a/src/cli/options.ts b/src/cli/options.ts index 8730c706..3fe037e9 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -63,6 +63,7 @@ const CLI_VALUE_OPTIONS = new Set([ "--duplicates", "--agent", "--target", + "--print-config", "--limit", "--fail-on", "--hotspot-jump-threshold", @@ -360,6 +361,22 @@ const CLI_COMMAND_SCHEMAS = new Map([ ), ], ["index", graphCommandSchema({ kind: "any" })], + [ + "install", + commandSchema(["--detect", "--dry-run", "--json", "--yes"], ["--print-config", "--target"], { + kind: "max", + max: 1, + usage: "Usage: codegraph install [target] [--target ] [--yes | --dry-run] [--print-config ]", + }), + ], + [ + "uninstall", + commandSchema(["--detect", "--dry-run", "--json", "--yes"], ["--target"], { + kind: "max", + max: 1, + usage: "Usage: codegraph uninstall [target] [--target ] [--yes | --dry-run]", + }), + ], [ "inspect", commandSchema([...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS], [...SHARED_BUILD_OPTIONS, "--limit"], { kind: "any" }), diff --git a/src/installer/registry.ts b/src/installer/registry.ts new file mode 100644 index 00000000..334a2952 --- /dev/null +++ b/src/installer/registry.ts @@ -0,0 +1,487 @@ +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { getSkillTargetDirForAgent, type SkillInstallAgent } from "../cli/skill.js"; +import { normalizePathForDisplay, pathExists } from "../cli/packageInfo.js"; + +export type InstallTargetId = SkillInstallAgent; + +export type TargetDetection = { + detected: boolean; + reason: string; + configPath?: string; + skillTargetDir: string; +}; + +export type InstallOptions = { + targetIds?: InstallTargetId[]; + yes?: boolean; + dryRun?: boolean; + homeDir?: string; + env?: Record; +}; + +export type PrintConfigOptions = { + targetId: InstallTargetId; + homeDir?: string; + env?: Record; +}; + +export type UninstallOptions = InstallOptions; + +export type InstallChange = { + target: InstallTargetId; + action: "create" | "update" | "delete" | "unchanged"; + path: string; + dryRun: boolean; +}; + +export type InstallResult = { + installed: boolean; + dryRun: boolean; + targets: InstallTargetId[]; + changes: InstallChange[]; +}; + +export type UninstallResult = { + uninstalled: boolean; + dryRun: boolean; + targets: InstallTargetId[]; + changes: InstallChange[]; +}; + +export type InstallTarget = { + id: InstallTargetId; + label: string; + detect(options?: InstallOptions): Promise; + printConfig(options?: PrintConfigOptions): string; + install(options?: InstallOptions): Promise; + uninstall(options?: UninstallOptions): Promise; +}; + +type ConfigKind = "toml-block" | "json-mcp-servers" | "json-opencode-mcp" | "skill-only"; + +type TargetDefinition = { + id: InstallTargetId; + label: string; + kind: ConfigKind; + configPath?: (settings: InstallerSettings) => string; +}; + +type InstallerSettings = { + homeDir: string; + env: Record; +}; + +type JsonRecord = Record; + +const TARGET_DEFINITIONS: TargetDefinition[] = [ + { + id: "codex", + label: "Codex CLI", + kind: "toml-block", + configPath: ({ homeDir }) => path.join(homeDir, ".codex", "config.toml"), + }, + { + id: "claude", + label: "Claude Code", + kind: "json-mcp-servers", + configPath: ({ homeDir }) => path.join(homeDir, ".claude", "mcp.json"), + }, + { + id: "cursor", + label: "Cursor", + kind: "json-mcp-servers", + configPath: ({ homeDir }) => path.join(homeDir, ".cursor", "mcp.json"), + }, + { + id: "gemini", + label: "Gemini CLI", + kind: "json-mcp-servers", + configPath: ({ homeDir }) => path.join(homeDir, ".gemini", "settings.json"), + }, + { + id: "opencode", + label: "OpenCode", + kind: "json-opencode-mcp", + configPath: ({ env, homeDir }) => + path.join(env.XDG_CONFIG_HOME ?? path.join(homeDir, ".config"), "opencode", "opencode.json"), + }, + { + id: "agents", + label: "Agents skill directory", + kind: "skill-only", + }, +]; + +const CODEGRAPH_TOML_MARKER_BEGIN = "# >>> codegraph mcp >>>"; +const CODEGRAPH_TOML_MARKER_END = "# <<< codegraph mcp <<<"; +const DEFAULT_TARGET_IDS = TARGET_DEFINITIONS.map((target) => target.id); + +export function listInstallTargets(): InstallTarget[] { + return TARGET_DEFINITIONS.map(createInstallTarget); +} + +export function parseInstallTargetIds(rawValue: string | undefined): InstallTargetId[] | undefined { + if (rawValue === undefined) return undefined; + const ids: InstallTargetId[] = []; + for (const part of rawValue.split(",")) { + const value = part.trim(); + if (!value) continue; + ids.push(parseInstallTargetId(value)); + } + if (!ids.length) { + throw new Error("--target must name at least one install target."); + } + return [...new Set(ids)]; +} + +export function parseInstallTargetId(value: string): InstallTargetId { + if (isInstallTargetId(value)) return value; + throw new Error(`Unknown install target "${value}". Expected ${DEFAULT_TARGET_IDS.join(", ")}.`); +} + +export async function detectInstallTargets(options: InstallOptions = {}): Promise { + return await Promise.all(listInstallTargets().map(async (target) => await target.detect(options))); +} + +export function printInstallConfig(options: PrintConfigOptions): string { + const target = createInstallTarget(definitionForTarget(options.targetId)); + return target.printConfig(options); +} + +export async function installCodegraphTargets(options: InstallOptions = {}): Promise { + assertWriteAllowed(options); + const targets = await resolveRequestedTargets(options); + const changes: InstallChange[] = []; + for (const target of targets) { + const result = await target.install(options); + changes.push(...result.changes); + } + return { + installed: changes.some((change) => change.action === "create" || change.action === "update"), + dryRun: options.dryRun ?? false, + targets: targets.map((target) => target.id), + changes, + }; +} + +export async function uninstallCodegraphTargets(options: UninstallOptions = {}): Promise { + assertWriteAllowed(options); + const targets = await resolveRequestedTargets(options); + const changes: InstallChange[] = []; + for (const target of targets) { + const result = await target.uninstall(options); + changes.push(...result.changes); + } + return { + uninstalled: changes.some((change) => change.action === "delete" || change.action === "update"), + dryRun: options.dryRun ?? false, + targets: targets.map((target) => target.id), + changes, + }; +} + +function createInstallTarget(definition: TargetDefinition): InstallTarget { + return { + id: definition.id, + label: definition.label, + detect: (options = {}) => Promise.resolve(detectTarget(definition, options)), + printConfig: (options = { targetId: definition.id }) => printTargetConfig(definition, options), + install: async (options = {}) => await installTarget(definition, options), + uninstall: async (options = {}) => await uninstallTarget(definition, options), + }; +} + +async function resolveRequestedTargets(options: InstallOptions): Promise { + if (options.targetIds?.length) { + return options.targetIds.map((id) => createInstallTarget(definitionForTarget(id))); + } + const detections = await detectInstallTargets(options); + const detectedIds = detections + .filter((detection) => detection.detected) + .map((detection, index) => { + const definition = TARGET_DEFINITIONS[index]!; + return definition.id; + }); + return detectedIds.map((id) => createInstallTarget(definitionForTarget(id))); +} + +function assertWriteAllowed(options: InstallOptions): void { + if (options.dryRun) return; + if (options.yes) return; + throw new Error("Install writes require --yes. Use --dry-run or --print-config to inspect changes first."); +} + +function detectTarget(definition: TargetDefinition, options: InstallOptions): TargetDetection { + const settings = installerSettings(options); + const configPath = definition.configPath?.(settings); + const skillTargetDir = getSkillTargetDirForAgent(definition.id, settings.homeDir, settings.env); + if (configPath !== undefined && pathExists(path.dirname(configPath))) { + return { + detected: true, + reason: `${definition.label} config directory exists`, + configPath: normalizePathForDisplay(configPath), + skillTargetDir: normalizePathForDisplay(skillTargetDir), + }; + } + if (pathExists(path.dirname(skillTargetDir))) { + return { + detected: true, + reason: `${definition.label} skill directory exists`, + ...(configPath !== undefined ? { configPath: normalizePathForDisplay(configPath) } : {}), + skillTargetDir: normalizePathForDisplay(skillTargetDir), + }; + } + return { + detected: definition.kind === "skill-only" && pathExists(path.dirname(path.dirname(skillTargetDir))), + reason: `${definition.label} was not detected`, + ...(configPath !== undefined ? { configPath: normalizePathForDisplay(configPath) } : {}), + skillTargetDir: normalizePathForDisplay(skillTargetDir), + }; +} + +async function installTarget(definition: TargetDefinition, options: InstallOptions): Promise { + const settings = installerSettings(options); + const dryRun = options.dryRun ?? false; + const changes: InstallChange[] = []; + const skillTargetDir = getSkillTargetDirForAgent(definition.id, settings.homeDir, settings.env); + changes.push(await upsertSkillPointer(definition, skillTargetDir, dryRun)); + if (definition.kind !== "skill-only") { + const configPath = requireConfigPath(definition, settings); + changes.push(await upsertConfig(definition, configPath, dryRun)); + } + return { installed: true, dryRun, targets: [definition.id], changes }; +} + +async function uninstallTarget(definition: TargetDefinition, options: UninstallOptions): Promise { + const settings = installerSettings(options); + const dryRun = options.dryRun ?? false; + const changes: InstallChange[] = []; + const skillTargetDir = getSkillTargetDirForAgent(definition.id, settings.homeDir, settings.env); + changes.push(await removeSkillPointer(definition, skillTargetDir, dryRun)); + if (definition.kind !== "skill-only") { + const configPath = requireConfigPath(definition, settings); + changes.push(await removeConfig(definition, configPath, dryRun)); + } + return { uninstalled: true, dryRun, targets: [definition.id], changes }; +} + +async function upsertSkillPointer( + definition: TargetDefinition, + skillTargetDir: string, + dryRun: boolean, +): Promise { + const markerPath = path.join(skillTargetDir, "CODEGRAPH_INSTALLED"); + const content = `Installed by codegraph install for ${definition.label}.\nRun codegraph skill install --agent ${definition.id} --force to refresh bundled skill files.\n`; + const existing = await readOptionalFile(markerPath); + if (existing === content) return change(definition.id, "unchanged", markerPath, dryRun); + if (!dryRun) { + await fsp.mkdir(skillTargetDir, { recursive: true }); + await fsp.writeFile(markerPath, content, "utf8"); + } + return change(definition.id, existing === null ? "create" : "update", markerPath, dryRun); +} + +async function removeSkillPointer( + definition: TargetDefinition, + skillTargetDir: string, + dryRun: boolean, +): Promise { + const markerPath = path.join(skillTargetDir, "CODEGRAPH_INSTALLED"); + if (!pathExists(markerPath)) return change(definition.id, "unchanged", markerPath, dryRun); + if (!dryRun) await fsp.rm(markerPath, { force: true }); + return change(definition.id, "delete", markerPath, dryRun); +} + +async function upsertConfig(definition: TargetDefinition, configPath: string, dryRun: boolean): Promise { + const existing = await readOptionalFile(configPath); + const next = renderConfigWithCodegraph(definition, existing, configPath); + if (existing === next) return change(definition.id, "unchanged", configPath, dryRun); + if (!dryRun) { + await fsp.mkdir(path.dirname(configPath), { recursive: true }); + await fsp.writeFile(configPath, next, "utf8"); + } + return change(definition.id, existing === null ? "create" : "update", configPath, dryRun); +} + +async function removeConfig(definition: TargetDefinition, configPath: string, dryRun: boolean): Promise { + const existing = await readOptionalFile(configPath); + if (existing === null) return change(definition.id, "unchanged", configPath, dryRun); + const next = removeCodegraphConfig(definition, existing, configPath); + if (existing === next) return change(definition.id, "unchanged", configPath, dryRun); + const action = next.trim() ? "update" : "delete"; + if (!dryRun) { + if (action === "delete") { + await fsp.rm(configPath, { force: true }); + } else { + await fsp.writeFile(configPath, next, "utf8"); + } + } + return change(definition.id, action, configPath, dryRun); +} + +function renderConfigWithCodegraph(definition: TargetDefinition, existing: string | null, configPath: string): string { + if (definition.kind === "toml-block") { + return upsertMarkedTomlBlock(existing ?? "", codexTomlSnippet()); + } + const parsed = parseConfigJson(existing, configPath); + if (definition.kind === "json-opencode-mcp") { + parsed.mcp = mergeRecord(readRecordProperty(parsed, "mcp"), { + codegraph: { + type: "local", + enabled: true, + command: ["codegraph", "mcp", "serve", "--root", ".", "--stdio"], + }, + }); + } else { + parsed.mcpServers = mergeRecord(readRecordProperty(parsed, "mcpServers"), { + codegraph: { + type: "stdio", + command: "codegraph", + args: ["mcp", "serve", "--root", ".", "--stdio"], + }, + }); + } + return `${JSON.stringify(parsed, null, 2)}\n`; +} + +function removeCodegraphConfig(definition: TargetDefinition, existing: string, configPath: string): string { + if (definition.kind === "toml-block") { + return removeMarkedTomlBlock(existing); + } + const parsed = parseConfigJson(existing, configPath); + const property = definition.kind === "json-opencode-mcp" ? "mcp" : "mcpServers"; + const servers = readRecordProperty(parsed, property); + const server = servers.codegraph; + if (!isCodegraphJsonServer(server)) return existing; + delete servers.codegraph; + if (Object.keys(servers).length) { + parsed[property] = servers; + } else { + delete parsed[property]; + } + return `${JSON.stringify(parsed, null, 2)}\n`; +} + +function printTargetConfig(definition: TargetDefinition, options: PrintConfigOptions): string { + const settings = installerSettings(options); + const skillTargetDir = getSkillTargetDirForAgent(definition.id, settings.homeDir, settings.env); + if (definition.kind === "toml-block") return codexTomlSnippet(); + if (definition.kind === "json-opencode-mcp") { + return `${JSON.stringify({ mcp: { codegraph: { type: "local", enabled: true, command: ["codegraph", "mcp", "serve", "--root", ".", "--stdio"] } } }, null, 2)}\n`; + } + if (definition.kind === "skill-only") { + return `codegraph skill install --agent ${definition.id} --target ${normalizePathForDisplay(skillTargetDir)}\n`; + } + return `${JSON.stringify({ mcpServers: { codegraph: { type: "stdio", command: "codegraph", args: ["mcp", "serve", "--root", ".", "--stdio"] } } }, null, 2)}\n`; +} + +function codexTomlSnippet(): string { + return '[mcp_servers.codegraph]\ncommand = "codegraph"\nargs = ["mcp", "serve", "--root", ".", "--stdio"]\nstartup_timeout_ms = 20000\n'; +} + +function upsertMarkedTomlBlock(existing: string, snippet: string): string { + const block = `${CODEGRAPH_TOML_MARKER_BEGIN}\n${snippet.trimEnd()}\n${CODEGRAPH_TOML_MARKER_END}`; + const withoutBlock = removeMarkedTomlBlock(existing).trimEnd(); + if (!withoutBlock) return `${block}\n`; + return `${withoutBlock}\n\n${block}\n`; +} + +function removeMarkedTomlBlock(existing: string): string { + const begin = escapeRegExp(CODEGRAPH_TOML_MARKER_BEGIN); + const end = escapeRegExp(CODEGRAPH_TOML_MARKER_END); + const pattern = new RegExp(`\\n?${begin}[\\s\\S]*?${end}\\n?`, "g"); + return existing.replace(pattern, "\n").replace(/\n{3,}/g, "\n\n"); +} + +function parseConfigJson(existing: string | null, configPath: string): JsonRecord { + if (existing === null || !existing.trim()) return {}; + try { + const parsed: unknown = JSON.parse(existing); + if (isJsonRecord(parsed)) return parsed; + } catch (error) { + throw new Error( + `Unable to parse ${normalizePathForDisplay(configPath)} as JSON. Fix the file before running codegraph install.`, + { + cause: error, + }, + ); + } + throw new Error(`${normalizePathForDisplay(configPath)} must contain a JSON object.`); +} + +function readRecordProperty(record: JsonRecord, property: string): JsonRecord { + const value = record[property]; + if (value === undefined) return {}; + if (isJsonRecord(value)) return value; + throw new Error(`Existing ${property} config must be a JSON object before codegraph can merge into it.`); +} + +function mergeRecord(left: JsonRecord, right: JsonRecord): JsonRecord { + return { ...left, ...right }; +} + +function isCodegraphJsonServer(value: unknown): boolean { + if (!isJsonRecord(value)) return false; + const command = value.command; + if (command === "codegraph") return true; + if (Array.isArray(command) && command[0] === "codegraph") return true; + return false; +} + +function isJsonRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function installerSettings(options: { homeDir?: string; env?: Record }): InstallerSettings { + return { + homeDir: options.homeDir ?? os.homedir(), + env: options.env ?? process.env, + }; +} + +function requireConfigPath(definition: TargetDefinition, settings: InstallerSettings): string { + const configPath = definition.configPath?.(settings); + if (configPath === undefined) { + throw new Error(`${definition.label} does not have an MCP config file target.`); + } + return configPath; +} + +function definitionForTarget(id: InstallTargetId): TargetDefinition { + const definition = TARGET_DEFINITIONS.find((target) => target.id === id); + if (!definition) throw new Error(`Unknown install target "${id}".`); + return definition; +} + +function isInstallTargetId(value: string): value is InstallTargetId { + return DEFAULT_TARGET_IDS.includes(value as InstallTargetId); +} + +function change( + target: InstallTargetId, + action: InstallChange["action"], + filePath: string, + dryRun: boolean, +): InstallChange { + return { + target, + action, + path: normalizePathForDisplay(filePath), + dryRun, + }; +} + +async function readOptionalFile(filePath: string): Promise { + try { + return await fsp.readFile(filePath, "utf8"); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return null; + throw error; + } +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/tests/installer.test.ts b/tests/installer.test.ts new file mode 100644 index 00000000..0e8db946 --- /dev/null +++ b/tests/installer.test.ts @@ -0,0 +1,108 @@ +import fsp from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + detectInstallTargets, + installCodegraphTargets, + printInstallConfig, + uninstallCodegraphTargets, +} from "../src/installer/registry.js"; +import { captureCli } from "./helpers/cli.js"; +import { mkTmpDir } from "./helpers/filesystem.js"; + +async function readFile(filePath: string): Promise { + return await fsp.readFile(filePath, "utf8"); +} + +describe("agent installer workflow", () => { + it("detects present and missing target directories", async () => { + const homeDir = await mkTmpDir("cg-install-detect-"); + await fsp.mkdir(path.join(homeDir, ".codex"), { recursive: true }); + + const detections = await detectInstallTargets({ homeDir }); + const codex = detections.find((target) => target.configPath?.endsWith(".codex/config.toml")); + const gemini = detections.find((target) => target.configPath?.endsWith(".gemini/settings.json")); + + expect(codex?.detected).toBeTruthy(); + expect(codex?.reason).toContain("config directory exists"); + expect(gemini?.detected).toBeFalsy(); + }); + + it("prints a Codex MCP TOML snippet from the CLI", async () => { + const result = await captureCli(["install", "--print-config", "codex"]); + + expect(result.exitCode).toBeUndefined(); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("[mcp_servers.codegraph]"); + expect(result.stdout).toContain('command = "codegraph"'); + expect(result.stdout).toContain('["mcp", "serve", "--root", ".", "--stdio"]'); + }); + + it("previews install changes without writing files", async () => { + const homeDir = await mkTmpDir("cg-install-dry-run-"); + const configPath = path.join(homeDir, ".codex", "config.toml"); + + const result = await installCodegraphTargets({ homeDir, targetIds: ["codex"], dryRun: true }); + + expect(result.dryRun).toBeTruthy(); + expect(result.changes.some((change) => change.path.endsWith(".codex/config.toml"))).toBeTruthy(); + await expect(fsp.stat(configPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("installs idempotently for TOML and JSON targets", async () => { + const homeDir = await mkTmpDir("cg-install-idempotent-"); + + const first = await installCodegraphTargets({ homeDir, targetIds: ["codex", "cursor"], yes: true }); + const second = await installCodegraphTargets({ homeDir, targetIds: ["codex", "cursor"], yes: true }); + + expect(first.installed).toBeTruthy(); + expect(second.changes.every((change) => change.action === "unchanged")).toBeTruthy(); + expect(await readFile(path.join(homeDir, ".codex", "config.toml"))).toContain("# >>> codegraph mcp >>>"); + const cursorConfig = JSON.parse(await readFile(path.join(homeDir, ".cursor", "mcp.json"))) as { + mcpServers?: { codegraph?: { command?: string } }; + }; + expect(cursorConfig.mcpServers?.codegraph?.command).toBe("codegraph"); + }); + + it("uninstalls only Codegraph-owned config entries", async () => { + const homeDir = await mkTmpDir("cg-install-uninstall-"); + const cursorConfigPath = path.join(homeDir, ".cursor", "mcp.json"); + await fsp.mkdir(path.dirname(cursorConfigPath), { recursive: true }); + await fsp.writeFile( + cursorConfigPath, + `${JSON.stringify({ mcpServers: { other: { command: "other-tool" } } }, null, 2)}\n`, + "utf8", + ); + + await installCodegraphTargets({ homeDir, targetIds: ["cursor"], yes: true }); + await uninstallCodegraphTargets({ homeDir, targetIds: ["cursor"], yes: true }); + + const cursorConfig = JSON.parse(await readFile(cursorConfigPath)) as { + mcpServers?: { other?: { command?: string } }; + }; + expect(cursorConfig.mcpServers?.other?.command).toBe("other-tool"); + expect(JSON.stringify(cursorConfig)).not.toContain("codegraph"); + }); + + it("requires --yes for writes", async () => { + const homeDir = await mkTmpDir("cg-install-yes-"); + + await expect(installCodegraphTargets({ homeDir, targetIds: ["codex"] })).rejects.toThrow(/--yes/); + }); + + it("fails malformed existing JSON with an actionable error", async () => { + const homeDir = await mkTmpDir("cg-install-malformed-"); + const configPath = path.join(homeDir, ".cursor", "mcp.json"); + await fsp.mkdir(path.dirname(configPath), { recursive: true }); + await fsp.writeFile(configPath, "{ not json", "utf8"); + + await expect(installCodegraphTargets({ homeDir, targetIds: ["cursor"], yes: true })).rejects.toThrow( + /Unable to parse .*mcp\.json as JSON/, + ); + }); + + it("prints direct config snippets through the library helper", async () => { + expect(printInstallConfig({ targetId: "codex" })).toContain("startup_timeout_ms"); + expect(printInstallConfig({ targetId: "opencode" })).toContain('"type": "local"'); + }); +}); From 30cf07f9363f27f8ce134c7c38c938e2c7f86327 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Fri, 3 Jul 2026 05:21:56 -0400 Subject: [PATCH 10/26] Add project lifecycle commands --- README.md | 10 + codegraph-skill/codegraph/SKILL.md | 2 + docs/cli.md | 13 ++ src/cli.ts | 20 +- src/cli/help.ts | 26 +++ src/cli/lifecycle.ts | 94 ++++++++++ src/cli/options.ts | 32 ++++ src/lifecycle/manifest.ts | 291 +++++++++++++++++++++++++++++ tests/lifecycle.test.ts | 131 +++++++++++++ 9 files changed, 618 insertions(+), 1 deletion(-) create mode 100644 src/cli/lifecycle.ts create mode 100644 src/lifecycle/manifest.ts create mode 100644 tests/lifecycle.test.ts diff --git a/README.md b/README.md index 159ad057..59244d99 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Detailed command contracts and JSON shapes live in [docs/cli.md](./docs/cli.md). - Per-file symbol indexes with locals, exports, docstrings, line spans, and lightweight complexity metadata. - Cross-file go-to-definition and find-references support across the shared source-language pipeline. - Deterministic agent exploration, orientation, packet retrieval, search, bounded explanations, portable artifact bundles, and MCP tools across files, symbols, chunks, SQL objects, graph neighborhoods, and review ranges with stable follow-up targets. +- Project lifecycle commands initialize, inspect, refresh, and remove `.codegraph/manifest.json` metadata while reusing the existing disk cache. - Semantic chunking for code and text files, including Vue and Svelte single-file component block splitting. - Duplicate and near-duplicate detection over indexed symbols, semantic chunks, text chunks, token fingerprints, and AST shape hashes when parser context is available. - AST grep, public API summaries, unresolved import reports, hotspot analysis, cycle detection, and shortest dependency paths. @@ -96,6 +97,10 @@ node ./dist/cli.js orient --root . --budget small --pretty node ./dist/cli.js search "build review report" --json node ./dist/cli.js explain src/cli.ts +# optional lifecycle manifest and cache warmup +node ./dist/cli.js init --root . +node ./dist/cli.js status --root . --json + # optional runtime and artifact health check node ./dist/cli.js doctor @@ -140,6 +145,11 @@ codegraph orient --root . --budget small --pretty codegraph search "build review report" --json codegraph explain src/review.ts +# project lifecycle marker and cache warmup +codegraph init --root . +codegraph status --root . --json +codegraph sync --root . + # semantic navigation codegraph goto codegraph refs --file src/index.ts --line 12 --col 17 --pretty diff --git a/codegraph-skill/codegraph/SKILL.md b/codegraph-skill/codegraph/SKILL.md index 06f0e4ad..22c7b456 100644 --- a/codegraph-skill/codegraph/SKILL.md +++ b/codegraph-skill/codegraph/SKILL.md @@ -43,10 +43,12 @@ Then choose the smallest useful follow-up: - review: `codegraph review --base HEAD --head WORKTREE --summary` - drift: `codegraph drift ./src --base origin/main --head HEAD --pretty --graph-edges summary --public-api removals` - installer: `codegraph install --target codex,claude --dry-run` +- lifecycle: `codegraph init --root .`, `codegraph status --root . --json`, `codegraph sync --root .` Use `--root` to define the project boundary for config lookup, cache scope, path confinement, and output normalization. For `orient`, `drift`, and positional graph commands, positional paths are include roots inside that project. Use `codegraph install --target --yes` to configure supported local agent clients. Use `--dry-run` or `--print-config ` first; uninstall removes only Codegraph-owned marker blocks, marker files, or MCP entries whose command is `codegraph`. +Lifecycle commands write only `.codegraph/manifest.json` metadata and reuse the existing disk cache; use `uninit` to remove recognized lifecycle state. ## Output Choice diff --git a/docs/cli.md b/docs/cli.md index 9606c6c6..e1b11220 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -101,6 +101,12 @@ codegraph graph --root . --sql-artifacts --json # SQLite export codegraph graph --sqlite ./codegraph.sqlite +# Project lifecycle marker and cache warmup +codegraph init --root . +codegraph status --root . --json +codegraph sync --root . +codegraph uninit --root . --force + # Build and report diagnostics codegraph graph --report codegraph index --report @@ -111,6 +117,13 @@ codegraph review --report --report-file review.report.json Graph, index, and review reports include `backend.native.byLanguage` so native usage and fallback remain visible per language. Build reports also include `backend.parser` when syntax-tree backend degradation leaves files without parser context. Reports also include `graph.fallbackImportExtraction.byLanguage` and `byReason` when regex import extraction is used. Review JSON reports `diagnostics.symbolMappingParseFailures`, `diagnostics.missingFiles`, `changedFiles[].status` as `updated`, `deleted`, or `missing`, and `sqlContext` when changed SQL files or changed SQL literals make SQL artifact facts relevant. +### Project lifecycle + +- `init` creates `.codegraph/manifest.json`, warms the existing disk cache through the index build path, and is idempotent when the manifest is current. Use `--force` to rebuild and overwrite the manifest metadata. +- `status` reports whether lifecycle metadata exists, last sync time, then/current file counts, config/build-option drift, analysis label, and the suggested next command. Use `--json` for `schemaVersion: 1`. +- `sync` refreshes the manifest after edits and requires an initialized project unless `--init` is passed. +- `uninit` removes only recognized lifecycle state by default. It refuses unknown `.codegraph/` entries unless `--force` is passed. + ### Symbols, navigation, grep, and chunking ```bash diff --git a/src/cli.ts b/src/cli.ts index 3ed0a4ff..ab1a83c1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -38,6 +38,7 @@ import { handleGrepCommand } from "./cli/grep.js"; import { CLI_HELP_TEXT, helpTextForCommand, isKnownCliCommand } from "./cli/help.js"; import { handleImpactCommand } from "./cli/impact.js"; import { handleInstallerCommand } from "./cli/install.js"; +import { handleLifecycleCommand } from "./cli/lifecycle.js"; import { handleIndexCommand } from "./cli/index.js"; import { handleHotspotsCommand, handleInspectCommand } from "./cli/inspect.js"; import { handleOrientCommand } from "./cli/orient.js"; @@ -221,7 +222,11 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { cmd === "hotspots" || cmd === "inspect" || cmd === "duplicates" || - cmd === "impact") && + cmd === "impact" || + cmd === "init" || + cmd === "status" || + cmd === "sync" || + cmd === "uninit") && !rootOpt && firstPositionalRoot !== undefined && isExistingDirectory(firstPositionalRoot) @@ -538,6 +543,19 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { return await resolveFilesFromRoots(); }; + if (cmd === "init" || cmd === "status" || cmd === "sync" || cmd === "uninit") { + await handleLifecycleCommand({ + command: cmd, + root: projectRootFs, + buildOptions: buildAgentOptions(), + getOpt, + hasFlag, + writeJSONLine, + writeStdoutLine, + }); + return; + } + if (cmd === "explore") { await handleExploreCommand({ positionals: parsed.positionals, diff --git a/src/cli/help.ts b/src/cli/help.ts index a1ca6122..c6326949 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -16,6 +16,10 @@ Commands: drift Compare architecture health between refs or artifacts mcp Serve MCP tools for agent graph navigation index Build the project symbol index + init Initialize project-local Codegraph lifecycle metadata + status Inspect project-local Codegraph lifecycle metadata + sync Refresh project-local Codegraph lifecycle metadata + uninit Remove project-local Codegraph lifecycle metadata goto Go to definition refs Find references deps List dependencies @@ -84,6 +88,10 @@ Examples: codegraph explore "how does auth reach db?" --pretty codegraph explain src/auth.ts --json codegraph impact --provider git --base HEAD --head WORKTREE + codegraph init --root . + codegraph status --root . --json + codegraph sync --root . + codegraph uninit --root . --force codegraph packet get file:src%2Fcli.ts --json codegraph artifact build --root . --out codegraph-out --json codegraph mcp serve --root . --stdio @@ -129,6 +137,7 @@ const knownCliCommands = new Set([ "hotspots", "impact", "index", + "init", "install", "inspect", "mcp", @@ -140,8 +149,11 @@ const knownCliCommands = new Set([ "review", "search", "skill", + "status", + "sync", "sql", "unresolved", + "uninit", "version", "uninstall", ]); @@ -150,6 +162,18 @@ export function isKnownCliCommand(command: string): boolean { return knownCliCommands.has(command); } +export const LIFECYCLE_HELP_TEXT = `codegraph lifecycle - Initialize, inspect, refresh, or remove project-local Codegraph state + +Usage: + codegraph init [path] [--root ] [--force] [--json] + codegraph status [path] [--root ] [--json] + codegraph sync [path] [--root ] [--init] [--force] [--json] + codegraph uninit [path] [--root ] [--force] [--json] + +State: + Lifecycle commands write only .codegraph/manifest.json metadata. They reuse the existing disk cache and never make other commands depend on the manifest. +`; + export const INSTALL_HELP_TEXT = `codegraph install - Configure Codegraph for supported agent clients Usage: codegraph install [target] [--target ] [--yes | --dry-run] [--print-config ] [--detect] [--json] @@ -388,6 +412,8 @@ export function helpTextForCommand(command: string, positionals: readonly string if (command === "explain") return EXPLAIN_HELP_TEXT; if (command === "install") return INSTALL_HELP_TEXT; if (command === "uninstall") return UNINSTALL_HELP_TEXT; + if (command === "init" || command === "status" || command === "sync" || command === "uninit") + return LIFECYCLE_HELP_TEXT; if (command === "drift") return DRIFT_HELP_TEXT; if (command === "duplicates") return DUPLICATES_HELP_TEXT; if (command === "artifact") return ARTIFACT_HELP_TEXT; diff --git a/src/cli/lifecycle.ts b/src/cli/lifecycle.ts new file mode 100644 index 00000000..35bff6ac --- /dev/null +++ b/src/cli/lifecycle.ts @@ -0,0 +1,94 @@ +import type { BuildOptions } from "../indexer/types.js"; +import { + getCodegraphLifecycleStatus, + initCodegraphLifecycle, + syncCodegraphLifecycle, + uninitCodegraphLifecycle, + type CodegraphLifecycleStatus, + type CodegraphLifecycleSyncResult, + type CodegraphLifecycleUninitResult, +} from "../lifecycle/manifest.js"; + +export type LifecycleCommandContext = { + command: "init" | "status" | "sync" | "uninit"; + root: string; + buildOptions?: BuildOptions; + getOpt: (name: string) => string | undefined; + hasFlag: (name: string) => boolean; + writeJSONLine: (value: unknown) => void; + writeStdoutLine: (message: string) => void; +}; + +export async function handleLifecycleCommand(context: LifecycleCommandContext): Promise { + if (context.command === "init") { + const result = await initCodegraphLifecycle(context.root, { + ...(context.buildOptions ? { buildOptions: context.buildOptions } : {}), + force: context.hasFlag("--force"), + }); + writeLifecycleResult(context, result, formatSyncResult("Initialized", result)); + return; + } + + if (context.command === "sync") { + const result = await syncCodegraphLifecycle(context.root, { + ...(context.buildOptions ? { buildOptions: context.buildOptions } : {}), + init: context.hasFlag("--init"), + force: context.hasFlag("--force"), + }); + writeLifecycleResult(context, result, formatSyncResult("Synced", result)); + return; + } + + if (context.command === "uninit") { + const result = await uninitCodegraphLifecycle(context.root, { force: context.hasFlag("--force") }); + writeLifecycleResult(context, result, formatUninitResult(result)); + return; + } + + const result = await getCodegraphLifecycleStatus(context.root, { + ...(context.buildOptions ? { buildOptions: context.buildOptions } : {}), + }); + writeLifecycleResult(context, result, formatStatus(result)); +} + +function writeLifecycleResult( + context: LifecycleCommandContext, + value: CodegraphLifecycleStatus | CodegraphLifecycleSyncResult | CodegraphLifecycleUninitResult, + pretty: string, +): void { + if (context.hasFlag("--json")) { + context.writeJSONLine(value); + } else { + context.writeStdoutLine(pretty); + } +} + +function formatSyncResult(label: string, result: CodegraphLifecycleSyncResult): string { + const delta = result.changedFiles.totalDelta; + const deltaLabel = delta ? `, delta ${delta}` : ""; + return `${label} Codegraph at ${result.root}: ${result.manifest.fileCount} files${deltaLabel}. Manifest: ${result.manifestPath}`; +} + +function formatUninitResult(result: CodegraphLifecycleUninitResult): string { + if (!result.removed) return `Codegraph is not initialized at ${result.root}.`; + return `Removed Codegraph lifecycle state at ${result.root}.`; +} + +function formatStatus(status: CodegraphLifecycleStatus): string { + if (!status.initialized) { + return `Codegraph is not initialized at ${status.root}. Next: ${status.suggestedNextCommand}`; + } + const fileCount = status.fileCount ? `${status.fileCount.then} then, ${status.fileCount.current} current` : "unknown"; + const configChanged = status.configChanged ? "yes" : "no"; + const buildOptionsChanged = status.buildOptionsChanged ? "yes" : "no"; + const analysis = status.analysis?.label ?? "unknown"; + return [ + `Codegraph initialized at ${status.root}.`, + `Last sync: ${status.lastSyncAt ?? "unknown"}`, + `Files: ${fileCount}`, + `Config changed: ${configChanged}`, + `Build options changed: ${buildOptionsChanged}`, + `Analysis: ${analysis}`, + `Next: ${status.suggestedNextCommand}`, + ].join("\n"); +} diff --git a/src/cli/options.ts b/src/cli/options.ts index 3fe037e9..d5e5e97e 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -361,6 +361,14 @@ const CLI_COMMAND_SCHEMAS = new Map([ ), ], ["index", graphCommandSchema({ kind: "any" })], + [ + "init", + commandSchema([...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS, "--force"], SHARED_BUILD_OPTIONS, { + kind: "max", + max: 1, + usage: "Usage: codegraph init [path] [--root ] [--force] [--json]", + }), + ], [ "install", commandSchema(["--detect", "--dry-run", "--json", "--yes"], ["--print-config", "--target"], { @@ -480,6 +488,22 @@ const CLI_COMMAND_SCHEMAS = new Map([ usage: "Usage: codegraph skill [--agent | --target ] [--force]", }), ], + [ + "status", + commandSchema([...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS], SHARED_BUILD_OPTIONS, { + kind: "max", + max: 1, + usage: "Usage: codegraph status [path] [--root ] [--json]", + }), + ], + [ + "sync", + commandSchema([...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS, "--force", "--init"], SHARED_BUILD_OPTIONS, { + kind: "max", + max: 1, + usage: "Usage: codegraph sync [path] [--root ] [--init] [--force] [--json]", + }), + ], [ "sql", commandSchema(["--json"], ["--db", "--query", "--sqlite"], { @@ -487,6 +511,14 @@ const CLI_COMMAND_SCHEMAS = new Map([ usage: 'Usage: codegraph sql --db --query "SELECT ..."', }), ], + [ + "uninit", + commandSchema(["--force", "--json"], ["--root"], { + kind: "max", + max: 1, + usage: "Usage: codegraph uninit [path] [--root ] [--force] [--json]", + }), + ], [ "unresolved", commandSchema([...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS, "--verbose"], SHARED_BUILD_OPTIONS, { diff --git a/src/lifecycle/manifest.ts b/src/lifecycle/manifest.ts new file mode 100644 index 00000000..c21c1643 --- /dev/null +++ b/src/lifecycle/manifest.ts @@ -0,0 +1,291 @@ +import { createHash } from "node:crypto"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import { CODEGRAPH_CONFIG_FILE } from "../config.js"; +import { createAgentSession, listAgentSessionFiles } from "../agent/session.js"; +import type { BuildOptions } from "../indexer/types.js"; +import type { AnalysisSummary } from "../analysisSummary.js"; + +export type CodegraphLifecycleManifest = { + schemaVersion: 1; + root: "."; + createdAt: string; + lastSyncAt: string; + configHash: string; + buildOptionsHash: string; + fileCount: number; + analysis: AnalysisSummary; +}; + +export type CodegraphLifecycleStatus = { + schemaVersion: 1; + root: string; + initialized: boolean; + manifestPath: string; + lastSyncAt?: string; + fileCount?: { + then: number; + current: number; + }; + configChanged: boolean; + buildOptionsChanged: boolean; + analysis?: AnalysisSummary; + suggestedNextCommand: string; +}; + +export type CodegraphLifecycleSyncResult = { + schemaVersion: 1; + root: string; + initialized: true; + manifestPath: string; + manifest: CodegraphLifecycleManifest; + changedFiles: { + added: number; + removed: number; + totalDelta: number; + }; +}; + +export type CodegraphLifecycleUninitResult = { + schemaVersion: 1; + root: string; + removed: boolean; + manifestPath: string; +}; + +const MANIFEST_SCHEMA_VERSION = 1; +const CODEGRAPH_DIR = ".codegraph"; +const MANIFEST_FILE = "manifest.json"; +const KNOWN_CODEGRAPH_FILES = new Set([MANIFEST_FILE]); + +export function codegraphLifecycleManifestPath(root: string): string { + return path.join(root, CODEGRAPH_DIR, MANIFEST_FILE); +} + +export async function initCodegraphLifecycle( + root: string, + options: { buildOptions?: BuildOptions; force?: boolean } = {}, +): Promise { + const existing = await readLifecycleManifest(root); + if (existing && !options.force) { + const status = await getCodegraphLifecycleStatus(root, options); + if (!status.configChanged && !status.buildOptionsChanged && status.fileCount?.then === status.fileCount?.current) { + return { + schemaVersion: MANIFEST_SCHEMA_VERSION, + root, + initialized: true, + manifestPath: codegraphLifecycleManifestPath(root), + manifest: existing, + changedFiles: { added: 0, removed: 0, totalDelta: 0 }, + }; + } + } + return await syncCodegraphLifecycle(root, { ...options, init: true }); +} + +export async function syncCodegraphLifecycle( + root: string, + options: { buildOptions?: BuildOptions; init?: boolean; force?: boolean } = {}, +): Promise { + const existing = await readLifecycleManifest(root); + if (!existing && !options.init) { + throw new Error("Codegraph is not initialized for this project. Run codegraph init or codegraph sync --init."); + } + const manifest = await buildLifecycleManifest(root, options.buildOptions, existing); + await writeLifecycleManifest(root, manifest); + const thenCount = existing?.fileCount ?? 0; + const totalDelta = manifest.fileCount - thenCount; + return { + schemaVersion: MANIFEST_SCHEMA_VERSION, + root, + initialized: true, + manifestPath: codegraphLifecycleManifestPath(root), + manifest, + changedFiles: { + added: Math.max(0, totalDelta), + removed: Math.max(0, -totalDelta), + totalDelta, + }, + }; +} + +export async function getCodegraphLifecycleStatus( + root: string, + options: { buildOptions?: BuildOptions } = {}, +): Promise { + const manifestPath = codegraphLifecycleManifestPath(root); + const manifest = await readLifecycleManifest(root); + const configHash = await hashConfig(root); + const buildOptionsHash = hashBuildOptions(options.buildOptions); + const files = await listAgentSessionFiles({ + root, + ...(options.buildOptions ? { buildOptions: options.buildOptions } : {}), + }); + if (!manifest) { + return { + schemaVersion: MANIFEST_SCHEMA_VERSION, + root, + initialized: false, + manifestPath, + configChanged: false, + buildOptionsChanged: false, + suggestedNextCommand: "codegraph init", + }; + } + const configChanged = manifest.configHash !== configHash; + const buildOptionsChanged = manifest.buildOptionsHash !== buildOptionsHash; + const changed = configChanged || buildOptionsChanged || manifest.fileCount !== files.length; + return { + schemaVersion: MANIFEST_SCHEMA_VERSION, + root, + initialized: true, + manifestPath, + lastSyncAt: manifest.lastSyncAt, + fileCount: { + then: manifest.fileCount, + current: files.length, + }, + configChanged, + buildOptionsChanged, + analysis: manifest.analysis, + suggestedNextCommand: changed ? "codegraph sync" : "codegraph status", + }; +} + +export async function uninitCodegraphLifecycle( + root: string, + options: { force?: boolean } = {}, +): Promise { + const dir = path.join(root, CODEGRAPH_DIR); + const manifestPath = codegraphLifecycleManifestPath(root); + const entries = await readCodegraphDirEntries(dir); + if (!entries.length) { + return { schemaVersion: MANIFEST_SCHEMA_VERSION, root, removed: false, manifestPath }; + } + const unknownEntries = entries.filter((entry) => !KNOWN_CODEGRAPH_FILES.has(entry)); + if (unknownEntries.length && !options.force) { + throw new Error( + `Refusing to remove .codegraph with unknown entries: ${unknownEntries.join(", ")}. Use --force to remove them.`, + ); + } + if (options.force) { + await fsp.rm(dir, { recursive: true, force: true }); + } else { + await fsp.rm(manifestPath, { force: true }); + await removeDirIfEmpty(dir); + } + return { schemaVersion: MANIFEST_SCHEMA_VERSION, root, removed: true, manifestPath }; +} + +async function buildLifecycleManifest( + root: string, + buildOptions: BuildOptions | undefined, + existing: CodegraphLifecycleManifest | null, +): Promise { + const now = new Date().toISOString(); + const options = { ...(buildOptions ?? {}), cache: "disk" as const }; + const session = createAgentSession({ root, buildOptions: options }); + const snapshot = await session.loadProject({ symbolGraph: "skip" }); + return { + schemaVersion: MANIFEST_SCHEMA_VERSION, + root: ".", + createdAt: existing?.createdAt ?? now, + lastSyncAt: now, + configHash: await hashConfig(root), + buildOptionsHash: hashBuildOptions(buildOptions), + fileCount: snapshot.files.length, + analysis: snapshot.analysis, + }; +} + +async function readLifecycleManifest(root: string): Promise { + try { + const raw = await fsp.readFile(codegraphLifecycleManifestPath(root), "utf8"); + const parsed: unknown = JSON.parse(raw); + if (isLifecycleManifest(parsed)) return parsed; + throw new Error("Invalid Codegraph lifecycle manifest schema."); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return null; + throw error; + } +} + +async function writeLifecycleManifest(root: string, manifest: CodegraphLifecycleManifest): Promise { + const manifestPath = codegraphLifecycleManifestPath(root); + await fsp.mkdir(path.dirname(manifestPath), { recursive: true }); + const tempPath = `${manifestPath}.${process.pid}.${Date.now()}.tmp`; + await fsp.writeFile(tempPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + await fsp.rename(tempPath, manifestPath); +} + +async function hashConfig(root: string): Promise { + const configPath = path.join(root, CODEGRAPH_CONFIG_FILE); + try { + return sha256(await fsp.readFile(configPath, "utf8")); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return sha256(""); + throw error; + } +} + +function hashBuildOptions(buildOptions: BuildOptions | undefined): string { + return sha256(stableStringify(stripUnhashableBuildOptions(buildOptions ?? {}))); +} + +function stripUnhashableBuildOptions(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stripUnhashableBuildOptions); + if (typeof value === "function") return "[function]"; + if (typeof value !== "object" || value === null) return value; + const out: Record = {}; + for (const [key, nested] of Object.entries(value)) { + if (typeof nested === "function") continue; + out[key] = stripUnhashableBuildOptions(nested); + } + return out; +} + +function stableStringify(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + if (typeof value !== "object" || value === null) return JSON.stringify(value); + const entries = Object.entries(value).sort(([left], [right]) => left.localeCompare(right)); + return `{${entries.map(([key, nested]) => `${JSON.stringify(key)}:${stableStringify(nested)}`).join(",")}}`; +} + +function sha256(value: string | Buffer): string { + return createHash("sha256").update(value).digest("hex"); +} + +async function readCodegraphDirEntries(dir: string): Promise { + try { + return await fsp.readdir(dir); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return []; + throw error; + } +} + +async function removeDirIfEmpty(dir: string): Promise { + try { + await fsp.rmdir(dir); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOTEMPTY") return; + if (error instanceof Error && "code" in error && error.code === "ENOENT") return; + throw error; + } +} + +function isLifecycleManifest(value: unknown): value is CodegraphLifecycleManifest { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const record = value as Record; + return ( + record.schemaVersion === MANIFEST_SCHEMA_VERSION && + record.root === "." && + typeof record.createdAt === "string" && + typeof record.lastSyncAt === "string" && + typeof record.configHash === "string" && + typeof record.buildOptionsHash === "string" && + typeof record.fileCount === "number" && + typeof record.analysis === "object" && + record.analysis !== null + ); +} diff --git a/tests/lifecycle.test.ts b/tests/lifecycle.test.ts new file mode 100644 index 00000000..b8899d98 --- /dev/null +++ b/tests/lifecycle.test.ts @@ -0,0 +1,131 @@ +import fsp from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + codegraphLifecycleManifestPath, + getCodegraphLifecycleStatus, + initCodegraphLifecycle, + syncCodegraphLifecycle, + uninitCodegraphLifecycle, + type CodegraphLifecycleManifest, +} from "../src/lifecycle/manifest.js"; +import { captureCli } from "./helpers/cli.js"; +import { mkTmpDir } from "./helpers/filesystem.js"; + +async function writeFile(root: string, relativePath: string, content: string): Promise { + const filePath = path.join(root, relativePath); + await fsp.mkdir(path.dirname(filePath), { recursive: true }); + await fsp.writeFile(filePath, content, "utf8"); +} + +async function readManifest(root: string): Promise { + const raw = await fsp.readFile(codegraphLifecycleManifestPath(root), "utf8"); + return JSON.parse(raw) as CodegraphLifecycleManifest; +} + +describe("project lifecycle commands", () => { + it("init creates a manifest and is idempotent when current", async () => { + const root = await mkTmpDir("cg-life-init-"); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + + const first = await initCodegraphLifecycle(root); + const second = await initCodegraphLifecycle(root); + + expect(first.manifest.fileCount).toBe(1); + expect(second.changedFiles.totalDelta).toBe(0); + expect(await readManifest(root)).toEqual(first.manifest); + }); + + it("init --force rebuilds and updates lastSyncAt", async () => { + const root = await mkTmpDir("cg-life-force-"); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + await initCodegraphLifecycle(root); + const manifestPath = codegraphLifecycleManifestPath(root); + const manifest = await readManifest(root); + const oldManifest = { + ...manifest, + lastSyncAt: "2000-01-01T00:00:00.000Z", + }; + await fsp.writeFile(manifestPath, `${JSON.stringify(oldManifest, null, 2)}\n`, "utf8"); + + const result = await initCodegraphLifecycle(root, { force: true }); + + expect(result.manifest.createdAt).toBe(manifest.createdAt); + expect(result.manifest.lastSyncAt).not.toBe(oldManifest.lastSyncAt); + }); + + it("status reports initialized and not initialized projects", async () => { + const root = await mkTmpDir("cg-life-status-"); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + + const before = await getCodegraphLifecycleStatus(root); + await initCodegraphLifecycle(root); + const after = await getCodegraphLifecycleStatus(root); + + expect(before.initialized).toBeFalsy(); + expect(before.suggestedNextCommand).toBe("codegraph init"); + expect(after.initialized).toBeTruthy(); + expect(after.fileCount).toEqual({ then: 1, current: 1 }); + }); + + it("status detects config hash changes", async () => { + const root = await mkTmpDir("cg-life-config-"); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + await writeFile(root, "codegraph.config.json", `${JSON.stringify({ discovery: { useGitignore: true } })}\n`); + await initCodegraphLifecycle(root); + await writeFile(root, "codegraph.config.json", `${JSON.stringify({ discovery: { useGitignore: false } })}\n`); + + const status = await getCodegraphLifecycleStatus(root); + + expect(status.configChanged).toBeTruthy(); + expect(status.suggestedNextCommand).toBe("codegraph sync"); + }); + + it("sync updates manifest after a file edit", async () => { + const root = await mkTmpDir("cg-life-sync-"); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + await initCodegraphLifecycle(root); + await writeFile(root, "src/extra.ts", "export const extra = 2;\n"); + + const result = await syncCodegraphLifecycle(root); + + expect(result.manifest.fileCount).toBe(2); + expect(result.changedFiles.added).toBe(1); + }); + + it("sync requires an initialized project unless --init is used", async () => { + const root = await mkTmpDir("cg-life-sync-init-"); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + + await expect(syncCodegraphLifecycle(root)).rejects.toThrow(/not initialized/); + const result = await syncCodegraphLifecycle(root, { init: true }); + expect(result.initialized).toBeTruthy(); + }); + + it("uninit removes recognized lifecycle files and refuses unknown entries", async () => { + const root = await mkTmpDir("cg-life-uninit-"); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + await initCodegraphLifecycle(root); + await fsp.writeFile(path.join(root, ".codegraph", "keep.txt"), "operator data\n", "utf8"); + + await expect(uninitCodegraphLifecycle(root)).rejects.toThrow(/unknown entries/); + const result = await uninitCodegraphLifecycle(root, { force: true }); + + expect(result.removed).toBeTruthy(); + await expect(fsp.stat(path.join(root, ".codegraph"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("CLI status --json returns a stable lifecycle envelope", async () => { + const root = await mkTmpDir("cg-life-cli-"); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + + const initResult = await captureCli(["init", root, "--json"]); + const statusResult = await captureCli(["status", root, "--json"]); + + expect(initResult.exitCode).toBeUndefined(); + expect(statusResult.exitCode).toBeUndefined(); + const status = JSON.parse(statusResult.stdout) as { schemaVersion?: number; initialized?: boolean }; + expect(status.schemaVersion).toBe(1); + expect(status.initialized).toBeTruthy(); + }); +}); From ced65d14095a1631cc2c3aa9abb472312dfab6fc Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Fri, 3 Jul 2026 05:43:29 -0400 Subject: [PATCH 11/26] Fix lifecycle freshness and force behavior --- codegraph-skill/codegraph/SKILL.md | 2 +- src/cli.ts | 24 ++++-- src/cli/help.ts | 4 +- src/cli/lifecycle.ts | 2 - src/cli/options.ts | 4 +- src/lifecycle/manifest.ts | 75 +++++++++++++----- tests/lifecycle.test.ts | 123 ++++++++++++++++++++++++++--- 7 files changed, 193 insertions(+), 41 deletions(-) diff --git a/codegraph-skill/codegraph/SKILL.md b/codegraph-skill/codegraph/SKILL.md index 22c7b456..1e89492b 100644 --- a/codegraph-skill/codegraph/SKILL.md +++ b/codegraph-skill/codegraph/SKILL.md @@ -48,7 +48,7 @@ Then choose the smallest useful follow-up: Use `--root` to define the project boundary for config lookup, cache scope, path confinement, and output normalization. For `orient`, `drift`, and positional graph commands, positional paths are include roots inside that project. Use `codegraph install --target --yes` to configure supported local agent clients. Use `--dry-run` or `--print-config ` first; uninstall removes only Codegraph-owned marker blocks, marker files, or MCP entries whose command is `codegraph`. -Lifecycle commands write only `.codegraph/manifest.json` metadata and reuse the existing disk cache; use `uninit` to remove recognized lifecycle state. +Lifecycle commands own only `.codegraph/manifest.json` metadata. `init` and `sync` may warm or update `.codegraph-cache/index-v1/`; other commands do not depend on the manifest. Use `uninit` to remove recognized lifecycle state. ## Output Choice diff --git a/src/cli.ts b/src/cli.ts index ab1a83c1..93a80dc1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -81,6 +81,10 @@ function isExistingDirectory(filePath: string): boolean { } } +function isLifecycleCommand(command: string): command is "init" | "status" | "sync" | "uninit" { + return command === "init" || command === "status" || command === "sync" || command === "uninit"; +} + function assertValidIncludeRoots(command: string, baseRoot: string, includeRoots: readonly string[]): void { const globLikeRoot = includeRoots.find((includeRoot) => looksLikeGlobPattern(baseRoot, includeRoot)); if (!globLikeRoot) return; @@ -214,6 +218,18 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { } const firstPositionalRoot = parsed.positionals.length === 1 ? resolveAbs(parsed.positionals[0]!) : undefined; + if ( + isLifecycleCommand(cmd) && + !rootOpt && + firstPositionalRoot !== undefined && + !isExistingDirectory(firstPositionalRoot) + ) { + writeStderrLine( + `Invalid ${cmd} path "${parsed.positionals[0]!}". Expected an existing directory or use --root .`, + ); + exitCli(2); + return; + } const defaultProjectRoot = (cmd === "graph" || cmd === "graph-delta" || @@ -223,10 +239,7 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { cmd === "inspect" || cmd === "duplicates" || cmd === "impact" || - cmd === "init" || - cmd === "status" || - cmd === "sync" || - cmd === "uninit") && + isLifecycleCommand(cmd)) && !rootOpt && firstPositionalRoot !== undefined && isExistingDirectory(firstPositionalRoot) @@ -543,12 +556,11 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { return await resolveFilesFromRoots(); }; - if (cmd === "init" || cmd === "status" || cmd === "sync" || cmd === "uninit") { + if (isLifecycleCommand(cmd)) { await handleLifecycleCommand({ command: cmd, root: projectRootFs, buildOptions: buildAgentOptions(), - getOpt, hasFlag, writeJSONLine, writeStdoutLine, diff --git a/src/cli/help.ts b/src/cli/help.ts index c6326949..e05da6bb 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -167,11 +167,11 @@ export const LIFECYCLE_HELP_TEXT = `codegraph lifecycle - Initialize, inspect, r Usage: codegraph init [path] [--root ] [--force] [--json] codegraph status [path] [--root ] [--json] - codegraph sync [path] [--root ] [--init] [--force] [--json] + codegraph sync [path] [--root ] [--init] [--json] codegraph uninit [path] [--root ] [--force] [--json] State: - Lifecycle commands write only .codegraph/manifest.json metadata. They reuse the existing disk cache and never make other commands depend on the manifest. + Lifecycle commands own only .codegraph/manifest.json metadata. Init and sync may warm or update the disk cache under .codegraph-cache/index-v1/. Other commands do not depend on the manifest. `; export const INSTALL_HELP_TEXT = `codegraph install - Configure Codegraph for supported agent clients diff --git a/src/cli/lifecycle.ts b/src/cli/lifecycle.ts index 35bff6ac..6f9f088d 100644 --- a/src/cli/lifecycle.ts +++ b/src/cli/lifecycle.ts @@ -13,7 +13,6 @@ export type LifecycleCommandContext = { command: "init" | "status" | "sync" | "uninit"; root: string; buildOptions?: BuildOptions; - getOpt: (name: string) => string | undefined; hasFlag: (name: string) => boolean; writeJSONLine: (value: unknown) => void; writeStdoutLine: (message: string) => void; @@ -33,7 +32,6 @@ export async function handleLifecycleCommand(context: LifecycleCommandContext): const result = await syncCodegraphLifecycle(context.root, { ...(context.buildOptions ? { buildOptions: context.buildOptions } : {}), init: context.hasFlag("--init"), - force: context.hasFlag("--force"), }); writeLifecycleResult(context, result, formatSyncResult("Synced", result)); return; diff --git a/src/cli/options.ts b/src/cli/options.ts index d5e5e97e..7b303d78 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -498,10 +498,10 @@ const CLI_COMMAND_SCHEMAS = new Map([ ], [ "sync", - commandSchema([...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS, "--force", "--init"], SHARED_BUILD_OPTIONS, { + commandSchema([...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS, "--init"], SHARED_BUILD_OPTIONS, { kind: "max", max: 1, - usage: "Usage: codegraph sync [path] [--root ] [--init] [--force] [--json]", + usage: "Usage: codegraph sync [path] [--root ] [--init] [--json]", }), ], [ diff --git a/src/lifecycle/manifest.ts b/src/lifecycle/manifest.ts index c21c1643..f3ce1987 100644 --- a/src/lifecycle/manifest.ts +++ b/src/lifecycle/manifest.ts @@ -1,8 +1,8 @@ import { createHash } from "node:crypto"; import fsp from "node:fs/promises"; import path from "node:path"; -import { CODEGRAPH_CONFIG_FILE } from "../config.js"; import { createAgentSession, listAgentSessionFiles } from "../agent/session.js"; +import { computeConfigHash } from "../indexer/build-cache/manifest.js"; import type { BuildOptions } from "../indexer/types.js"; import type { AnalysisSummary } from "../analysisSummary.js"; @@ -14,6 +14,7 @@ export type CodegraphLifecycleManifest = { configHash: string; buildOptionsHash: string; fileCount: number; + fileSignatureHash: string; analysis: AnalysisSummary; }; @@ -29,6 +30,7 @@ export type CodegraphLifecycleStatus = { }; configChanged: boolean; buildOptionsChanged: boolean; + filesChanged: boolean; analysis?: AnalysisSummary; suggestedNextCommand: string; }; @@ -66,10 +68,10 @@ export async function initCodegraphLifecycle( root: string, options: { buildOptions?: BuildOptions; force?: boolean } = {}, ): Promise { - const existing = await readLifecycleManifest(root); + const existing = await readLifecycleManifest(root, options.force ? { allowInvalid: true } : {}); if (existing && !options.force) { const status = await getCodegraphLifecycleStatus(root, options); - if (!status.configChanged && !status.buildOptionsChanged && status.fileCount?.then === status.fileCount?.current) { + if (!status.configChanged && !status.buildOptionsChanged && !status.filesChanged) { return { schemaVersion: MANIFEST_SCHEMA_VERSION, root, @@ -87,11 +89,16 @@ export async function syncCodegraphLifecycle( root: string, options: { buildOptions?: BuildOptions; init?: boolean; force?: boolean } = {}, ): Promise { - const existing = await readLifecycleManifest(root); + const existing = await readLifecycleManifest(root, { allowInvalid: Boolean(options.init && options.force) }); if (!existing && !options.init) { throw new Error("Codegraph is not initialized for this project. Run codegraph init or codegraph sync --init."); } - const manifest = await buildLifecycleManifest(root, options.buildOptions, existing); + const manifest = await buildLifecycleManifest( + root, + options.buildOptions, + existing, + options.force ? { force: true } : {}, + ); await writeLifecycleManifest(root, manifest); const thenCount = existing?.fileCount ?? 0; const totalDelta = manifest.fileCount - thenCount; @@ -129,12 +136,15 @@ export async function getCodegraphLifecycleStatus( manifestPath, configChanged: false, buildOptionsChanged: false, + filesChanged: false, suggestedNextCommand: "codegraph init", }; } + const fileSignatureHash = await hashDiscoveredFiles(files, root); const configChanged = manifest.configHash !== configHash; const buildOptionsChanged = manifest.buildOptionsHash !== buildOptionsHash; - const changed = configChanged || buildOptionsChanged || manifest.fileCount !== files.length; + const filesChanged = manifest.fileSignatureHash !== fileSignatureHash; + const changed = configChanged || buildOptionsChanged || filesChanged; return { schemaVersion: MANIFEST_SCHEMA_VERSION, root, @@ -147,6 +157,7 @@ export async function getCodegraphLifecycleStatus( }, configChanged, buildOptionsChanged, + filesChanged, analysis: manifest.analysis, suggestedNextCommand: changed ? "codegraph sync" : "codegraph status", }; @@ -181,10 +192,14 @@ async function buildLifecycleManifest( root: string, buildOptions: BuildOptions | undefined, existing: CodegraphLifecycleManifest | null, + options: { force?: boolean } = {}, ): Promise { const now = new Date().toISOString(); - const options = { ...(buildOptions ?? {}), cache: "disk" as const }; - const session = createAgentSession({ root, buildOptions: options }); + const sessionBuildOptions = { ...(buildOptions ?? {}), cache: "disk" as const }; + const forcedSessionBuildOptions = options.force + ? { ...sessionBuildOptions, cacheStrict: true, cacheVerify: true } + : sessionBuildOptions; + const session = createAgentSession({ root, buildOptions: forcedSessionBuildOptions }); const snapshot = await session.loadProject({ symbolGraph: "skip" }); return { schemaVersion: MANIFEST_SCHEMA_VERSION, @@ -194,19 +209,30 @@ async function buildLifecycleManifest( configHash: await hashConfig(root), buildOptionsHash: hashBuildOptions(buildOptions), fileCount: snapshot.files.length, + fileSignatureHash: await hashDiscoveredFiles(snapshot.files, root), analysis: snapshot.analysis, }; } -async function readLifecycleManifest(root: string): Promise { +async function readLifecycleManifest( + root: string, + options: { allowInvalid?: boolean } = {}, +): Promise { + const manifestPath = codegraphLifecycleManifestPath(root); + let raw: string; + try { + raw = await fsp.readFile(manifestPath, "utf8"); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return null; + throw new Error(`Unable to read Codegraph lifecycle manifest at ${manifestPath}: ${stringifyError(error)}`); + } try { - const raw = await fsp.readFile(codegraphLifecycleManifestPath(root), "utf8"); const parsed: unknown = JSON.parse(raw); if (isLifecycleManifest(parsed)) return parsed; throw new Error("Invalid Codegraph lifecycle manifest schema."); } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") return null; - throw error; + if (options.allowInvalid) return null; + throw new Error(`Unable to read Codegraph lifecycle manifest at ${manifestPath}: ${stringifyError(error)}`); } } @@ -219,13 +245,25 @@ async function writeLifecycleManifest(root: string, manifest: CodegraphLifecycle } async function hashConfig(root: string): Promise { - const configPath = path.join(root, CODEGRAPH_CONFIG_FILE); - try { - return sha256(await fsp.readFile(configPath, "utf8")); - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") return sha256(""); - throw error; + const result = await computeConfigHash(root); + return result.hash; +} + +async function hashDiscoveredFiles(files: readonly string[], root: string): Promise { + const hash = createHash("sha256"); + for (const file of [...files].sort((left, right) => left.localeCompare(right))) { + const relative = path.relative(root, file).replace(/\\/g, "/"); + hash.update(relative); + hash.update("\0"); + hash.update(await fsp.readFile(file)); + hash.update("\0"); } + return hash.digest("hex"); +} + +function stringifyError(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); } function hashBuildOptions(buildOptions: BuildOptions | undefined): string { @@ -285,6 +323,7 @@ function isLifecycleManifest(value: unknown): value is CodegraphLifecycleManifes typeof record.configHash === "string" && typeof record.buildOptionsHash === "string" && typeof record.fileCount === "number" && + typeof record.fileSignatureHash === "string" && typeof record.analysis === "object" && record.analysis !== null ); diff --git a/tests/lifecycle.test.ts b/tests/lifecycle.test.ts index b8899d98..38aa913d 100644 --- a/tests/lifecycle.test.ts +++ b/tests/lifecycle.test.ts @@ -8,6 +8,8 @@ import { syncCodegraphLifecycle, uninitCodegraphLifecycle, type CodegraphLifecycleManifest, + type CodegraphLifecycleSyncResult, + type CodegraphLifecycleUninitResult, } from "../src/lifecycle/manifest.js"; import { captureCli } from "./helpers/cli.js"; import { mkTmpDir } from "./helpers/filesystem.js"; @@ -23,6 +25,10 @@ async function readManifest(root: string): Promise { return JSON.parse(raw) as CodegraphLifecycleManifest; } +async function readCodegraphEntries(root: string): Promise { + return (await fsp.readdir(path.join(root, ".codegraph"))).sort(); +} + describe("project lifecycle commands", () => { it("init creates a manifest and is idempotent when current", async () => { const root = await mkTmpDir("cg-life-init-"); @@ -34,6 +40,7 @@ describe("project lifecycle commands", () => { expect(first.manifest.fileCount).toBe(1); expect(second.changedFiles.totalDelta).toBe(0); expect(await readManifest(root)).toEqual(first.manifest); + expect(await readCodegraphEntries(root)).toEqual(["manifest.json"]); }); it("init --force rebuilds and updates lastSyncAt", async () => { @@ -54,6 +61,24 @@ describe("project lifecycle commands", () => { expect(result.manifest.lastSyncAt).not.toBe(oldManifest.lastSyncAt); }); + it("init --force recovers from a corrupt manifest", async () => { + const root = await mkTmpDir("cg-life-force-corrupt-manifest-"); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + const manifestPath = codegraphLifecycleManifestPath(root); + await fsp.mkdir(path.dirname(manifestPath), { recursive: true }); + await fsp.writeFile(manifestPath, "{not valid json\n", "utf8"); + + await expect(initCodegraphLifecycle(root)).rejects.toThrow( + `Unable to read Codegraph lifecycle manifest at ${manifestPath}`, + ); + + const result = await initCodegraphLifecycle(root, { force: true }); + + expect(result.manifest.fileCount).toBe(1); + expect(await readManifest(root)).toEqual(result.manifest); + expect(await readCodegraphEntries(root)).toEqual(["manifest.json"]); + }); + it("status reports initialized and not initialized projects", async () => { const root = await mkTmpDir("cg-life-status-"); await writeFile(root, "src/main.ts", "export const main = 1;\n"); @@ -68,12 +93,25 @@ describe("project lifecycle commands", () => { expect(after.fileCount).toEqual({ then: 1, current: 1 }); }); + it("status detects same-file content edits without a file-count change", async () => { + const root = await mkTmpDir("cg-life-status-edit-"); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + await initCodegraphLifecycle(root); + + await writeFile(root, "src/main.ts", "export const main = 2;\n"); + const status = await getCodegraphLifecycleStatus(root); + + expect(status.fileCount).toEqual({ then: 1, current: 1 }); + expect(status.filesChanged).toBeTruthy(); + expect(status.suggestedNextCommand).toBe("codegraph sync"); + }); + it("status detects config hash changes", async () => { const root = await mkTmpDir("cg-life-config-"); await writeFile(root, "src/main.ts", "export const main = 1;\n"); - await writeFile(root, "codegraph.config.json", `${JSON.stringify({ discovery: { useGitignore: true } })}\n`); + await writeFile(root, "package.json", `${JSON.stringify({ name: "fixture", dependencies: { left: "1.0.0" } })}\n`); await initCodegraphLifecycle(root); - await writeFile(root, "codegraph.config.json", `${JSON.stringify({ discovery: { useGitignore: false } })}\n`); + await writeFile(root, "package.json", `${JSON.stringify({ name: "fixture", dependencies: { right: "1.0.0" } })}\n`); const status = await getCodegraphLifecycleStatus(root); @@ -91,6 +129,7 @@ describe("project lifecycle commands", () => { expect(result.manifest.fileCount).toBe(2); expect(result.changedFiles.added).toBe(1); + expect(await readCodegraphEntries(root)).toEqual(["manifest.json"]); }); it("sync requires an initialized project unless --init is used", async () => { @@ -115,17 +154,81 @@ describe("project lifecycle commands", () => { await expect(fsp.stat(path.join(root, ".codegraph"))).rejects.toMatchObject({ code: "ENOENT" }); }); - it("CLI status --json returns a stable lifecycle envelope", async () => { - const root = await mkTmpDir("cg-life-cli-"); + it("uninit without --force removes a manifest-only .codegraph directory", async () => { + const root = await mkTmpDir("cg-life-uninit-manifest-only-"); await writeFile(root, "src/main.ts", "export const main = 1;\n"); + await initCodegraphLifecycle(root); - const initResult = await captureCli(["init", root, "--json"]); - const statusResult = await captureCli(["status", root, "--json"]); + const result = await uninitCodegraphLifecycle(root); + + expect(result.removed).toBeTruthy(); + await expect(fsp.stat(path.join(root, ".codegraph"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("CLI JSON captures cover lifecycle mutating commands with positional roots", async () => { + const initRoot = await mkTmpDir("cg-life-cli-init-"); + await writeFile(initRoot, "src/main.ts", "export const main = 1;\n"); + + const initResult = await captureCli(["init", initRoot, "--force", "--json"]); expect(initResult.exitCode).toBeUndefined(); - expect(statusResult.exitCode).toBeUndefined(); - const status = JSON.parse(statusResult.stdout) as { schemaVersion?: number; initialized?: boolean }; - expect(status.schemaVersion).toBe(1); - expect(status.initialized).toBeTruthy(); + expect(initResult.stderr).toBe(""); + const initPayload = JSON.parse(initResult.stdout) as CodegraphLifecycleSyncResult; + expect(initPayload.root).toBe(initRoot); + expect(initPayload.initialized).toBeTruthy(); + expect(initPayload.manifest.fileCount).toBe(1); + expect(await readCodegraphEntries(initRoot)).toEqual(["manifest.json"]); + + const syncRoot = await mkTmpDir("cg-life-cli-sync-"); + await writeFile(syncRoot, "src/main.ts", "export const main = 1;\n"); + + const syncInitResult = await captureCli(["sync", syncRoot, "--init", "--json"]); + + expect(syncInitResult.exitCode).toBeUndefined(); + expect(syncInitResult.stderr).toBe(""); + const syncInitPayload = JSON.parse(syncInitResult.stdout) as CodegraphLifecycleSyncResult; + expect(syncInitPayload.root).toBe(syncRoot); + expect(syncInitPayload.initialized).toBeTruthy(); + expect(syncInitPayload.manifest.fileCount).toBe(1); + expect(await readCodegraphEntries(syncRoot)).toEqual(["manifest.json"]); + + await writeFile(syncRoot, "src/extra.ts", "export const extra = 2;\n"); + const syncResult = await captureCli(["sync", syncRoot, "--json"]); + + expect(syncResult.exitCode).toBeUndefined(); + expect(syncResult.stderr).toBe(""); + const syncPayload = JSON.parse(syncResult.stdout) as CodegraphLifecycleSyncResult; + expect(syncPayload.root).toBe(syncRoot); + expect(syncPayload.manifest.fileCount).toBe(2); + expect(syncPayload.changedFiles.added).toBe(1); + expect(await readCodegraphEntries(syncRoot)).toEqual(["manifest.json"]); + + const uninitResult = await captureCli(["uninit", syncRoot, "--force", "--json"]); + + expect(uninitResult.exitCode).toBeUndefined(); + expect(uninitResult.stderr).toBe(""); + const uninitPayload = JSON.parse(uninitResult.stdout) as CodegraphLifecycleUninitResult; + expect(uninitPayload.root).toBe(syncRoot); + expect(uninitPayload.removed).toBeTruthy(); + await expect(fsp.stat(path.join(syncRoot, ".codegraph"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("CLI lifecycle commands reject invalid positional roots instead of falling back to cwd", async () => { + const commands = ["init", "status", "sync", "uninit"] as const; + + for (const command of commands) { + const cwd = await mkTmpDir(`cg-life-cli-invalid-${command}-`); + const invalidRoot = path.join(cwd, "missing-root"); + const args = [command, invalidRoot, "--json"]; + if (command === "sync") args.push("--init"); + if (command === "uninit") args.push("--force"); + + const result = await captureCli(args, { cwd }); + + expect(result.exitCode, `${command} should reject a missing positional root`).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain(command); + expect(result.stderr).toContain(invalidRoot); + } }); }); From 3dc25dd80adbcc2252444c3e67f3895b9814cfb0 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Fri, 3 Jul 2026 05:52:34 -0400 Subject: [PATCH 12/26] Address lifecycle review findings --- src/cli.ts | 28 +++++++--- src/cli/help.ts | 2 +- src/lifecycle/manifest.ts | 15 +----- tests/lifecycle.test.ts | 109 ++++++++++++++++++++++++++++++++++---- 4 files changed, 123 insertions(+), 31 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 93a80dc1..7304c76e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -218,6 +218,13 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { } const firstPositionalRoot = parsed.positionals.length === 1 ? resolveAbs(parsed.positionals[0]!) : undefined; + if (isLifecycleCommand(cmd) && rootOpt && parsed.positionals.length) { + writeStderrLine( + `Invalid ${cmd} path "${parsed.positionals[0]!}". Positional paths cannot be combined with --root for lifecycle commands.`, + ); + exitCli(2); + return; + } if ( isLifecycleCommand(cmd) && !rootOpt && @@ -557,14 +564,19 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { }; if (isLifecycleCommand(cmd)) { - await handleLifecycleCommand({ - command: cmd, - root: projectRootFs, - buildOptions: buildAgentOptions(), - hasFlag, - writeJSONLine, - writeStdoutLine, - }); + try { + await handleLifecycleCommand({ + command: cmd, + root: projectRootFs, + buildOptions: buildAgentOptions(), + hasFlag, + writeJSONLine, + writeStdoutLine, + }); + } catch (error) { + writeStderrLine(error instanceof Error ? error.message : String(error)); + exitCli(1); + } return; } diff --git a/src/cli/help.ts b/src/cli/help.ts index e05da6bb..01cf80d5 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -162,7 +162,7 @@ export function isKnownCliCommand(command: string): boolean { return knownCliCommands.has(command); } -export const LIFECYCLE_HELP_TEXT = `codegraph lifecycle - Initialize, inspect, refresh, or remove project-local Codegraph state +export const LIFECYCLE_HELP_TEXT = `codegraph init/status/sync/uninit - Initialize, inspect, refresh, or remove project-local Codegraph state Usage: codegraph init [path] [--root ] [--force] [--json] diff --git a/src/lifecycle/manifest.ts b/src/lifecycle/manifest.ts index f3ce1987..212931dd 100644 --- a/src/lifecycle/manifest.ts +++ b/src/lifecycle/manifest.ts @@ -3,6 +3,7 @@ import fsp from "node:fs/promises"; import path from "node:path"; import { createAgentSession, listAgentSessionFiles } from "../agent/session.js"; import { computeConfigHash } from "../indexer/build-cache/manifest.js"; +import { summarizeBuildOptions } from "../indexer/build-cache/options.js"; import type { BuildOptions } from "../indexer/types.js"; import type { AnalysisSummary } from "../analysisSummary.js"; @@ -267,19 +268,7 @@ function stringifyError(error: unknown): string { } function hashBuildOptions(buildOptions: BuildOptions | undefined): string { - return sha256(stableStringify(stripUnhashableBuildOptions(buildOptions ?? {}))); -} - -function stripUnhashableBuildOptions(value: unknown): unknown { - if (Array.isArray(value)) return value.map(stripUnhashableBuildOptions); - if (typeof value === "function") return "[function]"; - if (typeof value !== "object" || value === null) return value; - const out: Record = {}; - for (const [key, nested] of Object.entries(value)) { - if (typeof nested === "function") continue; - out[key] = stripUnhashableBuildOptions(nested); - } - return out; + return sha256(stableStringify(summarizeBuildOptions(buildOptions))); } function stableStringify(value: unknown): string { diff --git a/tests/lifecycle.test.ts b/tests/lifecycle.test.ts index 38aa913d..bd10ee4b 100644 --- a/tests/lifecycle.test.ts +++ b/tests/lifecycle.test.ts @@ -8,6 +8,7 @@ import { syncCodegraphLifecycle, uninitCodegraphLifecycle, type CodegraphLifecycleManifest, + type CodegraphLifecycleStatus, type CodegraphLifecycleSyncResult, type CodegraphLifecycleUninitResult, } from "../src/lifecycle/manifest.js"; @@ -43,22 +44,28 @@ describe("project lifecycle commands", () => { expect(await readCodegraphEntries(root)).toEqual(["manifest.json"]); }); - it("init --force rebuilds and updates lastSyncAt", async () => { + it("init --force recomputes stale manifest metadata for current project files", async () => { const root = await mkTmpDir("cg-life-force-"); await writeFile(root, "src/main.ts", "export const main = 1;\n"); - await initCodegraphLifecycle(root); + const first = await initCodegraphLifecycle(root); + await writeFile(root, "src/extra.ts", "export const extra = 2;\n"); const manifestPath = codegraphLifecycleManifestPath(root); - const manifest = await readManifest(root); - const oldManifest = { - ...manifest, - lastSyncAt: "2000-01-01T00:00:00.000Z", + const staleManifest = { + ...first.manifest, + fileCount: 99, + fileSignatureHash: "stale-file-signature-hash", }; - await fsp.writeFile(manifestPath, `${JSON.stringify(oldManifest, null, 2)}\n`, "utf8"); + await fsp.writeFile(manifestPath, `${JSON.stringify(staleManifest, null, 2)}\n`, "utf8"); const result = await initCodegraphLifecycle(root, { force: true }); + const status = await getCodegraphLifecycleStatus(root); - expect(result.manifest.createdAt).toBe(manifest.createdAt); - expect(result.manifest.lastSyncAt).not.toBe(oldManifest.lastSyncAt); + expect(result.manifest.createdAt).toBe(first.manifest.createdAt); + expect(result.manifest.fileCount).toBe(2); + expect(result.manifest.fileSignatureHash).not.toBe(staleManifest.fileSignatureHash); + expect(status.fileCount).toEqual({ then: 2, current: 2 }); + expect(status.filesChanged).toBeFalsy(); + expect(await readManifest(root)).toEqual(result.manifest); }); it("init --force recovers from a corrupt manifest", async () => { @@ -213,6 +220,90 @@ describe("project lifecycle commands", () => { await expect(fsp.stat(path.join(syncRoot, ".codegraph"))).rejects.toMatchObject({ code: "ENOENT" }); }); + it("CLI lifecycle commands reject positional roots when --root is supplied", async () => { + const commands = ["init", "status", "sync", "uninit"] as const; + + for (const command of commands) { + const cwd = await mkTmpDir(`cg-life-cli-root-conflict-${command}-`); + const positionalRoot = path.join(cwd, "positional-root"); + const flagRoot = path.join(cwd, "flag-root"); + await fsp.mkdir(positionalRoot); + await fsp.mkdir(flagRoot); + const args = [command, positionalRoot, "--root", flagRoot, "--json"]; + if (command === "sync") args.push("--init"); + if (command === "uninit") args.push("--force"); + + const result = await captureCli(args, { cwd }); + + expect(result.exitCode, `${command} should reject a positional path combined with --root`).toBe(2); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain(command); + expect(result.stderr).toContain(positionalRoot); + expect(result.stderr).toContain("--root"); + expect(result.stderr).toMatch(/positional/i); + } + }); + + it("CLI status --json reports initialized and uninitialized lifecycle state", async () => { + const uninitializedRoot = await mkTmpDir("cg-life-cli-status-uninitialized-"); + await writeFile(uninitializedRoot, "src/main.ts", "export const main = 1;\n"); + + const uninitializedResult = await captureCli(["status", uninitializedRoot, "--json"]); + + expect(uninitializedResult.exitCode).toBeUndefined(); + expect(uninitializedResult.stderr).toBe(""); + const uninitializedPayload = JSON.parse(uninitializedResult.stdout) as CodegraphLifecycleStatus; + expect(uninitializedPayload.schemaVersion).toBe(1); + expect(uninitializedPayload.root).toBe(uninitializedRoot); + expect(uninitializedPayload.initialized).toBeFalsy(); + expect(uninitializedPayload.fileCount).toBeUndefined(); + expect(uninitializedPayload.suggestedNextCommand).toBe("codegraph init"); + + const initializedRoot = await mkTmpDir("cg-life-cli-status-initialized-"); + await writeFile(initializedRoot, "src/main.ts", "export const main = 1;\n"); + await initCodegraphLifecycle(initializedRoot); + + const initializedResult = await captureCli(["status", initializedRoot, "--json"]); + + expect(initializedResult.exitCode).toBeUndefined(); + expect(initializedResult.stderr).toBe(""); + const initializedPayload = JSON.parse(initializedResult.stdout) as CodegraphLifecycleStatus; + expect(initializedPayload.schemaVersion).toBe(1); + expect(initializedPayload.root).toBe(initializedRoot); + expect(initializedPayload.initialized).toBeTruthy(); + expect(initializedPayload.fileCount).toEqual({ then: 1, current: 1 }); + expect(initializedPayload.suggestedNextCommand).toBe("codegraph status"); + }); + + it("CLI lifecycle user errors fail cleanly without stack traces", async () => { + const syncRoot = await mkTmpDir("cg-life-cli-sync-user-error-"); + await writeFile(syncRoot, "src/main.ts", "export const main = 1;\n"); + + const syncResult = await captureCli(["sync", syncRoot, "--json"]); + + expect(syncResult.exitCode).toBe(1); + expect(syncResult.stdout).toBe(""); + expect(syncResult.stderr).toContain("not initialized"); + expect(syncResult.stderr).toContain("codegraph init"); + expect(syncResult.stderr).not.toMatch(/^Error:/m); + expect(syncResult.stderr).not.toMatch(/\n\s+at /); + + const uninitRoot = await mkTmpDir("cg-life-cli-uninit-user-error-"); + await writeFile(uninitRoot, "src/main.ts", "export const main = 1;\n"); + await initCodegraphLifecycle(uninitRoot); + await fsp.writeFile(path.join(uninitRoot, ".codegraph", "operator-note.txt"), "operator data\n", "utf8"); + + const uninitResult = await captureCli(["uninit", uninitRoot, "--json"]); + + expect(uninitResult.exitCode).toBe(1); + expect(uninitResult.stdout).toBe(""); + expect(uninitResult.stderr).toContain("unknown entries"); + expect(uninitResult.stderr).toContain("operator-note.txt"); + expect(uninitResult.stderr).toContain("--force"); + expect(uninitResult.stderr).not.toMatch(/^Error:/m); + expect(uninitResult.stderr).not.toMatch(/\n\s+at /); + }); + it("CLI lifecycle commands reject invalid positional roots instead of falling back to cwd", async () => { const commands = ["init", "status", "sync", "uninit"] as const; From 4bd1dbbc1cc3e6260532f0d959a7f1e99db66d83 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Fri, 3 Jul 2026 06:02:36 -0400 Subject: [PATCH 13/26] Resolve lifecycle verify regressions --- src/cli.ts | 8 +++-- src/cli/help.ts | 13 +++++--- src/cli/options.ts | 8 ++--- src/lifecycle/manifest.ts | 44 ++++++++++++++++++++++--- tests/lifecycle.test.ts | 69 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 127 insertions(+), 15 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 7304c76e..c4223ff7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -39,6 +39,7 @@ import { CLI_HELP_TEXT, helpTextForCommand, isKnownCliCommand } from "./cli/help import { handleImpactCommand } from "./cli/impact.js"; import { handleInstallerCommand } from "./cli/install.js"; import { handleLifecycleCommand } from "./cli/lifecycle.js"; +import { CodegraphLifecycleUserError } from "./lifecycle/manifest.js"; import { handleIndexCommand } from "./cli/index.js"; import { handleHotspotsCommand, handleInspectCommand } from "./cli/inspect.js"; import { handleOrientCommand } from "./cli/orient.js"; @@ -574,8 +575,11 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { writeStdoutLine, }); } catch (error) { - writeStderrLine(error instanceof Error ? error.message : String(error)); - exitCli(1); + if (error instanceof CodegraphLifecycleUserError) { + writeStderrLine(error.message); + exitCli(1); + } + throw error; } return; } diff --git a/src/cli/help.ts b/src/cli/help.ts index 01cf80d5..b721b510 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -165,13 +165,18 @@ export function isKnownCliCommand(command: string): boolean { export const LIFECYCLE_HELP_TEXT = `codegraph init/status/sync/uninit - Initialize, inspect, refresh, or remove project-local Codegraph state Usage: - codegraph init [path] [--root ] [--force] [--json] - codegraph status [path] [--root ] [--json] - codegraph sync [path] [--root ] [--init] [--json] - codegraph uninit [path] [--root ] [--force] [--json] + codegraph init [path] [--force] [--json] + codegraph init --root [--force] [--json] + codegraph status [path] [--json] + codegraph status --root [--json] + codegraph sync [path] [--init] [--json] + codegraph sync --root [--init] [--json] + codegraph uninit [path] [--force] [--json] + codegraph uninit --root [--force] [--json] State: Lifecycle commands own only .codegraph/manifest.json metadata. Init and sync may warm or update the disk cache under .codegraph-cache/index-v1/. Other commands do not depend on the manifest. + Positional paths and --root are alternatives for lifecycle commands; do not combine them. `; export const INSTALL_HELP_TEXT = `codegraph install - Configure Codegraph for supported agent clients diff --git a/src/cli/options.ts b/src/cli/options.ts index 7b303d78..7b75b952 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -366,7 +366,7 @@ const CLI_COMMAND_SCHEMAS = new Map([ commandSchema([...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS, "--force"], SHARED_BUILD_OPTIONS, { kind: "max", max: 1, - usage: "Usage: codegraph init [path] [--root ] [--force] [--json]", + usage: "Usage: codegraph init [path] [--force] [--json] OR codegraph init --root [--force] [--json]", }), ], [ @@ -493,7 +493,7 @@ const CLI_COMMAND_SCHEMAS = new Map([ commandSchema([...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS], SHARED_BUILD_OPTIONS, { kind: "max", max: 1, - usage: "Usage: codegraph status [path] [--root ] [--json]", + usage: "Usage: codegraph status [path] [--json] OR codegraph status --root [--json]", }), ], [ @@ -501,7 +501,7 @@ const CLI_COMMAND_SCHEMAS = new Map([ commandSchema([...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS, "--init"], SHARED_BUILD_OPTIONS, { kind: "max", max: 1, - usage: "Usage: codegraph sync [path] [--root ] [--init] [--json]", + usage: "Usage: codegraph sync [path] [--init] [--json] OR codegraph sync --root [--init] [--json]", }), ], [ @@ -516,7 +516,7 @@ const CLI_COMMAND_SCHEMAS = new Map([ commandSchema(["--force", "--json"], ["--root"], { kind: "max", max: 1, - usage: "Usage: codegraph uninit [path] [--root ] [--force] [--json]", + usage: "Usage: codegraph uninit [path] [--force] [--json] OR codegraph uninit --root [--force] [--json]", }), ], [ diff --git a/src/lifecycle/manifest.ts b/src/lifecycle/manifest.ts index 212931dd..bbd711ee 100644 --- a/src/lifecycle/manifest.ts +++ b/src/lifecycle/manifest.ts @@ -3,7 +3,11 @@ import fsp from "node:fs/promises"; import path from "node:path"; import { createAgentSession, listAgentSessionFiles } from "../agent/session.js"; import { computeConfigHash } from "../indexer/build-cache/manifest.js"; -import { summarizeBuildOptions } from "../indexer/build-cache/options.js"; +import { + normalizeGraphOptions, + summarizeBuildOptions, + type ManifestBuildOptions, +} from "../indexer/build-cache/options.js"; import type { BuildOptions } from "../indexer/types.js"; import type { AnalysisSummary } from "../analysisSummary.js"; @@ -59,8 +63,22 @@ export type CodegraphLifecycleUninitResult = { const MANIFEST_SCHEMA_VERSION = 1; const CODEGRAPH_DIR = ".codegraph"; const MANIFEST_FILE = "manifest.json"; + +type LifecycleBuildOptionsSummary = ManifestBuildOptions & { + graph?: ReturnType; + native?: BuildOptions["native"]; + threads?: number; + nativeThreads?: number; + useNativeWorkers?: boolean; + cacheVerify?: boolean; + cacheDir?: string; +}; const KNOWN_CODEGRAPH_FILES = new Set([MANIFEST_FILE]); +export class CodegraphLifecycleUserError extends Error { + override name = "CodegraphLifecycleUserError"; +} + export function codegraphLifecycleManifestPath(root: string): string { return path.join(root, CODEGRAPH_DIR, MANIFEST_FILE); } @@ -92,7 +110,9 @@ export async function syncCodegraphLifecycle( ): Promise { const existing = await readLifecycleManifest(root, { allowInvalid: Boolean(options.init && options.force) }); if (!existing && !options.init) { - throw new Error("Codegraph is not initialized for this project. Run codegraph init or codegraph sync --init."); + throw new CodegraphLifecycleUserError( + "Codegraph is not initialized for this project. Run codegraph init or codegraph sync --init.", + ); } const manifest = await buildLifecycleManifest( root, @@ -176,7 +196,7 @@ export async function uninitCodegraphLifecycle( } const unknownEntries = entries.filter((entry) => !KNOWN_CODEGRAPH_FILES.has(entry)); if (unknownEntries.length && !options.force) { - throw new Error( + throw new CodegraphLifecycleUserError( `Refusing to remove .codegraph with unknown entries: ${unknownEntries.join(", ")}. Use --force to remove them.`, ); } @@ -233,7 +253,9 @@ async function readLifecycleManifest( throw new Error("Invalid Codegraph lifecycle manifest schema."); } catch (error) { if (options.allowInvalid) return null; - throw new Error(`Unable to read Codegraph lifecycle manifest at ${manifestPath}: ${stringifyError(error)}`); + throw new CodegraphLifecycleUserError( + `Unable to read Codegraph lifecycle manifest at ${manifestPath}: ${stringifyError(error)}`, + ); } } @@ -268,7 +290,19 @@ function stringifyError(error: unknown): string { } function hashBuildOptions(buildOptions: BuildOptions | undefined): string { - return sha256(stableStringify(summarizeBuildOptions(buildOptions))); + return sha256(stableStringify(summarizeLifecycleBuildOptions(buildOptions))); +} + +function summarizeLifecycleBuildOptions(buildOptions: BuildOptions | undefined): LifecycleBuildOptionsSummary { + const summary: LifecycleBuildOptionsSummary = { ...summarizeBuildOptions(buildOptions) }; + if (buildOptions?.graph) summary.graph = normalizeGraphOptions(buildOptions.graph); + if (buildOptions?.native !== undefined) summary.native = buildOptions.native; + if (buildOptions?.threads !== undefined) summary.threads = buildOptions.threads; + if (buildOptions?.nativeThreads !== undefined) summary.nativeThreads = buildOptions.nativeThreads; + if (buildOptions?.useNativeWorkers !== undefined) summary.useNativeWorkers = buildOptions.useNativeWorkers; + if (buildOptions?.cacheVerify !== undefined) summary.cacheVerify = buildOptions.cacheVerify; + if (buildOptions?.cacheDir !== undefined) summary.cacheDir = buildOptions.cacheDir; + return summary; } function stableStringify(value: unknown): string { diff --git a/tests/lifecycle.test.ts b/tests/lifecycle.test.ts index bd10ee4b..0371b09e 100644 --- a/tests/lifecycle.test.ts +++ b/tests/lifecycle.test.ts @@ -12,6 +12,7 @@ import { type CodegraphLifecycleSyncResult, type CodegraphLifecycleUninitResult, } from "../src/lifecycle/manifest.js"; +import type { BuildOptions } from "../src/indexer/types.js"; import { captureCli } from "./helpers/cli.js"; import { mkTmpDir } from "./helpers/filesystem.js"; @@ -44,6 +45,33 @@ describe("project lifecycle commands", () => { expect(await readCodegraphEntries(root)).toEqual(["manifest.json"]); }); + it("init --force refreshes lastSyncAt when project files and options are current", async () => { + const root = await mkTmpDir("cg-life-force-current-"); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + const first = await initCodegraphLifecycle(root); + const manifestPath = codegraphLifecycleManifestPath(root); + const staleManifest: CodegraphLifecycleManifest = { + ...first.manifest, + lastSyncAt: "2000-01-01T00:00:00.000Z", + }; + await fsp.writeFile(manifestPath, `${JSON.stringify(staleManifest, null, 2)}\n`, "utf8"); + + const currentStatus = await getCodegraphLifecycleStatus(root); + const withoutForce = await initCodegraphLifecycle(root); + + expect(currentStatus.suggestedNextCommand).toBe("codegraph status"); + expect(currentStatus.lastSyncAt).toBe(staleManifest.lastSyncAt); + expect(withoutForce.manifest).toEqual(staleManifest); + expect(await readManifest(root)).toEqual(staleManifest); + + const forced = await initCodegraphLifecycle(root, { force: true }); + + expect(forced.manifest.createdAt).toBe(first.manifest.createdAt); + expect(forced.manifest.lastSyncAt).not.toBe(staleManifest.lastSyncAt); + expect(forced.changedFiles.totalDelta).toBe(0); + expect(await readManifest(root)).toEqual(forced.manifest); + }); + it("init --force recomputes stale manifest metadata for current project files", async () => { const root = await mkTmpDir("cg-life-force-"); await writeFile(root, "src/main.ts", "export const main = 1;\n"); @@ -126,6 +154,47 @@ describe("project lifecycle commands", () => { expect(status.suggestedNextCommand).toBe("codegraph sync"); }); + it("status detects lifecycle-relevant build option drift", async () => { + const cases: { name: string; initial: BuildOptions; current: BuildOptions }[] = [ + { + name: "native mode", + initial: { native: "off" }, + current: { native: "auto" }, + }, + { + name: "graph options", + initial: { graph: { fast: true } }, + current: { graph: { fast: false } }, + }, + ]; + + for (const testCase of cases) { + const root = await mkTmpDir(`cg-life-build-options-${testCase.name.replaceAll(" ", "-")}-`); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + await initCodegraphLifecycle(root, { buildOptions: testCase.initial }); + + const status = await getCodegraphLifecycleStatus(root, { buildOptions: testCase.current }); + + expect(status.fileCount, testCase.name).toEqual({ then: 1, current: 1 }); + expect(status.filesChanged, testCase.name).toBeFalsy(); + expect(status.buildOptionsChanged, testCase.name).toBeTruthy(); + expect(status.suggestedNextCommand, testCase.name).toBe("codegraph sync"); + } + }); + + it("status treats omitted cache and explicit cache off as the same build options", async () => { + const root = await mkTmpDir("cg-life-cache-equivalent-"); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + await initCodegraphLifecycle(root, { buildOptions: { cache: "off" } }); + + const status = await getCodegraphLifecycleStatus(root); + + expect(status.fileCount).toEqual({ then: 1, current: 1 }); + expect(status.filesChanged).toBeFalsy(); + expect(status.buildOptionsChanged).toBeFalsy(); + expect(status.suggestedNextCommand).toBe("codegraph status"); + }); + it("sync updates manifest after a file edit", async () => { const root = await mkTmpDir("cg-life-sync-"); await writeFile(root, "src/main.ts", "export const main = 1;\n"); From 9b911f4ac26c2000aec4d21e85681bcaccf4c799 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Fri, 3 Jul 2026 06:13:26 -0400 Subject: [PATCH 14/26] Complete lifecycle review coverage --- codegraph-skill/codegraph/SKILL.md | 1 + docs/cli.md | 1 + src/lifecycle/manifest.ts | 10 ---------- tests/lifecycle.test.ts | 27 +++++++++++++++++++++++++++ 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/codegraph-skill/codegraph/SKILL.md b/codegraph-skill/codegraph/SKILL.md index 1e89492b..942cc853 100644 --- a/codegraph-skill/codegraph/SKILL.md +++ b/codegraph-skill/codegraph/SKILL.md @@ -49,6 +49,7 @@ Use `--root` to define the project boundary for config lookup, cache scope, path For `orient`, `drift`, and positional graph commands, positional paths are include roots inside that project. Use `codegraph install --target --yes` to configure supported local agent clients. Use `--dry-run` or `--print-config ` first; uninstall removes only Codegraph-owned marker blocks, marker files, or MCP entries whose command is `codegraph`. Lifecycle commands own only `.codegraph/manifest.json` metadata. `init` and `sync` may warm or update `.codegraph-cache/index-v1/`; other commands do not depend on the manifest. Use `uninit` to remove recognized lifecycle state. +Lifecycle commands accept either a positional project path or `--root `; never combine both. ## Output Choice diff --git a/docs/cli.md b/docs/cli.md index e1b11220..eee5577b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -123,6 +123,7 @@ Graph, index, and review reports include `backend.native.byLanguage` so native u - `status` reports whether lifecycle metadata exists, last sync time, then/current file counts, config/build-option drift, analysis label, and the suggested next command. Use `--json` for `schemaVersion: 1`. - `sync` refreshes the manifest after edits and requires an initialized project unless `--init` is passed. - `uninit` removes only recognized lifecycle state by default. It refuses unknown `.codegraph/` entries unless `--force` is passed. +- Lifecycle commands accept either a positional project path or `--root `. They reject using both together because lifecycle manifests always describe one project boundary, not include-root subsets. ### Symbols, navigation, grep, and chunking diff --git a/src/lifecycle/manifest.ts b/src/lifecycle/manifest.ts index bbd711ee..e47e528f 100644 --- a/src/lifecycle/manifest.ts +++ b/src/lifecycle/manifest.ts @@ -67,11 +67,6 @@ const MANIFEST_FILE = "manifest.json"; type LifecycleBuildOptionsSummary = ManifestBuildOptions & { graph?: ReturnType; native?: BuildOptions["native"]; - threads?: number; - nativeThreads?: number; - useNativeWorkers?: boolean; - cacheVerify?: boolean; - cacheDir?: string; }; const KNOWN_CODEGRAPH_FILES = new Set([MANIFEST_FILE]); @@ -297,11 +292,6 @@ function summarizeLifecycleBuildOptions(buildOptions: BuildOptions | undefined): const summary: LifecycleBuildOptionsSummary = { ...summarizeBuildOptions(buildOptions) }; if (buildOptions?.graph) summary.graph = normalizeGraphOptions(buildOptions.graph); if (buildOptions?.native !== undefined) summary.native = buildOptions.native; - if (buildOptions?.threads !== undefined) summary.threads = buildOptions.threads; - if (buildOptions?.nativeThreads !== undefined) summary.nativeThreads = buildOptions.nativeThreads; - if (buildOptions?.useNativeWorkers !== undefined) summary.useNativeWorkers = buildOptions.useNativeWorkers; - if (buildOptions?.cacheVerify !== undefined) summary.cacheVerify = buildOptions.cacheVerify; - if (buildOptions?.cacheDir !== undefined) summary.cacheDir = buildOptions.cacheDir; return summary; } diff --git a/tests/lifecycle.test.ts b/tests/lifecycle.test.ts index 0371b09e..9b138366 100644 --- a/tests/lifecycle.test.ts +++ b/tests/lifecycle.test.ts @@ -31,6 +31,14 @@ async function readCodegraphEntries(root: string): Promise { return (await fsp.readdir(path.join(root, ".codegraph"))).sort(); } +async function expectDiskIndexCacheHasArtifacts(root: string): Promise { + const cacheRoot = path.join(root, ".codegraph-cache", "index-v1"); + const stats = await fsp.stat(cacheRoot); + expect(stats.isDirectory()).toBeTruthy(); + const entries = await fsp.readdir(cacheRoot); + expect(entries.length).toBeGreaterThan(0); +} + describe("project lifecycle commands", () => { it("init creates a manifest and is idempotent when current", async () => { const root = await mkTmpDir("cg-life-init-"); @@ -208,6 +216,25 @@ describe("project lifecycle commands", () => { expect(await readCodegraphEntries(root)).toEqual(["manifest.json"]); }); + it("init and sync keep lifecycle metadata separate from the disk index cache", async () => { + const root = await mkTmpDir("cg-life-cache-warm-"); + const cacheRoot = path.join(root, ".codegraph-cache", "index-v1"); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + + await initCodegraphLifecycle(root); + + expect(await readCodegraphEntries(root)).toEqual(["manifest.json"]); + await expectDiskIndexCacheHasArtifacts(root); + + await fsp.rm(cacheRoot, { recursive: true, force: true }); + await fsp.mkdir(cacheRoot, { recursive: true }); + await writeFile(root, "src/extra.ts", "export const extra = 2;\n"); + await syncCodegraphLifecycle(root); + + await expectDiskIndexCacheHasArtifacts(root); + expect(await readCodegraphEntries(root)).toEqual(["manifest.json"]); + }); + it("sync requires an initialized project unless --init is used", async () => { const root = await mkTmpDir("cg-life-sync-init-"); await writeFile(root, "src/main.ts", "export const main = 1;\n"); From ac97209d71675b5b108f97d24b75b2e5a5ced628 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Fri, 3 Jul 2026 06:20:51 -0400 Subject: [PATCH 15/26] Normalize lifecycle option drift --- src/lifecycle/manifest.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/lifecycle/manifest.ts b/src/lifecycle/manifest.ts index e47e528f..1a544f13 100644 --- a/src/lifecycle/manifest.ts +++ b/src/lifecycle/manifest.ts @@ -65,8 +65,8 @@ const CODEGRAPH_DIR = ".codegraph"; const MANIFEST_FILE = "manifest.json"; type LifecycleBuildOptionsSummary = ManifestBuildOptions & { - graph?: ReturnType; - native?: BuildOptions["native"]; + graph: ReturnType; + native: BuildOptions["native"]; }; const KNOWN_CODEGRAPH_FILES = new Set([MANIFEST_FILE]); @@ -289,10 +289,11 @@ function hashBuildOptions(buildOptions: BuildOptions | undefined): string { } function summarizeLifecycleBuildOptions(buildOptions: BuildOptions | undefined): LifecycleBuildOptionsSummary { - const summary: LifecycleBuildOptionsSummary = { ...summarizeBuildOptions(buildOptions) }; - if (buildOptions?.graph) summary.graph = normalizeGraphOptions(buildOptions.graph); - if (buildOptions?.native !== undefined) summary.native = buildOptions.native; - return summary; + return { + ...summarizeBuildOptions(buildOptions), + graph: normalizeGraphOptions(buildOptions?.graph), + native: buildOptions?.native ?? "auto", + }; } function stableStringify(value: unknown): string { From a8472f58dd6f664b2eb6873206140be65bedf9fe Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Fri, 3 Jul 2026 06:28:25 -0400 Subject: [PATCH 16/26] Cover lifecycle option defaults --- tests/lifecycle.test.ts | 46 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/lifecycle.test.ts b/tests/lifecycle.test.ts index 9b138366..ba814988 100644 --- a/tests/lifecycle.test.ts +++ b/tests/lifecycle.test.ts @@ -203,6 +203,52 @@ describe("project lifecycle commands", () => { expect(status.suggestedNextCommand).toBe("codegraph status"); }); + it("status treats omitted and explicit default-equivalent native and graph options as current", async () => { + const explicitDefaultGraphOptions: BuildOptions = { + graph: { fast: false, resolveNodeModules: false, dynamicImportHeuristics: false }, + }; + const cases: { name: string; initial?: BuildOptions; current?: BuildOptions }[] = [ + { + name: "omitted at init and explicit native auto at status", + current: { native: "auto" }, + }, + { + name: "explicit native auto at init and omitted at status", + initial: { native: "auto" }, + }, + { + name: "omitted at init and explicit graph defaults at status", + current: explicitDefaultGraphOptions, + }, + { + name: "explicit graph defaults at init and omitted at status", + initial: explicitDefaultGraphOptions, + }, + ]; + + for (const testCase of cases) { + const root = await mkTmpDir(`cg-life-build-options-defaults-${testCase.name.replaceAll(" ", "-")}-`); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + if (testCase.initial) { + await initCodegraphLifecycle(root, { buildOptions: testCase.initial }); + } else { + await initCodegraphLifecycle(root); + } + + let status: CodegraphLifecycleStatus; + if (testCase.current) { + status = await getCodegraphLifecycleStatus(root, { buildOptions: testCase.current }); + } else { + status = await getCodegraphLifecycleStatus(root); + } + + expect(status.fileCount, testCase.name).toEqual({ then: 1, current: 1 }); + expect(status.filesChanged, testCase.name).toBeFalsy(); + expect(status.buildOptionsChanged, testCase.name).toBeFalsy(); + expect(status.suggestedNextCommand, testCase.name).toBe("codegraph status"); + } + }); + it("sync updates manifest after a file edit", async () => { const root = await mkTmpDir("cg-life-sync-"); await writeFile(root, "src/main.ts", "export const main = 1;\n"); From 8a7f827e2cc3a5cf42101a2c6752c924bdee3810 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 8 Jul 2026 03:18:35 -0400 Subject: [PATCH 17/26] Fix lifecycle sync diffing, config drift, and root test coverage --- src/lifecycle/manifest.ts | 51 +++++++++++++++-- src/sqlite/write.ts | 13 ----- tests/lifecycle.test.ts | 114 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 19 deletions(-) diff --git a/src/lifecycle/manifest.ts b/src/lifecycle/manifest.ts index 1a544f13..19fbc9c0 100644 --- a/src/lifecycle/manifest.ts +++ b/src/lifecycle/manifest.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import fsp from "node:fs/promises"; import path from "node:path"; import { createAgentSession, listAgentSessionFiles } from "../agent/session.js"; +import { CODEGRAPH_CONFIG_FILE } from "../config.js"; import { computeConfigHash } from "../indexer/build-cache/manifest.js"; import { normalizeGraphOptions, @@ -20,6 +21,8 @@ export type CodegraphLifecycleManifest = { buildOptionsHash: string; fileCount: number; fileSignatureHash: string; + /** Sorted, root-relative file paths as of the last sync. Absent on manifests written before this field existed. */ + files?: string[]; analysis: AnalysisSummary; }; @@ -124,11 +127,7 @@ export async function syncCodegraphLifecycle( initialized: true, manifestPath: codegraphLifecycleManifestPath(root), manifest, - changedFiles: { - added: Math.max(0, totalDelta), - removed: Math.max(0, -totalDelta), - totalDelta, - }, + changedFiles: diffLifecycleFileCounts(existing?.files, manifest.files, totalDelta), }; } @@ -226,6 +225,7 @@ async function buildLifecycleManifest( buildOptionsHash: hashBuildOptions(buildOptions), fileCount: snapshot.files.length, fileSignatureHash: await hashDiscoveredFiles(snapshot.files, root), + files: discoveredFileRelativePaths(snapshot.files, root), analysis: snapshot.analysis, }; } @@ -264,7 +264,40 @@ async function writeLifecycleManifest(root: string, manifest: CodegraphLifecycle async function hashConfig(root: string): Promise { const result = await computeConfigHash(root); - return result.hash; + const codegraphConfig = await readOptionalConfigContent(path.join(root, CODEGRAPH_CONFIG_FILE)); + if (codegraphConfig === null) return result.hash; + return sha256(`${result.hash}\0${codegraphConfig}`); +} + +async function readOptionalConfigContent(filePath: string): Promise { + try { + return await fsp.readFile(filePath, "utf8"); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return null; + throw error; + } +} + +function discoveredFileRelativePaths(files: readonly string[], root: string): string[] { + return [...files] + .map((file) => path.relative(root, file).replace(/\\/g, "/")) + .sort((left, right) => left.localeCompare(right)); +} + +function diffLifecycleFileCounts( + previous: readonly string[] | undefined, + current: readonly string[] | undefined, + totalDelta: number, +): { added: number; removed: number; totalDelta: number } { + if (previous === undefined || current === undefined) { + // Legacy manifest predates per-file tracking; approximate from the net file-count delta. + return { added: Math.max(0, totalDelta), removed: Math.max(0, -totalDelta), totalDelta }; + } + const previousSet = new Set(previous); + const currentSet = new Set(current); + const added = current.filter((file) => !previousSet.has(file)).length; + const removed = previous.filter((file) => !currentSet.has(file)).length; + return { added, removed, totalDelta }; } async function hashDiscoveredFiles(files: readonly string[], root: string): Promise { @@ -338,7 +371,13 @@ function isLifecycleManifest(value: unknown): value is CodegraphLifecycleManifes typeof record.buildOptionsHash === "string" && typeof record.fileCount === "number" && typeof record.fileSignatureHash === "string" && + isOptionalStringArray(record.files) && typeof record.analysis === "object" && record.analysis !== null ); } + +function isOptionalStringArray(value: unknown): value is string[] | undefined { + if (value === undefined) return true; + return Array.isArray(value) && value.every((entry) => typeof entry === "string"); +} diff --git a/src/sqlite/write.ts b/src/sqlite/write.ts index d75e8996..ea37c911 100644 --- a/src/sqlite/write.ts +++ b/src/sqlite/write.ts @@ -1,4 +1,3 @@ -import fs from "node:fs/promises"; import { type SymbolGraph, type SymbolNode } from "../graphs/symbol-graph.js"; import type { Graph } from "../types.js"; import type { SqliteDatabase } from "../sqlite-driver.js"; @@ -14,18 +13,6 @@ type SqliteArtifactFileSignature = { mtimeMs: number; }; -async function collectSqliteArtifactFileSignatures(files: Iterable): Promise { - const signatures: SqliteArtifactFileSignature[] = []; - await Promise.all( - [...files].map(async (file) => { - const stat = await fs.stat(file); - if (!stat.isFile()) return; - signatures.push({ path: file, size: stat.size, mtimeMs: stat.mtimeMs }); - }), - ); - signatures.sort((left, right) => left.path.localeCompare(right.path)); - return signatures; -} function normalizeSqliteArtifactFileSignatures( signatures: Iterable, ): SqliteArtifactFileSignature[] { diff --git a/tests/lifecycle.test.ts b/tests/lifecycle.test.ts index ba814988..d25f48be 100644 --- a/tests/lifecycle.test.ts +++ b/tests/lifecycle.test.ts @@ -15,6 +15,7 @@ import { import type { BuildOptions } from "../src/indexer/types.js"; import { captureCli } from "./helpers/cli.js"; import { mkTmpDir } from "./helpers/filesystem.js"; +import { CODEGRAPH_CONFIG_FILE } from "../src/config.js"; async function writeFile(root: string, relativePath: string, content: string): Promise { const filePath = path.join(root, relativePath); @@ -162,6 +163,22 @@ describe("project lifecycle commands", () => { expect(status.suggestedNextCommand).toBe("codegraph sync"); }); + it("status detects codegraph.config.json content changes", async () => { + const root = await mkTmpDir("cg-life-config-json-"); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + await initCodegraphLifecycle(root); + + const before = await getCodegraphLifecycleStatus(root); + expect(before.configChanged).toBeFalsy(); + + await writeFile(root, CODEGRAPH_CONFIG_FILE, `${JSON.stringify({ discovery: { ignoreGlobs: ["dist/**"] } })}\n`); + + const after = await getCodegraphLifecycleStatus(root); + + expect(after.configChanged).toBeTruthy(); + expect(after.suggestedNextCommand).toBe("codegraph sync"); + }); + it("status detects lifecycle-relevant build option drift", async () => { const cases: { name: string; initial: BuildOptions; current: BuildOptions }[] = [ { @@ -262,6 +279,46 @@ describe("project lifecycle commands", () => { expect(await readCodegraphEntries(root)).toEqual(["manifest.json"]); }); + it("sync independently tracks added and removed files when one is added and a different one is removed", async () => { + const root = await mkTmpDir("cg-life-sync-add-remove-"); + await writeFile(root, "src/a.ts", "export const a = 1;\n"); + await writeFile(root, "src/b.ts", "export const b = 2;\n"); + await initCodegraphLifecycle(root); + + await fsp.rm(path.join(root, "src/a.ts")); + await writeFile(root, "src/c.ts", "export const c = 3;\n"); + + const result = await syncCodegraphLifecycle(root); + + expect(result.manifest.fileCount).toBe(2); + expect(result.manifest.files).toEqual(["src/b.ts", "src/c.ts"]); + expect(result.changedFiles.totalDelta).toBe(0); + expect(result.changedFiles.added).toBe(1); + expect(result.changedFiles.removed).toBe(1); + }); + + it("sync falls back to net-delta approximation when the previous manifest predates per-file tracking", async () => { + const root = await mkTmpDir("cg-life-sync-legacy-manifest-"); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + await initCodegraphLifecycle(root); + + const manifestPath = codegraphLifecycleManifestPath(root); + const legacyManifest = await readManifest(root); + delete legacyManifest.files; + await fsp.writeFile(manifestPath, `${JSON.stringify(legacyManifest, null, 2)}\n`, "utf8"); + + await writeFile(root, "src/extra.ts", "export const extra = 2;\n"); + + const result = await syncCodegraphLifecycle(root); + + expect(result.changedFiles.added).toBeGreaterThanOrEqual(0); + expect(result.changedFiles.removed).toBeGreaterThanOrEqual(0); + expect(result.changedFiles.added).toBe(1); + expect(result.changedFiles.removed).toBe(0); + expect(result.manifest.fileCount).toBe(2); + expect(result.manifest.files).toEqual(["src/extra.ts", "src/main.ts"]); + }); + it("init and sync keep lifecycle metadata separate from the disk index cache", async () => { const root = await mkTmpDir("cg-life-cache-warm-"); const cacheRoot = path.join(root, ".codegraph-cache", "index-v1"); @@ -386,6 +443,63 @@ describe("project lifecycle commands", () => { } }); + it("CLI lifecycle mutating commands honor --root without a positional path", async () => { + const commands = ["init", "sync", "uninit"] as const; + + for (const command of commands) { + const cwd = await mkTmpDir(`cg-life-cli-root-success-${command}-cwd-`); + const flagRoot = await mkTmpDir(`cg-life-cli-root-success-${command}-root-`); + await writeFile(flagRoot, "src/main.ts", "export const main = 1;\n"); + if (command === "uninit") { + await initCodegraphLifecycle(flagRoot); + expect(await readCodegraphEntries(flagRoot)).toEqual(["manifest.json"]); + } + + const args = [command, "--root", flagRoot, "--json"]; + if (command === "sync") args.push("--init"); + if (command === "uninit") args.push("--force"); + + const result = await captureCli(args, { cwd }); + + expect(result.exitCode, `${command} --root should succeed`).toBeUndefined(); + expect(result.stderr, command).toBe(""); + + if (command === "uninit") { + const payload = JSON.parse(result.stdout) as CodegraphLifecycleUninitResult; + expect(payload.root, command).toBe(flagRoot); + expect(payload.removed, command).toBeTruthy(); + await expect(fsp.stat(path.join(flagRoot, ".codegraph"))).rejects.toMatchObject({ code: "ENOENT" }); + } else { + const payload = JSON.parse(result.stdout) as CodegraphLifecycleSyncResult; + expect(payload.root, command).toBe(flagRoot); + expect(payload.initialized, command).toBeTruthy(); + expect(payload.manifest.fileCount, command).toBe(1); + expect(await readCodegraphEntries(flagRoot)).toEqual(["manifest.json"]); + } + + await expect(fsp.stat(path.join(cwd, ".codegraph")), `${command} must not touch cwd`).rejects.toMatchObject({ + code: "ENOENT", + }); + } + }); + + it("CLI status --root reports the flag root's lifecycle state regardless of cwd", async () => { + const cwd = await mkTmpDir("cg-life-cli-root-success-status-cwd-"); + const flagRoot = await mkTmpDir("cg-life-cli-root-success-status-root-"); + await writeFile(flagRoot, "src/main.ts", "export const main = 1;\n"); + await initCodegraphLifecycle(flagRoot); + + const result = await captureCli(["status", "--root", flagRoot, "--json"], { cwd }); + + expect(result.exitCode).toBeUndefined(); + expect(result.stderr).toBe(""); + const payload = JSON.parse(result.stdout) as CodegraphLifecycleStatus; + expect(payload.root).toBe(flagRoot); + expect(payload.initialized).toBeTruthy(); + expect(payload.fileCount).toEqual({ then: 1, current: 1 }); + expect(payload.suggestedNextCommand).toBe("codegraph status"); + }); + it("CLI status --json reports initialized and uninitialized lifecycle state", async () => { const uninitializedRoot = await mkTmpDir("cg-life-cli-status-uninitialized-"); await writeFile(uninitializedRoot, "src/main.ts", "export const main = 1;\n"); From 14841f2f43302091feb30c1c74627c47bcd8cb16 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 8 Jul 2026 03:31:39 -0400 Subject: [PATCH 18/26] Centralize config drift hashing and strengthen content-drift coverage --- src/indexer/build-cache/manifest.ts | 3 ++- src/lifecycle/manifest.ts | 14 +------------- tests/lifecycle.test.ts | 16 ++++++++++++++++ 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/indexer/build-cache/manifest.ts b/src/indexer/build-cache/manifest.ts index d40a896f..3365b784 100644 --- a/src/indexer/build-cache/manifest.ts +++ b/src/indexer/build-cache/manifest.ts @@ -4,6 +4,7 @@ import fsp from "node:fs/promises"; import path from "node:path"; import fg from "fast-glob"; import type { GraphCacheEntry, GraphBuildOptions } from "../../graphs/types.js"; +import { CODEGRAPH_CONFIG_FILE } from "../../config.js"; import { logWithLevel, type LogLevel } from "../../logging.js"; import type { Edge } from "../../types.js"; import { @@ -120,7 +121,7 @@ export function sanitizeManifestEntriesForRoot( export async function computeConfigHash(projectRoot: string, logLevel?: LogLevel): Promise { try { - const configFiles = await fg([...DEFAULT_PROJECT_MANIFESTS, "**/.gitignore"], { + const configFiles = await fg([...DEFAULT_PROJECT_MANIFESTS, CODEGRAPH_CONFIG_FILE, "**/.gitignore"], { cwd: projectRoot, absolute: true, dot: true, diff --git a/src/lifecycle/manifest.ts b/src/lifecycle/manifest.ts index 19fbc9c0..6121f5e7 100644 --- a/src/lifecycle/manifest.ts +++ b/src/lifecycle/manifest.ts @@ -2,7 +2,6 @@ import { createHash } from "node:crypto"; import fsp from "node:fs/promises"; import path from "node:path"; import { createAgentSession, listAgentSessionFiles } from "../agent/session.js"; -import { CODEGRAPH_CONFIG_FILE } from "../config.js"; import { computeConfigHash } from "../indexer/build-cache/manifest.js"; import { normalizeGraphOptions, @@ -264,18 +263,7 @@ async function writeLifecycleManifest(root: string, manifest: CodegraphLifecycle async function hashConfig(root: string): Promise { const result = await computeConfigHash(root); - const codegraphConfig = await readOptionalConfigContent(path.join(root, CODEGRAPH_CONFIG_FILE)); - if (codegraphConfig === null) return result.hash; - return sha256(`${result.hash}\0${codegraphConfig}`); -} - -async function readOptionalConfigContent(filePath: string): Promise { - try { - return await fsp.readFile(filePath, "utf8"); - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") return null; - throw error; - } + return result.hash; } function discoveredFileRelativePaths(files: readonly string[], root: string): string[] { diff --git a/tests/lifecycle.test.ts b/tests/lifecycle.test.ts index d25f48be..171fa6ed 100644 --- a/tests/lifecycle.test.ts +++ b/tests/lifecycle.test.ts @@ -177,6 +177,22 @@ describe("project lifecycle commands", () => { expect(after.configChanged).toBeTruthy(); expect(after.suggestedNextCommand).toBe("codegraph sync"); + + // Sync so the manifest baselines against the file's current ("dist/**") content. + await initCodegraphLifecycle(root, { force: true }); + + const baselined = await getCodegraphLifecycleStatus(root); + expect(baselined.configChanged).toBeFalsy(); + + // Rewrite with different valid content (not a mere appearance) and confirm drift is detected again. + // An implementation that only checks the file's presence/path (and ignores content) would still see + // this as "unchanged since sync" and wrongly report configChanged: false here. + await writeFile(root, CODEGRAPH_CONFIG_FILE, `${JSON.stringify({ discovery: { ignoreGlobs: ["build/**"] } })}\n`); + + const driftedAgain = await getCodegraphLifecycleStatus(root); + + expect(driftedAgain.configChanged).toBeTruthy(); + expect(driftedAgain.suggestedNextCommand).toBe("codegraph sync"); }); it("status detects lifecycle-relevant build option drift", async () => { From 4bf6920ea9885b8cacd4a9d624fa106da9ed79a6 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 8 Jul 2026 03:43:30 -0400 Subject: [PATCH 19/26] Surface config hash errors and cover disk cache invalidation --- src/lifecycle/manifest.ts | 12 ++++++++---- tests/cache-invalidation.test.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/lifecycle/manifest.ts b/src/lifecycle/manifest.ts index 6121f5e7..2d2c3a58 100644 --- a/src/lifecycle/manifest.ts +++ b/src/lifecycle/manifest.ts @@ -3,6 +3,7 @@ import fsp from "node:fs/promises"; import path from "node:path"; import { createAgentSession, listAgentSessionFiles } from "../agent/session.js"; import { computeConfigHash } from "../indexer/build-cache/manifest.js"; +import { logWithLevel } from "../logging.js"; import { normalizeGraphOptions, summarizeBuildOptions, @@ -136,7 +137,7 @@ export async function getCodegraphLifecycleStatus( ): Promise { const manifestPath = codegraphLifecycleManifestPath(root); const manifest = await readLifecycleManifest(root); - const configHash = await hashConfig(root); + const configHash = await hashConfig(root, options.buildOptions?.logLevel); const buildOptionsHash = hashBuildOptions(options.buildOptions); const files = await listAgentSessionFiles({ root, @@ -220,7 +221,7 @@ async function buildLifecycleManifest( root: ".", createdAt: existing?.createdAt ?? now, lastSyncAt: now, - configHash: await hashConfig(root), + configHash: await hashConfig(root, buildOptions?.logLevel), buildOptionsHash: hashBuildOptions(buildOptions), fileCount: snapshot.files.length, fileSignatureHash: await hashDiscoveredFiles(snapshot.files, root), @@ -261,8 +262,11 @@ async function writeLifecycleManifest(root: string, manifest: CodegraphLifecycle await fsp.rename(tempPath, manifestPath); } -async function hashConfig(root: string): Promise { - const result = await computeConfigHash(root); +async function hashConfig(root: string, logLevel: BuildOptions["logLevel"]): Promise { + const result = await computeConfigHash(root, logLevel); + if (result.error) { + logWithLevel(logLevel, "warn", `Warning: Codegraph lifecycle config drift check: ${result.error}`); + } return result.hash; } diff --git a/tests/cache-invalidation.test.ts b/tests/cache-invalidation.test.ts index 5993d5a0..86dec353 100644 --- a/tests/cache-invalidation.test.ts +++ b/tests/cache-invalidation.test.ts @@ -1165,6 +1165,38 @@ describe("Cache invalidation and strict hashing", () => { expect(report.manifest?.reason).toBeUndefined(); }); + it("invalidates the disk manifest cache when codegraph.config.json content changes", async () => { + const root = await mkTmpDir("dg-config-json-hash-"); + const entryFile = path.join(root, "entry.ts"); + const configPath = path.join(root, "codegraph.config.json"); + + await fsp.writeFile(entryFile, "export const value = 1;\n", "utf8"); + await fsp.writeFile(configPath, JSON.stringify({ discovery: { ignoreGlobs: ["dist/**"] } }), "utf8"); + + await buildProjectIndex(root, { cache: "disk" }); + + const manifestPath = path.join(root, ".codegraph-cache", "index-v1", "manifest.json"); + const manifestBefore = JSON.parse(await fsp.readFile(manifestPath, "utf8")) as { configHash?: unknown }; + expect(typeof manifestBefore.configHash).toBe("string"); + + await fsp.writeFile(configPath, JSON.stringify({ discovery: { ignoreGlobs: ["build/**"] } }), "utf8"); + + const report: BuildReport = { timings: {} }; + await buildProjectIndexIncremental(root, { + cache: "disk", + logLevel: "silent", + report, + }); + + expect(report.manifest?.used).toBe(true); + expect(report.manifest?.reused).toBe(false); + expect(report.manifest?.reason).toBe("graphOptionsMismatch"); + + const manifestAfter = JSON.parse(await fsp.readFile(manifestPath, "utf8")) as { configHash?: unknown }; + expect(typeof manifestAfter.configHash).toBe("string"); + expect(manifestAfter.configHash).not.toBe(manifestBefore.configHash); + }); + it("preserves stable config caches when clearing import resolution state", async () => { const root = await mkTmpDir("dg-import-cache-preserve-"); const srcDir = path.join(root, "src"); From f19d53e4ac0c0873f51ec63c60465ea67da76c9d Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 8 Jul 2026 07:24:51 -0400 Subject: [PATCH 20/26] Cover lifecycle config hash warning path --- tests/lifecycle.test.ts | 42 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/tests/lifecycle.test.ts b/tests/lifecycle.test.ts index 171fa6ed..aaea8002 100644 --- a/tests/lifecycle.test.ts +++ b/tests/lifecycle.test.ts @@ -1,6 +1,6 @@ import fsp from "node:fs/promises"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { codegraphLifecycleManifestPath, getCodegraphLifecycleStatus, @@ -195,6 +195,46 @@ describe("project lifecycle commands", () => { expect(driftedAgain.suggestedNextCommand).toBe("codegraph sync"); }); + it("init tolerates an unreadable config file: warns via hashConfig but still returns a hash-backed manifest", async () => { + const root = await mkTmpDir("cg-life-config-unreadable-"); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + await writeFile(root, "package.json", `${JSON.stringify({ name: "fixture" })}\n`); + await writeFile(root, ".gitignore", "node_modules/\n"); + const gitignorePath = path.join(root, ".gitignore"); + // Revoke read permission so computeConfigHash's per-file fsp.readFile throws (EACCES) for this + // one config-hash input while package.json still hashes successfully. .gitignore is deliberately + // chosen: it's one of computeConfigHash's matched config files (`**/.gitignore`), but unlike + // package.json/package-lock.json/codegraph.config.json it is never part of the discovered project + // file set (DEFAULT_PROJECT_PATTERNS) or read unconditionally elsewhere (loadGitignoreRules + // already swallows its own read failures) - so this isolates hashConfig's + // `if (result.error) logWithLevel(logLevel, "warn", ...)` branch without mocking fs or tripping + // an unrelated unguarded read. + await fsp.chmod(gitignorePath, 0o000); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + try { + const result = await initCodegraphLifecycle(root); + + // computeConfigHash is also consulted by the indexer's own disk-cache build/manifest layers + // (each logging their own generic "Warning: ..." message), so assert on the lifecycle-specific + // wording rather than an exact call count. + expect(warnSpy).toHaveBeenCalled(); + const lifecycleWarnCall = warnSpy.mock.calls.find( + (call) => typeof call[0] === "string" && call[0].includes("Codegraph lifecycle config drift check"), + ); + expect(lifecycleWarnCall).toBeDefined(); + expect(lifecycleWarnCall?.[0]).toEqual(expect.stringContaining(".gitignore")); + + // The command completes successfully (no throw) and still produces a hash-backed manifest, + // computed from whichever config files it *could* read (here, package.json). + expect(result.manifest.configHash).toMatch(/^[0-9a-f]{40}$/); + expect(await readManifest(root)).toEqual(result.manifest); + } finally { + warnSpy.mockRestore(); + await fsp.chmod(gitignorePath, 0o644); + } + }); + it("status detects lifecycle-relevant build option drift", async () => { const cases: { name: string; initial: BuildOptions; current: BuildOptions }[] = [ { From 07cf92c802378ebd9037ba4aa88e3d8391e2c136 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 8 Jul 2026 15:56:23 -0400 Subject: [PATCH 21/26] Address Copilot review: lazy status I/O, metadata file signatures, stableStringify, atomic manifest writes, dedupe help line --- src/cli/help.ts | 1 - src/lifecycle/manifest.ts | 71 ++++++++++++++++++++++++++++-------- tests/lifecycle.test.ts | 76 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 131 insertions(+), 17 deletions(-) diff --git a/src/cli/help.ts b/src/cli/help.ts index d13e3c69..4fc912ac 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -80,7 +80,6 @@ Recommended review commands: Unfamiliar repo: codegraph explore "how does auth reach db?" --root . --pretty codegraph orient --root . --budget small --pretty - codegraph explore "how does auth reach db?" --root . --pretty Examples: codegraph review --base HEAD --head WORKTREE --summary diff --git a/src/lifecycle/manifest.ts b/src/lifecycle/manifest.ts index 2d2c3a58..880dc196 100644 --- a/src/lifecycle/manifest.ts +++ b/src/lifecycle/manifest.ts @@ -1,9 +1,10 @@ -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import fsp from "node:fs/promises"; import path from "node:path"; import { createAgentSession, listAgentSessionFiles } from "../agent/session.js"; import { computeConfigHash } from "../indexer/build-cache/manifest.js"; import { logWithLevel } from "../logging.js"; +import { mapLimit } from "../util/concurrency.js"; import { normalizeGraphOptions, summarizeBuildOptions, @@ -137,12 +138,6 @@ export async function getCodegraphLifecycleStatus( ): Promise { const manifestPath = codegraphLifecycleManifestPath(root); const manifest = await readLifecycleManifest(root); - const configHash = await hashConfig(root, options.buildOptions?.logLevel); - const buildOptionsHash = hashBuildOptions(options.buildOptions); - const files = await listAgentSessionFiles({ - root, - ...(options.buildOptions ? { buildOptions: options.buildOptions } : {}), - }); if (!manifest) { return { schemaVersion: MANIFEST_SCHEMA_VERSION, @@ -155,6 +150,12 @@ export async function getCodegraphLifecycleStatus( suggestedNextCommand: "codegraph init", }; } + const configHash = await hashConfig(root, options.buildOptions?.logLevel); + const buildOptionsHash = hashBuildOptions(options.buildOptions); + const files = await listAgentSessionFiles({ + root, + ...(options.buildOptions ? { buildOptions: options.buildOptions } : {}), + }); const fileSignatureHash = await hashDiscoveredFiles(files, root); const configChanged = manifest.configHash !== configHash; const buildOptionsChanged = manifest.buildOptionsHash !== buildOptionsHash; @@ -254,12 +255,25 @@ async function readLifecycleManifest( } } +function lifecycleManifestTempFilePath(manifestPath: string): string { + const dir = path.dirname(manifestPath); + const base = path.basename(manifestPath); + return path.join(dir, `.${base}.${process.pid}.${randomUUID()}.tmp`); +} + async function writeLifecycleManifest(root: string, manifest: CodegraphLifecycleManifest): Promise { const manifestPath = codegraphLifecycleManifestPath(root); await fsp.mkdir(path.dirname(manifestPath), { recursive: true }); - const tempPath = `${manifestPath}.${process.pid}.${Date.now()}.tmp`; - await fsp.writeFile(tempPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); - await fsp.rename(tempPath, manifestPath); + const tempPath = lifecycleManifestTempFilePath(manifestPath); + try { + await fsp.writeFile(tempPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + await fsp.rename(tempPath, manifestPath); + } catch (error) { + await fsp.rm(tempPath, { force: true }).catch(() => { + // Cleanup is best-effort; surfacing the original error matters more. + }); + throw error; + } } async function hashConfig(root: string, logLevel: BuildOptions["logLevel"]): Promise { @@ -292,13 +306,36 @@ function diffLifecycleFileCounts( return { added, removed, totalDelta }; } +const FILE_SIGNATURE_STAT_CONCURRENCY = 64; + +function isMissingStatRace(error: unknown): boolean { + if (!(error instanceof Error)) return false; + if (!("code" in error)) return false; + return error.code === "ENOENT" || error.code === "ENOTDIR"; +} + async function hashDiscoveredFiles(files: readonly string[], root: string): Promise { + const sorted = [...files].sort((left, right) => left.localeCompare(right)); + const signatures = new Map(); + await mapLimit(sorted, FILE_SIGNATURE_STAT_CONCURRENCY, async (file) => { + try { + const stat = await fsp.stat(file); + signatures.set(file, { size: stat.size, mtimeMs: stat.mtimeMs }); + } catch (error) { + if (isMissingStatRace(error)) return; + throw error; + } + }); const hash = createHash("sha256"); - for (const file of [...files].sort((left, right) => left.localeCompare(right))) { + for (const file of sorted) { + const signature = signatures.get(file); + if (!signature) continue; const relative = path.relative(root, file).replace(/\\/g, "/"); hash.update(relative); hash.update("\0"); - hash.update(await fsp.readFile(file)); + hash.update(String(signature.size)); + hash.update("\0"); + hash.update(String(signature.mtimeMs)); hash.update("\0"); } return hash.digest("hex"); @@ -321,10 +358,14 @@ function summarizeLifecycleBuildOptions(buildOptions: BuildOptions | undefined): }; } -function stableStringify(value: unknown): string { - if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; +export function stableStringify(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((item) => (item === undefined ? "null" : stableStringify(item))).join(",")}]`; + } if (typeof value !== "object" || value === null) return JSON.stringify(value); - const entries = Object.entries(value).sort(([left], [right]) => left.localeCompare(right)); + const entries = Object.entries(value) + .filter(([, nested]) => nested !== undefined) + .sort(([left], [right]) => left.localeCompare(right)); return `{${entries.map(([key, nested]) => `${JSON.stringify(key)}:${stableStringify(nested)}`).join(",")}}`; } diff --git a/tests/lifecycle.test.ts b/tests/lifecycle.test.ts index aaea8002..9e9a8e15 100644 --- a/tests/lifecycle.test.ts +++ b/tests/lifecycle.test.ts @@ -1,10 +1,11 @@ import fsp from "node:fs/promises"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { codegraphLifecycleManifestPath, getCodegraphLifecycleStatus, initCodegraphLifecycle, + stableStringify, syncCodegraphLifecycle, uninitCodegraphLifecycle, type CodegraphLifecycleManifest, @@ -12,6 +13,8 @@ import { type CodegraphLifecycleSyncResult, type CodegraphLifecycleUninitResult, } from "../src/lifecycle/manifest.js"; +import * as indexerManifest from "../src/indexer/build-cache/manifest.js"; +import * as agentSession from "../src/agent/session.js"; import type { BuildOptions } from "../src/indexer/types.js"; import { captureCli } from "./helpers/cli.js"; import { mkTmpDir } from "./helpers/filesystem.js"; @@ -41,6 +44,10 @@ async function expectDiskIndexCacheHasArtifacts(root: string): Promise { } describe("project lifecycle commands", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it("init creates a manifest and is idempotent when current", async () => { const root = await mkTmpDir("cg-life-init-"); await writeFile(root, "src/main.ts", "export const main = 1;\n"); @@ -634,4 +641,71 @@ describe("project lifecycle commands", () => { expect(result.stderr).toContain(invalidRoot); } }); + + it("status short-circuits without hashing config or discovering files when uninitialized", async () => { + const root = await mkTmpDir("cg-life-status-short-circuit-"); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + + const configHashSpy = vi.spyOn(indexerManifest, "computeConfigHash"); + const listFilesSpy = vi.spyOn(agentSession, "listAgentSessionFiles"); + + const status = await getCodegraphLifecycleStatus(root); + + expect(status.initialized).toBeFalsy(); + expect(configHashSpy).not.toHaveBeenCalled(); + expect(listFilesSpy).not.toHaveBeenCalled(); + }); + + it("status treats an mtime-only rewrite as a file change even with byte-identical content", async () => { + const root = await mkTmpDir("cg-life-status-mtime-"); + const relativePath = "src/main.ts"; + const content = "export const main = 1;\n"; + await writeFile(root, relativePath, content); + await initCodegraphLifecycle(root); + + const initialStatus = await getCodegraphLifecycleStatus(root); + expect(initialStatus.filesChanged).toBeFalsy(); + + const filePath = path.join(root, relativePath); + const originalStat = await fsp.stat(filePath); + const futureDate = new Date(originalStat.mtime.getTime() + 60_000); + await fsp.writeFile(filePath, content, "utf8"); + await fsp.utimes(filePath, futureDate, futureDate); + + const status = await getCodegraphLifecycleStatus(root); + + expect(status.filesChanged).toBeTruthy(); + }); + + it("stableStringify omits undefined object values and disambiguates undefined array elements from empty arrays", () => { + expect(stableStringify({ a: undefined, b: 1 })).toBe(stableStringify({ b: 1 })); + expect(stableStringify({ b: 1 })).toBe('{"b":1}'); + + expect(stableStringify([])).not.toBe(stableStringify([undefined])); + expect(stableStringify([undefined])).toBe("[null]"); + + expect(stableStringify({ preset: undefined })).not.toContain("undefined"); + }); + + it("init cleans up its hidden temp manifest file when fs.rename fails", async () => { + const root = await mkTmpDir("cg-life-write-failure-"); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + + const manifestPath = codegraphLifecycleManifestPath(root); + const renameError = new Error("injected rename failure"); + const realRename = fsp.rename.bind(fsp); + const renameSpy = vi.spyOn(fsp, "rename").mockImplementation(async (oldPath, newPath) => { + if (newPath === manifestPath) { + throw renameError; + } + return realRename(oldPath, newPath); + }); + + await expect(initCodegraphLifecycle(root)).rejects.toThrow("injected rename failure"); + + renameSpy.mockRestore(); + + const entries = await fsp.readdir(path.join(root, ".codegraph")); + expect(entries.some((name) => name.endsWith(".tmp"))).toBe(false); + }); }); From 9a034f1f84666ed62509df65def5a87d2418640e Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 8 Jul 2026 16:42:42 -0400 Subject: [PATCH 22/26] Address Copilot review: drop non-functional --cache from lifecycle commands, surface manifest read failures as user errors --- src/cli/help.ts | 2 +- src/cli/options.ts | 10 +++++--- src/lifecycle/manifest.ts | 4 +++- tests/cli-options-validation.test.ts | 22 +++++++++++++++++ tests/lifecycle.test.ts | 35 ++++++++++++++++++++++++++++ 5 files changed, 68 insertions(+), 5 deletions(-) diff --git a/src/cli/help.ts b/src/cli/help.ts index 4fc912ac..6b720a11 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -207,7 +207,7 @@ Output: JSON is the default. Use --pretty for concise model-readable sections. Index options: - Supports shared --cache, --cache-strict, --cache-verify, --threads, --native, --workers, --include-glob, --ignore-glob, and --no-gitignore options. + Supports shared --cache-strict, --cache-verify, --threads, --native, --workers, --include-glob, --ignore-glob, and --no-gitignore options. init/sync always warm the disk cache and do not accept --cache. `; export const SEARCH_HELP_TEXT = `codegraph search - Ranked agent search across project context diff --git a/src/cli/options.ts b/src/cli/options.ts index e928ae5d..aadc922b 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -115,6 +115,10 @@ const SHARED_BUILD_OPTIONS = [ "--ignore-glob", "--resolution-hint", ]; +// Lifecycle commands (init/status/sync) always warm/read the disk cache and never honor an +// explicit --cache override, so --cache is intentionally excluded here to keep the CLI +// contract truthful. +const LIFECYCLE_BUILD_OPTIONS = SHARED_BUILD_OPTIONS.filter((option) => option !== "--cache"); const JSON_OUTPUT_FLAGS = ["--json", "--pretty"]; const REPORT_FLAGS = ["--report"]; const REPORT_OPTIONS = ["--report-file"]; @@ -363,7 +367,7 @@ const CLI_COMMAND_SCHEMAS = new Map([ ["index", graphCommandSchema({ kind: "any" })], [ "init", - commandSchema([...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS, "--force"], SHARED_BUILD_OPTIONS, { + commandSchema([...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS, "--force"], LIFECYCLE_BUILD_OPTIONS, { kind: "max", max: 1, usage: "Usage: codegraph init [path] [--force] [--json] OR codegraph init --root [--force] [--json]", @@ -491,7 +495,7 @@ const CLI_COMMAND_SCHEMAS = new Map([ ], [ "status", - commandSchema([...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS], SHARED_BUILD_OPTIONS, { + commandSchema([...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS], LIFECYCLE_BUILD_OPTIONS, { kind: "max", max: 1, usage: "Usage: codegraph status [path] [--json] OR codegraph status --root [--json]", @@ -499,7 +503,7 @@ const CLI_COMMAND_SCHEMAS = new Map([ ], [ "sync", - commandSchema([...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS, "--init"], SHARED_BUILD_OPTIONS, { + commandSchema([...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS, "--init"], LIFECYCLE_BUILD_OPTIONS, { kind: "max", max: 1, usage: "Usage: codegraph sync [path] [--init] [--json] OR codegraph sync --root [--init] [--json]", diff --git a/src/lifecycle/manifest.ts b/src/lifecycle/manifest.ts index 880dc196..0f2659ba 100644 --- a/src/lifecycle/manifest.ts +++ b/src/lifecycle/manifest.ts @@ -241,7 +241,9 @@ async function readLifecycleManifest( raw = await fsp.readFile(manifestPath, "utf8"); } catch (error) { if (error instanceof Error && "code" in error && error.code === "ENOENT") return null; - throw new Error(`Unable to read Codegraph lifecycle manifest at ${manifestPath}: ${stringifyError(error)}`); + throw new CodegraphLifecycleUserError( + `Unable to read Codegraph lifecycle manifest at ${manifestPath}: ${stringifyError(error)}`, + ); } try { const parsed: unknown = JSON.parse(raw); diff --git a/tests/cli-options-validation.test.ts b/tests/cli-options-validation.test.ts index 3fff9eab..22c4eded 100644 --- a/tests/cli-options-validation.test.ts +++ b/tests/cli-options-validation.test.ts @@ -67,4 +67,26 @@ describe("CLI command option validation", () => { expect(() => validateCliArgs(command, parsed)).toThrow(`Unknown option for ${command}: --json`); } }); + + it("rejects --cache for lifecycle commands (init/status/sync) since buildLifecycleManifest always forces disk cache", () => { + for (const command of ["init", "status", "sync"]) { + const parsed = parseCliArgs(command, ["--cache", "off"]); + + expect(() => validateCliArgs(command, parsed)).toThrow(`Unknown option for ${command}: --cache`); + } + }); + + it("still rejects --cache for uninit, which never accepted it", () => { + const parsed = parseCliArgs("uninit", ["--cache", "off"]); + + expect(() => validateCliArgs("uninit", parsed)).toThrow("Unknown option for uninit: --cache"); + }); + + it("still accepts --cache for commands that legitimately support an explicit cache override", () => { + for (const command of ["orient", "search"]) { + const parsed = parseCliArgs(command, ["--cache", "off"]); + + expect(() => validateCliArgs(command, parsed)).not.toThrow(); + } + }); }); diff --git a/tests/lifecycle.test.ts b/tests/lifecycle.test.ts index 9e9a8e15..ead0e810 100644 --- a/tests/lifecycle.test.ts +++ b/tests/lifecycle.test.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { codegraphLifecycleManifestPath, + CodegraphLifecycleUserError, getCodegraphLifecycleStatus, initCodegraphLifecycle, stableStringify, @@ -708,4 +709,38 @@ describe("project lifecycle commands", () => { const entries = await fsp.readdir(path.join(root, ".codegraph")); expect(entries.some((name) => name.endsWith(".tmp"))).toBe(false); }); + + it("surfaces a non-ENOENT manifest read failure as CodegraphLifecycleUserError, not a raw Error", async () => { + const root = await mkTmpDir("cg-life-read-failure-"); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + await initCodegraphLifecycle(root); + + const manifestPath = codegraphLifecycleManifestPath(root); + const originalReadFile = fsp.readFile.bind(fsp); + const eaccesError = Object.assign(new Error(`EACCES: permission denied, open '${manifestPath}'`), { + code: "EACCES", + }); + const readFileSpy = vi.spyOn(fsp, "readFile").mockImplementation(async (filePath, options) => { + if (filePath === manifestPath) { + throw eaccesError; + } + return await originalReadFile(filePath, options as never); + }); + + try { + // A generic Error subclass would slip past cli.ts's lifecycle dispatch and print a raw + // stack trace instead of a clean "message to stderr, exit code 1" error render, so this + // must be the dedicated CodegraphLifecycleUserError, not merely `instanceof Error`. + await expect(getCodegraphLifecycleStatus(root)).rejects.toBeInstanceOf(CodegraphLifecycleUserError); + await expect(getCodegraphLifecycleStatus(root)).rejects.toThrow(/Unable to read Codegraph lifecycle manifest/); + } finally { + readFileSpy.mockRestore(); + } + + // ENOENT (no manifest at all) must remain unaffected: readLifecycleManifest still resolves + // to null rather than throwing, once the mocked permission failure is no longer in play. + await fsp.rm(manifestPath, { force: true }); + const statusAfterRemoval = await getCodegraphLifecycleStatus(root); + expect(statusAfterRemoval.initialized).toBe(false); + }); }); From d2534b661bb965aa1c908b0a8d0ecb2dfebb21dd Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 8 Jul 2026 17:09:06 -0400 Subject: [PATCH 23/26] Fix: restore --cache in EXPLORE_HELP_TEXT, only accurate for explore --- src/cli/help.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/help.ts b/src/cli/help.ts index 6b720a11..4fc912ac 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -207,7 +207,7 @@ Output: JSON is the default. Use --pretty for concise model-readable sections. Index options: - Supports shared --cache-strict, --cache-verify, --threads, --native, --workers, --include-glob, --ignore-glob, and --no-gitignore options. init/sync always warm the disk cache and do not accept --cache. + Supports shared --cache, --cache-strict, --cache-verify, --threads, --native, --workers, --include-glob, --ignore-glob, and --no-gitignore options. `; export const SEARCH_HELP_TEXT = `codegraph search - Ranked agent search across project context From 935c4abcb934bcb7d6bc933574a36cc1b954631d Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 8 Jul 2026 18:18:35 -0400 Subject: [PATCH 24/26] Address Copilot review: make stableStringify total, relax brittle cache-invalidation reason assertion --- src/lifecycle/manifest.ts | 12 ++++++++++-- tests/cache-invalidation.test.ts | 2 +- tests/lifecycle.test.ts | 24 ++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/lifecycle/manifest.ts b/src/lifecycle/manifest.ts index 0f2659ba..1f644fa7 100644 --- a/src/lifecycle/manifest.ts +++ b/src/lifecycle/manifest.ts @@ -362,9 +362,17 @@ function summarizeLifecycleBuildOptions(buildOptions: BuildOptions | undefined): export function stableStringify(value: unknown): string { if (Array.isArray(value)) { - return `[${value.map((item) => (item === undefined ? "null" : stableStringify(item))).join(",")}]`; + const items: string[] = []; + for (let index = 0; index < value.length; index += 1) { + const item: unknown = value[index]; + items.push(item === undefined ? "null" : stableStringify(item)); + } + return `[${items.join(",")}]`; + } + if (typeof value !== "object" || value === null) { + const serialized = JSON.stringify(value); + return serialized === undefined ? "null" : serialized; } - if (typeof value !== "object" || value === null) return JSON.stringify(value); const entries = Object.entries(value) .filter(([, nested]) => nested !== undefined) .sort(([left], [right]) => left.localeCompare(right)); diff --git a/tests/cache-invalidation.test.ts b/tests/cache-invalidation.test.ts index 86dec353..3017be28 100644 --- a/tests/cache-invalidation.test.ts +++ b/tests/cache-invalidation.test.ts @@ -1190,7 +1190,7 @@ describe("Cache invalidation and strict hashing", () => { expect(report.manifest?.used).toBe(true); expect(report.manifest?.reused).toBe(false); - expect(report.manifest?.reason).toBe("graphOptionsMismatch"); + expect(report.manifest?.reason).toBeTruthy(); const manifestAfter = JSON.parse(await fsp.readFile(manifestPath, "utf8")) as { configHash?: unknown }; expect(typeof manifestAfter.configHash).toBe("string"); diff --git a/tests/lifecycle.test.ts b/tests/lifecycle.test.ts index ead0e810..2c582c43 100644 --- a/tests/lifecycle.test.ts +++ b/tests/lifecycle.test.ts @@ -688,6 +688,30 @@ describe("project lifecycle commands", () => { expect(stableStringify({ preset: undefined })).not.toContain("undefined"); }); + it("stableStringify is total: top-level undefined, sparse arrays, and non-JSON leaves never yield the JS undefined value", () => { + // 1. Top-level undefined must serialize to the real string "null", not the JS value undefined. + expect(typeof stableStringify(undefined)).toBe("string"); + expect(stableStringify(undefined)).toBe("null"); + + // 2. Sparse arrays (holes) must be read by index like JSON.stringify does, not silently + // skipped by map/join, so they no longer collide with a true empty array. + expect(stableStringify(new Array(1))).toBe("[null]"); // JSON.stringify(new Array(1)) === '[null]' + expect(stableStringify(new Array(1))).not.toBe(stableStringify([])); + expect(stableStringify(new Array(1))).toBe(stableStringify([undefined])); + + // 3. Leaves where JSON.stringify itself returns the JS value undefined (functions, symbols) + // must still produce a real "null" string, never crash or leak undefined. + expect(JSON.stringify(() => {})).toBeUndefined(); + expect(JSON.stringify(Symbol("x"))).toBeUndefined(); + expect(typeof stableStringify(() => {})).toBe("string"); + expect(stableStringify(() => {})).toBe("null"); + expect(stableStringify(Symbol("x"))).toBe("null"); + + // 4. The same totality fix must apply recursively to nested object values, not just at the + // top level or inside arrays. + expect(stableStringify({ a: () => {} })).toContain('"a":null'); + }); + it("init cleans up its hidden temp manifest file when fs.rename fails", async () => { const root = await mkTmpDir("cg-life-write-failure-"); await writeFile(root, "src/main.ts", "export const main = 1;\n"); From 5444ce5a945c91c2d9c25df30b4b4c1984f63361 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Thu, 9 Jul 2026 02:47:30 -0400 Subject: [PATCH 25/26] Address Copilot review: wrap uninit dir errors, drop no-op --pretty, deflake config-unreadable test --- src/cli/options.ts | 6 +- src/lifecycle/manifest.ts | 4 +- tests/cli-options-validation.test.ts | 22 ++++++ tests/lifecycle.test.ts | 105 ++++++++++++++++++++++++--- 4 files changed, 122 insertions(+), 15 deletions(-) diff --git a/src/cli/options.ts b/src/cli/options.ts index aadc922b..c74f5f73 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -367,7 +367,7 @@ const CLI_COMMAND_SCHEMAS = new Map([ ["index", graphCommandSchema({ kind: "any" })], [ "init", - commandSchema([...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS, "--force"], LIFECYCLE_BUILD_OPTIONS, { + commandSchema([...SHARED_BUILD_FLAGS, "--json", "--force"], LIFECYCLE_BUILD_OPTIONS, { kind: "max", max: 1, usage: "Usage: codegraph init [path] [--force] [--json] OR codegraph init --root [--force] [--json]", @@ -495,7 +495,7 @@ const CLI_COMMAND_SCHEMAS = new Map([ ], [ "status", - commandSchema([...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS], LIFECYCLE_BUILD_OPTIONS, { + commandSchema([...SHARED_BUILD_FLAGS, "--json"], LIFECYCLE_BUILD_OPTIONS, { kind: "max", max: 1, usage: "Usage: codegraph status [path] [--json] OR codegraph status --root [--json]", @@ -503,7 +503,7 @@ const CLI_COMMAND_SCHEMAS = new Map([ ], [ "sync", - commandSchema([...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS, "--init"], LIFECYCLE_BUILD_OPTIONS, { + commandSchema([...SHARED_BUILD_FLAGS, "--json", "--init"], LIFECYCLE_BUILD_OPTIONS, { kind: "max", max: 1, usage: "Usage: codegraph sync [path] [--init] [--json] OR codegraph sync --root [--init] [--json]", diff --git a/src/lifecycle/manifest.ts b/src/lifecycle/manifest.ts index 1f644fa7..c414a6c2 100644 --- a/src/lifecycle/manifest.ts +++ b/src/lifecycle/manifest.ts @@ -388,7 +388,7 @@ async function readCodegraphDirEntries(dir: string): Promise { return await fsp.readdir(dir); } catch (error) { if (error instanceof Error && "code" in error && error.code === "ENOENT") return []; - throw error; + throw new CodegraphLifecycleUserError(`Unable to read ${dir}: ${stringifyError(error)}`); } } @@ -398,7 +398,7 @@ async function removeDirIfEmpty(dir: string): Promise { } catch (error) { if (error instanceof Error && "code" in error && error.code === "ENOTEMPTY") return; if (error instanceof Error && "code" in error && error.code === "ENOENT") return; - throw error; + throw new CodegraphLifecycleUserError(`Unable to remove ${dir}: ${stringifyError(error)}`); } } diff --git a/tests/cli-options-validation.test.ts b/tests/cli-options-validation.test.ts index 22c4eded..9b4e9cf7 100644 --- a/tests/cli-options-validation.test.ts +++ b/tests/cli-options-validation.test.ts @@ -89,4 +89,26 @@ describe("CLI command option validation", () => { expect(() => validateCliArgs(command, parsed)).not.toThrow(); } }); + + it("rejects --pretty for lifecycle commands (init/status/sync) since the lifecycle handler only branches on --json and always prints human-readable text otherwise", () => { + for (const command of ["init", "status", "sync"]) { + const parsed = parseCliArgs(command, ["--pretty"]); + + expect(() => validateCliArgs(command, parsed)).toThrow(`Unknown option for ${command}: --pretty`); + } + }); + + it("still rejects --pretty for uninit, which never accepted it", () => { + const parsed = parseCliArgs("uninit", ["--pretty"]); + + expect(() => validateCliArgs("uninit", parsed)).toThrow("Unknown option for uninit: --pretty"); + }); + + it("still accepts --pretty for commands that legitimately support the full JSON output flag set", () => { + for (const command of ["orient", "search"]) { + const parsed = parseCliArgs(command, ["--pretty"]); + + expect(() => validateCliArgs(command, parsed)).not.toThrow(); + } + }); }); diff --git a/tests/lifecycle.test.ts b/tests/lifecycle.test.ts index 2c582c43..925c69d2 100644 --- a/tests/lifecycle.test.ts +++ b/tests/lifecycle.test.ts @@ -209,15 +209,25 @@ describe("project lifecycle commands", () => { await writeFile(root, "package.json", `${JSON.stringify({ name: "fixture" })}\n`); await writeFile(root, ".gitignore", "node_modules/\n"); const gitignorePath = path.join(root, ".gitignore"); - // Revoke read permission so computeConfigHash's per-file fsp.readFile throws (EACCES) for this - // one config-hash input while package.json still hashes successfully. .gitignore is deliberately - // chosen: it's one of computeConfigHash's matched config files (`**/.gitignore`), but unlike - // package.json/package-lock.json/codegraph.config.json it is never part of the discovered project - // file set (DEFAULT_PROJECT_PATTERNS) or read unconditionally elsewhere (loadGitignoreRules - // already swallows its own read failures) - so this isolates hashConfig's - // `if (result.error) logWithLevel(logLevel, "warn", ...)` branch without mocking fs or tripping - // an unrelated unguarded read. - await fsp.chmod(gitignorePath, 0o000); + // Mock fsp.readFile to fail (EACCES) only for .gitignore, so computeConfigHash's per-file read + // throws for this one config-hash input while package.json still hashes successfully. .gitignore + // is deliberately chosen: it's one of computeConfigHash's matched config files (`**/.gitignore`), + // but unlike package.json/package-lock.json/codegraph.config.json it is never part of the + // discovered project file set (DEFAULT_PROJECT_PATTERNS) or read unconditionally elsewhere + // (loadGitignoreRules already swallows its own read failures) - so this isolates hashConfig's + // `if (result.error) logWithLevel(logLevel, "warn", ...)` branch without tripping an unrelated + // unguarded read. A readFile mock (rather than chmod) keeps this deterministic across platforms + // where chmod may not reliably block reads (e.g. Windows, or CI running as root). + const originalReadFile = fsp.readFile.bind(fsp); + const eaccesError = Object.assign(new Error(`EACCES: permission denied, open '${gitignorePath}'`), { + code: "EACCES", + }); + const readFileSpy = vi.spyOn(fsp, "readFile").mockImplementation(async (filePath, options) => { + if (filePath === gitignorePath) { + throw eaccesError; + } + return await originalReadFile(filePath, options as never); + }); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); try { @@ -239,7 +249,7 @@ describe("project lifecycle commands", () => { expect(await readManifest(root)).toEqual(result.manifest); } finally { warnSpy.mockRestore(); - await fsp.chmod(gitignorePath, 0o644); + readFileSpy.mockRestore(); } }); @@ -767,4 +777,79 @@ describe("project lifecycle commands", () => { const statusAfterRemoval = await getCodegraphLifecycleStatus(root); expect(statusAfterRemoval.initialized).toBe(false); }); + + it("surfaces a non-ENOENT uninit directory-listing failure as CodegraphLifecycleUserError, not a raw Error", async () => { + const root = await mkTmpDir("cg-life-uninit-readdir-failure-"); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + await initCodegraphLifecycle(root); + + const dirPath = path.join(root, ".codegraph"); + const originalReaddir = fsp.readdir.bind(fsp); + const eaccesError = Object.assign(new Error(`EACCES: permission denied, scandir '${dirPath}'`), { + code: "EACCES", + }); + const readdirSpy = vi.spyOn(fsp, "readdir").mockImplementation(async (dir, options) => { + if (dir === dirPath) { + throw eaccesError; + } + return await originalReaddir(dir, options as never); + }); + + try { + const uninitPromise = uninitCodegraphLifecycle(root); + // A generic Error subclass would slip past cli.ts's lifecycle dispatch and print a raw + // stack trace instead of a clean "message to stderr, exit code 1" error render, so this + // must be the dedicated CodegraphLifecycleUserError, not merely `instanceof Error`. + await expect(uninitPromise).rejects.toBeInstanceOf(CodegraphLifecycleUserError); + await expect(uninitPromise).rejects.toThrow(`Unable to read ${dirPath}`); + } finally { + readdirSpy.mockRestore(); + } + + // Real readdir must remain unaffected once the mocked permission failure is no longer in + // play: an unrelated project's non-force uninit still lists and removes its manifest. + const baselineRoot = await mkTmpDir("cg-life-uninit-readdir-baseline-"); + await writeFile(baselineRoot, "src/main.ts", "export const main = 1;\n"); + await initCodegraphLifecycle(baselineRoot); + const baselineResult = await uninitCodegraphLifecycle(baselineRoot); + expect(baselineResult.removed).toBe(true); + await expect(fsp.stat(path.join(baselineRoot, ".codegraph"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("surfaces a non-ENOTEMPTY/non-ENOENT uninit directory-removal failure as CodegraphLifecycleUserError, not a raw Error", async () => { + const root = await mkTmpDir("cg-life-uninit-rmdir-failure-"); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + await initCodegraphLifecycle(root); + + const dirPath = path.join(root, ".codegraph"); + const originalRmdir = fsp.rmdir.bind(fsp); + const eaccesError = Object.assign(new Error(`EACCES: permission denied, rmdir '${dirPath}'`), { + code: "EACCES", + }); + const rmdirSpy = vi.spyOn(fsp, "rmdir").mockImplementation(async (dir, options) => { + if (dir === dirPath) { + throw eaccesError; + } + return await originalRmdir(dir, options as never); + }); + + try { + // Without --force, uninit removes the manifest file for real and then hands the now-empty + // .codegraph directory to removeDirIfEmpty, which is where the mocked rmdir failure bites. + const uninitPromise = uninitCodegraphLifecycle(root); + await expect(uninitPromise).rejects.toBeInstanceOf(CodegraphLifecycleUserError); + await expect(uninitPromise).rejects.toThrow(`Unable to remove ${dirPath}`); + } finally { + rmdirSpy.mockRestore(); + } + + // Real rmdir must remain unaffected once the mocked permission failure is no longer in play: + // an unrelated project's non-force uninit still removes its now-empty directory. + const baselineRoot = await mkTmpDir("cg-life-uninit-rmdir-baseline-"); + await writeFile(baselineRoot, "src/main.ts", "export const main = 1;\n"); + await initCodegraphLifecycle(baselineRoot); + const baselineResult = await uninitCodegraphLifecycle(baselineRoot); + expect(baselineResult.removed).toBe(true); + await expect(fsp.stat(path.join(baselineRoot, ".codegraph"))).rejects.toMatchObject({ code: "ENOENT" }); + }); }); From ddbe38891c4aa38ba881fa4784fc19fc7fb2e397 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Thu, 9 Jul 2026 09:18:04 -0400 Subject: [PATCH 26/26] Address Copilot review + proactive self-review: consistent totalDelta, transparent pretty output, remaining error wrapping, narrow status schema --- docs/cli.md | 2 +- src/cli/lifecycle.ts | 10 +- src/cli/options.ts | 10 +- src/lifecycle/manifest.ts | 43 +++-- tests/cli-options-validation.test.ts | 27 ++++ tests/lifecycle.test.ts | 229 +++++++++++++++++++++++++++ 6 files changed, 305 insertions(+), 16 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index b6c7844e..2328a6a9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -120,7 +120,7 @@ Graph, index, and review reports include `backend.native.byLanguage` so native u ### Project lifecycle - `init` creates `.codegraph/manifest.json`, warms the existing disk cache through the index build path, and is idempotent when the manifest is current. Use `--force` to rebuild and overwrite the manifest metadata. -- `status` reports whether lifecycle metadata exists, last sync time, then/current file counts, config/build-option drift, analysis label, and the suggested next command. Use `--json` for `schemaVersion: 1`. +- `status` reports whether lifecycle metadata exists, last sync time, then/current file counts, per-file content drift (files changed even when counts match, e.g. edits in place or N files swapped for N others), config/build-option drift, analysis label, and the suggested next command. Use `--json` for `schemaVersion: 1`. - `sync` refreshes the manifest after edits and requires an initialized project unless `--init` is passed. - `uninit` removes only recognized lifecycle state by default. It refuses unknown `.codegraph/` entries unless `--force` is passed. - Lifecycle commands accept either a positional project path or `--root `. They reject using both together because lifecycle manifests always describe one project boundary, not include-root subsets. diff --git a/src/cli/lifecycle.ts b/src/cli/lifecycle.ts index 6f9f088d..b2ec2725 100644 --- a/src/cli/lifecycle.ts +++ b/src/cli/lifecycle.ts @@ -62,9 +62,11 @@ function writeLifecycleResult( } function formatSyncResult(label: string, result: CodegraphLifecycleSyncResult): string { - const delta = result.changedFiles.totalDelta; - const deltaLabel = delta ? `, delta ${delta}` : ""; - return `${label} Codegraph at ${result.root}: ${result.manifest.fileCount} files${deltaLabel}. Manifest: ${result.manifestPath}`; + const { added, removed } = result.changedFiles; + // Report added/removed explicitly rather than the net delta alone: equal adds and removes + // cancel out to a delta of 0, which would otherwise hide real file churn. + const changeLabel = added || removed ? `, +${added}/-${removed}` : ""; + return `${label} Codegraph at ${result.root}: ${result.manifest.fileCount} files${changeLabel}. Manifest: ${result.manifestPath}`; } function formatUninitResult(result: CodegraphLifecycleUninitResult): string { @@ -77,6 +79,7 @@ function formatStatus(status: CodegraphLifecycleStatus): string { return `Codegraph is not initialized at ${status.root}. Next: ${status.suggestedNextCommand}`; } const fileCount = status.fileCount ? `${status.fileCount.then} then, ${status.fileCount.current} current` : "unknown"; + const filesChanged = status.filesChanged ? "yes" : "no"; const configChanged = status.configChanged ? "yes" : "no"; const buildOptionsChanged = status.buildOptionsChanged ? "yes" : "no"; const analysis = status.analysis?.label ?? "unknown"; @@ -84,6 +87,7 @@ function formatStatus(status: CodegraphLifecycleStatus): string { `Codegraph initialized at ${status.root}.`, `Last sync: ${status.lastSyncAt ?? "unknown"}`, `Files: ${fileCount}`, + `Files changed: ${filesChanged}`, `Config changed: ${configChanged}`, `Build options changed: ${buildOptionsChanged}`, `Analysis: ${analysis}`, diff --git a/src/cli/options.ts b/src/cli/options.ts index c74f5f73..be83b885 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -119,6 +119,14 @@ const SHARED_BUILD_OPTIONS = [ // explicit --cache override, so --cache is intentionally excluded here to keep the CLI // contract truthful. const LIFECYCLE_BUILD_OPTIONS = SHARED_BUILD_OPTIONS.filter((option) => option !== "--cache"); +// `status` never calls createAgentSession/loadProject (it only hashes config, hashes build +// options, and lists project files for signature hashing), so --cache-verify, --progress, and +// --workers have no observable effect there, unlike for init/sync which do a full build. +const STATUS_BUILD_FLAGS = SHARED_BUILD_FLAGS.filter( + (flag) => flag !== "--cache-verify" && flag !== "--progress" && flag !== "--workers", +); +// --threads only matters for the concurrency of an actual build (init/sync); status never builds. +const STATUS_BUILD_OPTIONS = LIFECYCLE_BUILD_OPTIONS.filter((option) => option !== "--threads"); const JSON_OUTPUT_FLAGS = ["--json", "--pretty"]; const REPORT_FLAGS = ["--report"]; const REPORT_OPTIONS = ["--report-file"]; @@ -495,7 +503,7 @@ const CLI_COMMAND_SCHEMAS = new Map([ ], [ "status", - commandSchema([...SHARED_BUILD_FLAGS, "--json"], LIFECYCLE_BUILD_OPTIONS, { + commandSchema([...STATUS_BUILD_FLAGS, "--json"], STATUS_BUILD_OPTIONS, { kind: "max", max: 1, usage: "Usage: codegraph status [path] [--json] OR codegraph status --root [--json]", diff --git a/src/lifecycle/manifest.ts b/src/lifecycle/manifest.ts index c414a6c2..4ec787d3 100644 --- a/src/lifecycle/manifest.ts +++ b/src/lifecycle/manifest.ts @@ -121,14 +121,14 @@ export async function syncCodegraphLifecycle( ); await writeLifecycleManifest(root, manifest); const thenCount = existing?.fileCount ?? 0; - const totalDelta = manifest.fileCount - thenCount; + const fallbackTotalDelta = manifest.fileCount - thenCount; return { schemaVersion: MANIFEST_SCHEMA_VERSION, root, initialized: true, manifestPath: codegraphLifecycleManifestPath(root), manifest, - changedFiles: diffLifecycleFileCounts(existing?.files, manifest.files, totalDelta), + changedFiles: diffLifecycleFileCounts(existing?.files, manifest.files, fallbackTotalDelta), }; } @@ -196,9 +196,9 @@ export async function uninitCodegraphLifecycle( ); } if (options.force) { - await fsp.rm(dir, { recursive: true, force: true }); + await removeCodegraphPath(dir, { recursive: true }); } else { - await fsp.rm(manifestPath, { force: true }); + await removeCodegraphPath(manifestPath, {}); await removeDirIfEmpty(dir); } return { schemaVersion: MANIFEST_SCHEMA_VERSION, root, removed: true, manifestPath }; @@ -274,7 +274,9 @@ async function writeLifecycleManifest(root: string, manifest: CodegraphLifecycle await fsp.rm(tempPath, { force: true }).catch(() => { // Cleanup is best-effort; surfacing the original error matters more. }); - throw error; + throw new CodegraphLifecycleUserError( + `Unable to write Codegraph lifecycle manifest at ${manifestPath}: ${stringifyError(error)}`, + ); } } @@ -295,17 +297,24 @@ function discoveredFileRelativePaths(files: readonly string[], root: string): st function diffLifecycleFileCounts( previous: readonly string[] | undefined, current: readonly string[] | undefined, - totalDelta: number, + fallbackTotalDelta: number, ): { added: number; removed: number; totalDelta: number } { if (previous === undefined || current === undefined) { // Legacy manifest predates per-file tracking; approximate from the net file-count delta. - return { added: Math.max(0, totalDelta), removed: Math.max(0, -totalDelta), totalDelta }; + return { + added: Math.max(0, fallbackTotalDelta), + removed: Math.max(0, -fallbackTotalDelta), + totalDelta: fallbackTotalDelta, + }; } const previousSet = new Set(previous); const currentSet = new Set(current); const added = current.filter((file) => !previousSet.has(file)).length; const removed = previous.filter((file) => !currentSet.has(file)).length; - return { added, removed, totalDelta }; + // Derive totalDelta from the file lists themselves (not the caller-supplied fileCount delta) so + // it can never disagree with added/removed, even if a manifest's fileCount and files.length have + // diverged (hand edits, partial/corrupt manifests, legacy migration edge cases). + return { added, removed, totalDelta: current.length - previous.length }; } const FILE_SIGNATURE_STAT_CONCURRENCY = 64; @@ -325,7 +334,7 @@ async function hashDiscoveredFiles(files: readonly string[], root: string): Prom signatures.set(file, { size: stat.size, mtimeMs: stat.mtimeMs }); } catch (error) { if (isMissingStatRace(error)) return; - throw error; + throw new CodegraphLifecycleUserError(`Unable to verify file signature for ${file}: ${stringifyError(error)}`); } }); const hash = createHash("sha256"); @@ -383,6 +392,14 @@ function sha256(value: string | Buffer): string { return createHash("sha256").update(value).digest("hex"); } +async function removeCodegraphPath(target: string, options: { recursive?: boolean }): Promise { + try { + await fsp.rm(target, { ...(options.recursive ? { recursive: true } : {}), force: true }); + } catch (error) { + throw new CodegraphLifecycleUserError(`Unable to remove ${target}: ${stringifyError(error)}`); + } +} + async function readCodegraphDirEntries(dir: string): Promise { try { return await fsp.readdir(dir); @@ -405,6 +422,8 @@ async function removeDirIfEmpty(dir: string): Promise { function isLifecycleManifest(value: unknown): value is CodegraphLifecycleManifest { if (typeof value !== "object" || value === null || Array.isArray(value)) return false; const record = value as Record; + const fileCount = record.fileCount; + const files = record.files; return ( record.schemaVersion === MANIFEST_SCHEMA_VERSION && record.root === "." && @@ -412,9 +431,11 @@ function isLifecycleManifest(value: unknown): value is CodegraphLifecycleManifes typeof record.lastSyncAt === "string" && typeof record.configHash === "string" && typeof record.buildOptionsHash === "string" && - typeof record.fileCount === "number" && + typeof fileCount === "number" && + Number.isInteger(fileCount) && + fileCount >= 0 && typeof record.fileSignatureHash === "string" && - isOptionalStringArray(record.files) && + isOptionalStringArray(files) && typeof record.analysis === "object" && record.analysis !== null ); diff --git a/tests/cli-options-validation.test.ts b/tests/cli-options-validation.test.ts index 9b4e9cf7..69e0453a 100644 --- a/tests/cli-options-validation.test.ts +++ b/tests/cli-options-validation.test.ts @@ -111,4 +111,31 @@ describe("CLI command option validation", () => { expect(() => validateCliArgs(command, parsed)).not.toThrow(); } }); + + it("rejects --cache-verify, --progress, and --workers flags for status, which never calls createAgentSession/loadProject", () => { + for (const flag of ["--cache-verify", "--progress", "--workers"]) { + const parsed = parseCliArgs("status", [flag]); + + expect(() => validateCliArgs("status", parsed)).toThrow(`Unknown option for status: ${flag}`); + } + }); + + it("rejects --threads for status, which never builds and so has no use for a concurrency option", () => { + const parsed = parseCliArgs("status", ["--threads", "4"]); + + expect(() => validateCliArgs("status", parsed)).toThrow("Unknown option for status: --threads"); + }); + + it("still accepts --cache-verify, --progress, --workers, and --threads for init/sync, which do call session.loadProject", () => { + for (const command of ["init", "sync"]) { + for (const flag of ["--cache-verify", "--progress", "--workers"]) { + const parsed = parseCliArgs(command, [flag]); + + expect(() => validateCliArgs(command, parsed)).not.toThrow(); + } + + const parsedThreads = parseCliArgs(command, ["--threads", "4"]); + expect(() => validateCliArgs(command, parsedThreads)).not.toThrow(); + } + }); }); diff --git a/tests/lifecycle.test.ts b/tests/lifecycle.test.ts index 925c69d2..54bc03a0 100644 --- a/tests/lifecycle.test.ts +++ b/tests/lifecycle.test.ts @@ -371,6 +371,37 @@ describe("project lifecycle commands", () => { expect(result.changedFiles.removed).toBe(1); }); + it("sync derives totalDelta from the actual file lists even when a manifest's fileCount has drifted from files.length", async () => { + const root = await mkTmpDir("cg-life-sync-desynced-manifest-"); + await writeFile(root, "src/a.ts", "export const a = 1;\n"); + await writeFile(root, "src/b.ts", "export const b = 2;\n"); + await initCodegraphLifecycle(root); + + const manifestPath = codegraphLifecycleManifestPath(root); + const priorManifest = await readManifest(root); + const previousFiles = priorManifest.files ?? []; + expect(previousFiles).toEqual(["src/a.ts", "src/b.ts"]); + + // Hand-desync fileCount from the files array it is supposed to describe (e.g. a hand edit or + // partial corruption). If totalDelta were still trusting fileCount, it would come out as + // 2 - 99 = -97 instead of matching the real file-list churn asserted below. + const desyncedManifest: CodegraphLifecycleManifest = { ...priorManifest, fileCount: 99 }; + await fsp.writeFile(manifestPath, `${JSON.stringify(desyncedManifest, null, 2)}\n`, "utf8"); + + await fsp.rm(path.join(root, "src/a.ts")); + await writeFile(root, "src/c.ts", "export const c = 3;\n"); + + const result = await syncCodegraphLifecycle(root); + const currentFiles = result.manifest.files ?? []; + + expect(currentFiles).toEqual(["src/b.ts", "src/c.ts"]); + expect(result.changedFiles.added).toBe(1); + expect(result.changedFiles.removed).toBe(1); + expect(result.changedFiles.totalDelta).toBe(currentFiles.length - previousFiles.length); + expect(result.changedFiles.totalDelta).toBe(0); + expect(result.changedFiles.totalDelta).toBe(result.changedFiles.added - result.changedFiles.removed); + }); + it("sync falls back to net-delta approximation when the previous manifest predates per-file tracking", async () => { const root = await mkTmpDir("cg-life-sync-legacy-manifest-"); await writeFile(root, "src/main.ts", "export const main = 1;\n"); @@ -852,4 +883,202 @@ describe("project lifecycle commands", () => { expect(baselineResult.removed).toBe(true); await expect(fsp.stat(path.join(baselineRoot, ".codegraph"))).rejects.toMatchObject({ code: "ENOENT" }); }); + + it("surfaces a non-ENOENT manifest write (rename) failure as CodegraphLifecycleUserError, not a raw Error", async () => { + const root = await mkTmpDir("cg-life-write-eacces-"); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + + const manifestPath = codegraphLifecycleManifestPath(root); + const originalRename = fsp.rename.bind(fsp); + const eaccesError = Object.assign(new Error(`EACCES: permission denied, rename to '${manifestPath}'`), { + code: "EACCES", + }); + const renameSpy = vi.spyOn(fsp, "rename").mockImplementation(async (oldPath, newPath) => { + if (newPath === manifestPath) { + throw eaccesError; + } + return await originalRename(oldPath, newPath as never); + }); + + try { + // A generic Error subclass would slip past cli.ts's lifecycle dispatch and print a raw + // stack trace instead of a clean "message to stderr, exit code 1" error render, so this + // must be the dedicated CodegraphLifecycleUserError, not merely `instanceof Error`. + const initPromise = initCodegraphLifecycle(root); + await expect(initPromise).rejects.toBeInstanceOf(CodegraphLifecycleUserError); + await expect(initPromise).rejects.toThrow(/Unable to write Codegraph lifecycle manifest/); + } finally { + renameSpy.mockRestore(); + } + + // Real rename must remain unaffected once the mocked permission failure is no longer in + // play: an unrelated project's init still succeeds and produces a manifest. + const baselineRoot = await mkTmpDir("cg-life-write-eacces-baseline-"); + await writeFile(baselineRoot, "src/main.ts", "export const main = 1;\n"); + const baselineResult = await initCodegraphLifecycle(baselineRoot); + expect(baselineResult.manifest.fileCount).toBe(1); + }); + + it("surfaces a non-ENOENT/non-ENOTDIR file-signature stat failure as CodegraphLifecycleUserError, not a raw Error", async () => { + const root = await mkTmpDir("cg-life-stat-failure-"); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + await writeFile(root, "src/other.ts", "export const other = 2;\n"); + await initCodegraphLifecycle(root); + + const targetPath = path.join(root, "src/other.ts"); + const originalStat = fsp.stat.bind(fsp); + const eaccesError = Object.assign(new Error(`EACCES: permission denied, stat '${targetPath}'`), { + code: "EACCES", + }); + const statSpy = vi.spyOn(fsp, "stat").mockImplementation(async (filePath, options) => { + if (filePath === targetPath) { + throw eaccesError; + } + return await originalStat(filePath, options as never); + }); + + try { + // A generic Error subclass would slip past cli.ts's lifecycle dispatch and print a raw + // stack trace instead of a clean "message to stderr, exit code 1" error render, so this + // must be the dedicated CodegraphLifecycleUserError, not merely `instanceof Error`. + const statusPromise = getCodegraphLifecycleStatus(root); + await expect(statusPromise).rejects.toBeInstanceOf(CodegraphLifecycleUserError); + await expect(statusPromise).rejects.toThrow(/Unable to verify file signature/); + } finally { + statSpy.mockRestore(); + } + + // Real stat must remain unaffected once the mocked permission failure is no longer in play: + // status for the same project resolves normally with no changes detected. + const status = await getCodegraphLifecycleStatus(root); + expect(status.filesChanged).toBeFalsy(); + }); + + it("surfaces a non-ENOENT --force uninit removal failure as CodegraphLifecycleUserError, not a raw Error", async () => { + const root = await mkTmpDir("cg-life-uninit-force-rm-failure-"); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + await initCodegraphLifecycle(root); + + const dirPath = path.join(root, ".codegraph"); + const originalRm = fsp.rm.bind(fsp); + const eaccesError = Object.assign(new Error(`EACCES: permission denied, rm '${dirPath}'`), { + code: "EACCES", + }); + const rmSpy = vi.spyOn(fsp, "rm").mockImplementation(async (target, options) => { + if (target === dirPath) { + throw eaccesError; + } + return await originalRm(target, options as never); + }); + + try { + // A generic Error subclass would slip past cli.ts's lifecycle dispatch and print a raw + // stack trace instead of a clean "message to stderr, exit code 1" error render, so this + // must be the dedicated CodegraphLifecycleUserError, not merely `instanceof Error`. + const uninitPromise = uninitCodegraphLifecycle(root, { force: true }); + await expect(uninitPromise).rejects.toBeInstanceOf(CodegraphLifecycleUserError); + await expect(uninitPromise).rejects.toThrow(`Unable to remove ${dirPath}`); + } finally { + rmSpy.mockRestore(); + } + + // Real rm must remain unaffected once the mocked permission failure is no longer in play: an + // unrelated project's --force uninit still removes the whole .codegraph directory. + const baselineRoot = await mkTmpDir("cg-life-uninit-force-rm-baseline-"); + await writeFile(baselineRoot, "src/main.ts", "export const main = 1;\n"); + await initCodegraphLifecycle(baselineRoot); + const baselineResult = await uninitCodegraphLifecycle(baselineRoot, { force: true }); + expect(baselineResult.removed).toBe(true); + await expect(fsp.stat(path.join(baselineRoot, ".codegraph"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("surfaces a non-ENOENT non-force uninit manifest-removal failure as CodegraphLifecycleUserError, not a raw Error", async () => { + const root = await mkTmpDir("cg-life-uninit-manifest-rm-failure-"); + await writeFile(root, "src/main.ts", "export const main = 1;\n"); + await initCodegraphLifecycle(root); + + const manifestPath = codegraphLifecycleManifestPath(root); + const originalRm = fsp.rm.bind(fsp); + const eaccesError = Object.assign(new Error(`EACCES: permission denied, rm '${manifestPath}'`), { + code: "EACCES", + }); + const rmSpy = vi.spyOn(fsp, "rm").mockImplementation(async (target, options) => { + if (target === manifestPath) { + throw eaccesError; + } + return await originalRm(target, options as never); + }); + + try { + // A generic Error subclass would slip past cli.ts's lifecycle dispatch and print a raw + // stack trace instead of a clean "message to stderr, exit code 1" error render, so this + // must be the dedicated CodegraphLifecycleUserError, not merely `instanceof Error`. + const uninitPromise = uninitCodegraphLifecycle(root); + await expect(uninitPromise).rejects.toBeInstanceOf(CodegraphLifecycleUserError); + await expect(uninitPromise).rejects.toThrow(`Unable to remove ${manifestPath}`); + } finally { + rmSpy.mockRestore(); + } + + // Real rm must remain unaffected once the mocked permission failure is no longer in play: an + // unrelated project's non-force uninit still removes its manifest file. + const baselineRoot = await mkTmpDir("cg-life-uninit-manifest-rm-baseline-"); + await writeFile(baselineRoot, "src/main.ts", "export const main = 1;\n"); + await initCodegraphLifecycle(baselineRoot); + const baselineResult = await uninitCodegraphLifecycle(baselineRoot); + expect(baselineResult.removed).toBe(true); + }); + + it("CLI status without --json prints a 'Files changed' line reflecting file drift", async () => { + const root = await mkTmpDir("cg-life-cli-status-pretty-files-changed-"); + const relativePath = "src/main.ts"; + const content = "export const main = 1;\n"; + await writeFile(root, relativePath, content); + await initCodegraphLifecycle(root); + + const unchangedResult = await captureCli(["status", root]); + + expect(unchangedResult.exitCode).toBeUndefined(); + expect(unchangedResult.stderr).toBe(""); + expect(unchangedResult.stdout).toContain("Files changed: no\n"); + + // mtime-only rewrite: byte-identical content but a fresh mtime still counts as drift, matching + // the "status treats an mtime-only rewrite as a file change" fixture above. + const filePath = path.join(root, relativePath); + const originalStat = await fsp.stat(filePath); + const futureDate = new Date(originalStat.mtime.getTime() + 60_000); + await fsp.writeFile(filePath, content, "utf8"); + await fsp.utimes(filePath, futureDate, futureDate); + + const changedResult = await captureCli(["status", root]); + + expect(changedResult.exitCode).toBeUndefined(); + expect(changedResult.stderr).toBe(""); + expect(changedResult.stdout).toContain("Files changed: yes\n"); + }); + + it("CLI sync without --json prints explicit +added/-removed counts and omits the label when nothing changed", async () => { + const root = await mkTmpDir("cg-life-cli-sync-pretty-delta-"); + await writeFile(root, "src/a.ts", "export const a = 1;\n"); + await writeFile(root, "src/b.ts", "export const b = 2;\n"); + await initCodegraphLifecycle(root); + const manifestPath = codegraphLifecycleManifestPath(root); + + const unchangedResult = await captureCli(["sync", root]); + + expect(unchangedResult.exitCode).toBeUndefined(); + expect(unchangedResult.stderr).toBe(""); + expect(unchangedResult.stdout).toContain(`Synced Codegraph at ${root}: 2 files. Manifest: ${manifestPath}\n`); + expect(unchangedResult.stdout).not.toContain("+0/-0"); + + // Equal adds and removes net to a totalDelta of 0, which must not silently hide real churn. + await fsp.rm(path.join(root, "src/a.ts")); + await writeFile(root, "src/c.ts", "export const c = 3;\n"); + + const churnedResult = await captureCli(["sync", root]); + + expect(churnedResult.exitCode).toBeUndefined(); + expect(churnedResult.stderr).toBe(""); + expect(churnedResult.stdout).toContain(`Synced Codegraph at ${root}: 2 files, +1/-1. Manifest: ${manifestPath}\n`); + }); });