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..942cc853 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. 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 9606c6c6..eee5577b 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, 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..c4223ff7 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,26 @@ 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 a1ca6122..b721b510 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] [--json] @@ -388,6 +417,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..6f9f088d --- /dev/null +++ b/src/cli/lifecycle.ts @@ -0,0 +1,92 @@ +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 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..7b75b952 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] [--force] [--json] OR codegraph init --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] [--json] OR codegraph status --root [--json]", + }), + ], + [ + "sync", + commandSchema([...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS, "--init"], SHARED_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"], { @@ -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] [--force] [--json] OR codegraph uninit --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..1a544f13 --- /dev/null +++ b/src/lifecycle/manifest.ts @@ -0,0 +1,344 @@ +import { createHash } 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 { + 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; + 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 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, + filesChanged: false, + suggestedNextCommand: "codegraph init", + }; + } + 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 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, + 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), + buildOptionsHash: hashBuildOptions(buildOptions), + fileCount: snapshot.files.length, + fileSignatureHash: await hashDiscoveredFiles(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 Error(`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)}`, + ); + } +} + +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 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 { + return sha256(stableStringify(summarizeLifecycleBuildOptions(buildOptions))); +} + +function summarizeLifecycleBuildOptions(buildOptions: BuildOptions | undefined): LifecycleBuildOptionsSummary { + return { + ...summarizeBuildOptions(buildOptions), + graph: normalizeGraphOptions(buildOptions?.graph), + native: buildOptions?.native ?? "auto", + }; +} + +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.fileSignatureHash === "string" && + 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..ba814988 --- /dev/null +++ b/tests/lifecycle.test.ts @@ -0,0 +1,467 @@ +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, + type CodegraphLifecycleStatus, + 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"; + +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", () => { + 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 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("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 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); + } + }); +});