diff --git a/README.md b/README.md index 337ee94f..14a747b8 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 b4b06d6c..c5fd1af8 100644 --- a/codegraph-skill/codegraph/SKILL.md +++ b/codegraph-skill/codegraph/SKILL.md @@ -43,10 +43,13 @@ 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 with MCP entries, bundled skill payloads, and marker files. Use `--dry-run` or `--print-config ` first; uninstall removes only Codegraph-owned marker blocks, marker files, exact bundled skill payloads, or exact installer-owned MCP entries. +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 04a4151a..2328a6a9 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,14 @@ 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, 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. + ### Symbols, navigation, grep, and chunking ```bash diff --git a/src/cli.ts b/src/cli.ts index 3ed0a4ff..f1623679 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -38,6 +38,8 @@ 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 { CodegraphLifecycleUserError } from "./lifecycle/manifest.js"; import { handleIndexCommand } from "./cli/index.js"; import { handleHotspotsCommand, handleInspectCommand } from "./cli/inspect.js"; import { handleOrientCommand } from "./cli/orient.js"; @@ -80,6 +82,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; @@ -213,6 +219,25 @@ 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 && + 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" || @@ -221,7 +246,8 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { cmd === "hotspots" || cmd === "inspect" || cmd === "duplicates" || - cmd === "impact") && + cmd === "impact" || + isLifecycleCommand(cmd)) && !rootOpt && firstPositionalRoot !== undefined && isExistingDirectory(firstPositionalRoot) @@ -538,6 +564,25 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { return await resolveFilesFromRoots(); }; + if (isLifecycleCommand(cmd)) { + try { + await handleLifecycleCommand({ + command: cmd, + root: projectRootFs, + buildOptions: buildAgentOptions(), + hasFlag, + writeJSONLine, + writeStdoutLine, + }); + } catch (error) { + if (error instanceof CodegraphLifecycleUserError) { + writeStderrLine(error.message); + exitCli(1); + } + throw error; + } + return; + } if (cmd === "explore") { await handleExploreCommand({ positionals: parsed.positionals, diff --git a/src/cli/help.ts b/src/cli/help.ts index 233f0613..4fc912ac 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,23 @@ export function isKnownCliCommand(command: string): boolean { return knownCliCommands.has(command); } +export const LIFECYCLE_HELP_TEXT = `codegraph init/status/sync/uninit - Initialize, inspect, refresh, or remove project-local Codegraph state + +Usage: + 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 Usage: codegraph install [target] [--target ] [--yes | --dry-run] [--print-config ] [--detect] @@ -389,6 +418,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..b2ec2725 --- /dev/null +++ b/src/cli/lifecycle.ts @@ -0,0 +1,96 @@ +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; + 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"), + }); + 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 { 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 { + 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 filesChanged = status.filesChanged ? "yes" : "no"; + 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}`, + `Files changed: ${filesChanged}`, + `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 66de7001..be83b885 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -115,6 +115,18 @@ 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"); +// `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"]; @@ -361,6 +373,14 @@ const CLI_COMMAND_SCHEMAS = new Map([ ), ], ["index", graphCommandSchema({ kind: "any" })], + [ + "init", + 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]", + }), + ], [ "install", commandSchema(["--detect", "--dry-run", "--yes"], ["--print-config", "--target"], { @@ -481,6 +501,22 @@ const CLI_COMMAND_SCHEMAS = new Map([ usage: "Usage: codegraph skill [--agent | --target ] [--force]", }), ], + [ + "status", + commandSchema([...STATUS_BUILD_FLAGS, "--json"], STATUS_BUILD_OPTIONS, { + kind: "max", + max: 1, + usage: "Usage: codegraph status [path] [--json] OR codegraph status --root [--json]", + }), + ], + [ + "sync", + 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]", + }), + ], [ "sql", commandSchema(["--json"], ["--db", "--query", "--sqlite"], { @@ -488,6 +524,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] [--force] [--json] OR codegraph uninit --root [--force] [--json]", + }), + ], [ "unresolved", commandSchema([...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS, "--verbose"], SHARED_BUILD_OPTIONS, { 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 new file mode 100644 index 00000000..4ec787d3 --- /dev/null +++ b/src/lifecycle/manifest.ts @@ -0,0 +1,447 @@ +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, + type ManifestBuildOptions, +} from "../indexer/build-cache/options.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; + fileSignatureHash: string; + /** Sorted, root-relative file paths as of the last sync. Absent on manifests written before this field existed. */ + files?: string[]; + analysis: AnalysisSummary; +}; + +export type CodegraphLifecycleStatus = { + schemaVersion: 1; + root: string; + initialized: boolean; + manifestPath: string; + lastSyncAt?: string; + fileCount?: { + then: number; + current: number; + }; + configChanged: boolean; + buildOptionsChanged: boolean; + filesChanged: 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"; + +type LifecycleBuildOptionsSummary = ManifestBuildOptions & { + graph: ReturnType; + native: BuildOptions["native"]; +}; +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); +} + +export async function initCodegraphLifecycle( + root: string, + options: { buildOptions?: BuildOptions; force?: boolean } = {}, +): Promise { + 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.filesChanged) { + 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, { allowInvalid: Boolean(options.init && options.force) }); + if (!existing && !options.init) { + throw new CodegraphLifecycleUserError( + "Codegraph is not initialized for this project. Run codegraph init or codegraph sync --init.", + ); + } + const manifest = await buildLifecycleManifest( + root, + options.buildOptions, + existing, + options.force ? { force: true } : {}, + ); + await writeLifecycleManifest(root, manifest); + const thenCount = existing?.fileCount ?? 0; + const fallbackTotalDelta = manifest.fileCount - thenCount; + return { + schemaVersion: MANIFEST_SCHEMA_VERSION, + root, + initialized: true, + manifestPath: codegraphLifecycleManifestPath(root), + manifest, + changedFiles: diffLifecycleFileCounts(existing?.files, manifest.files, fallbackTotalDelta), + }; +} + +export async function getCodegraphLifecycleStatus( + root: string, + options: { buildOptions?: BuildOptions } = {}, +): Promise { + const manifestPath = codegraphLifecycleManifestPath(root); + const manifest = await readLifecycleManifest(root); + if (!manifest) { + return { + schemaVersion: MANIFEST_SCHEMA_VERSION, + root, + initialized: false, + manifestPath, + configChanged: false, + buildOptionsChanged: false, + filesChanged: false, + 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; + const filesChanged = manifest.fileSignatureHash !== fileSignatureHash; + const changed = configChanged || buildOptionsChanged || filesChanged; + return { + schemaVersion: MANIFEST_SCHEMA_VERSION, + root, + initialized: true, + manifestPath, + lastSyncAt: manifest.lastSyncAt, + fileCount: { + then: manifest.fileCount, + current: files.length, + }, + configChanged, + buildOptionsChanged, + filesChanged, + 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 CodegraphLifecycleUserError( + `Refusing to remove .codegraph with unknown entries: ${unknownEntries.join(", ")}. Use --force to remove them.`, + ); + } + if (options.force) { + await removeCodegraphPath(dir, { recursive: true }); + } else { + await removeCodegraphPath(manifestPath, {}); + await removeDirIfEmpty(dir); + } + return { schemaVersion: MANIFEST_SCHEMA_VERSION, root, removed: true, manifestPath }; +} + +async function buildLifecycleManifest( + root: string, + buildOptions: BuildOptions | undefined, + existing: CodegraphLifecycleManifest | null, + options: { force?: boolean } = {}, +): Promise { + const now = new Date().toISOString(); + 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, + root: ".", + createdAt: existing?.createdAt ?? now, + lastSyncAt: now, + configHash: await hashConfig(root, buildOptions?.logLevel), + buildOptionsHash: hashBuildOptions(buildOptions), + fileCount: snapshot.files.length, + fileSignatureHash: await hashDiscoveredFiles(snapshot.files, root), + files: discoveredFileRelativePaths(snapshot.files, root), + analysis: snapshot.analysis, + }; +} + +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 CodegraphLifecycleUserError( + `Unable to read Codegraph lifecycle manifest at ${manifestPath}: ${stringifyError(error)}`, + ); + } + try { + const parsed: unknown = JSON.parse(raw); + if (isLifecycleManifest(parsed)) return parsed; + throw new Error("Invalid Codegraph lifecycle manifest schema."); + } catch (error) { + if (options.allowInvalid) return null; + throw new CodegraphLifecycleUserError( + `Unable to read Codegraph lifecycle manifest at ${manifestPath}: ${stringifyError(error)}`, + ); + } +} + +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 = 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 new CodegraphLifecycleUserError( + `Unable to write Codegraph lifecycle manifest at ${manifestPath}: ${stringifyError(error)}`, + ); + } +} + +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; +} + +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, + 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, 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; + // 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; + +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 new CodegraphLifecycleUserError(`Unable to verify file signature for ${file}: ${stringifyError(error)}`); + } + }); + const hash = createHash("sha256"); + 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(String(signature.size)); + hash.update("\0"); + hash.update(String(signature.mtimeMs)); + 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 { + return sha256(stableStringify(summarizeLifecycleBuildOptions(buildOptions))); +} + +function summarizeLifecycleBuildOptions(buildOptions: BuildOptions | undefined): LifecycleBuildOptionsSummary { + return { + ...summarizeBuildOptions(buildOptions), + graph: normalizeGraphOptions(buildOptions?.graph), + native: buildOptions?.native ?? "auto", + }; +} + +export function stableStringify(value: unknown): string { + if (Array.isArray(value)) { + 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; + } + 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(",")}}`; +} + +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); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return []; + throw new CodegraphLifecycleUserError(`Unable to read ${dir}: ${stringifyError(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 new CodegraphLifecycleUserError(`Unable to remove ${dir}: ${stringifyError(error)}`); + } +} + +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 === "." && + typeof record.createdAt === "string" && + typeof record.lastSyncAt === "string" && + typeof record.configHash === "string" && + typeof record.buildOptionsHash === "string" && + typeof fileCount === "number" && + Number.isInteger(fileCount) && + fileCount >= 0 && + typeof record.fileSignatureHash === "string" && + isOptionalStringArray(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/mcp/server.ts b/src/mcp/server.ts index 82f03cec..6efe32c6 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -530,7 +530,6 @@ function createCodegraphMcpHandlersForSession( const explanation = await explainCodegraphTargetWithSession(session, { root, target: request.handle }); return explanation.target; }), - goto: async (request) => await withFreshness(async () => { const snapshot = await session.loadProject({ symbolGraph: "skip" }); diff --git a/tests/cache-invalidation.test.ts b/tests/cache-invalidation.test.ts index 5993d5a0..3017be28 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).toBeTruthy(); + + 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"); diff --git a/tests/cli-options-validation.test.ts b/tests/cli-options-validation.test.ts index 3fff9eab..69e0453a 100644 --- a/tests/cli-options-validation.test.ts +++ b/tests/cli-options-validation.test.ts @@ -67,4 +67,75 @@ 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(); + } + }); + + 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(); + } + }); + + 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 new file mode 100644 index 00000000..54bc03a0 --- /dev/null +++ b/tests/lifecycle.test.ts @@ -0,0 +1,1084 @@ +import fsp from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + codegraphLifecycleManifestPath, + CodegraphLifecycleUserError, + getCodegraphLifecycleStatus, + initCodegraphLifecycle, + stableStringify, + syncCodegraphLifecycle, + uninitCodegraphLifecycle, + type CodegraphLifecycleManifest, + type CodegraphLifecycleStatus, + 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"; +import { CODEGRAPH_CONFIG_FILE } from "../src/config.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; +} + +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", () => { + 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"); + + 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); + 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"); + const first = await initCodegraphLifecycle(root); + await writeFile(root, "src/extra.ts", "export const extra = 2;\n"); + const manifestPath = codegraphLifecycleManifestPath(root); + const staleManifest = { + ...first.manifest, + fileCount: 99, + fileSignatureHash: "stale-file-signature-hash", + }; + 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(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 () => { + 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"); + + 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 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, "package.json", `${JSON.stringify({ name: "fixture", dependencies: { left: "1.0.0" } })}\n`); + await initCodegraphLifecycle(root); + await writeFile(root, "package.json", `${JSON.stringify({ name: "fixture", dependencies: { right: "1.0.0" } })}\n`); + + const status = await getCodegraphLifecycleStatus(root); + + expect(status.configChanged).toBeTruthy(); + 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"); + + // 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("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"); + // 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 { + 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(); + readFileSpy.mockRestore(); + } + }); + + 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("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"); + 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); + 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 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"); + 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"); + 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"); + + 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("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 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(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 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 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"); + + 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; + + 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); + } + }); + + 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("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"); + + 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); + }); + + 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); + }); + + 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" }); + }); + + 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`); + }); +}); diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index 445c26f0..95693049 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -742,7 +742,6 @@ describe("codegraph MCP handlers", () => { expect(result.freshness).toEqual({ state: "fresh" }); expect(freshnessChecks).toBe(0); }); - 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");