From 30cf07f9363f27f8ce134c7c38c938e2c7f86327 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Fri, 3 Jul 2026 05:21:56 -0400 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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");