diff --git a/bunfig.toml b/bunfig.toml index debd2257..aad7d807 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,2 +1,2 @@ [test] -preload = ["./scripts/check-bun-version.mjs"] +preload = ["./scripts/check-bun-version.mjs", "./packages/surfaces/cli/bin/bun-darwin-sqlite.ts"] diff --git a/packages/contracts/src/failure.ts b/packages/contracts/src/failure.ts index e907b9ba..b5cbdd94 100644 --- a/packages/contracts/src/failure.ts +++ b/packages/contracts/src/failure.ts @@ -64,6 +64,7 @@ const policies: Record = { "architecture-model-ignored": { code: "AC_PRECONDITION_FAILED", action: "commit-architecture-model", message: "Git ignore rules exclude architecture model paths, so the model cannot be committed and a manifest digest cannot rebuild it. Remove or narrow the rule, commit the model, then retry adoption. The diagnostic command prints each deciding rule.", facts: { pathCount: "uint", paths: "optional:diagnosticPaths", commands: "optional:diagnosticCommands", omittedPathCount: "uint" } }, "projection-footprint-uncovered": { code: "AC_PRECONDITION_FAILED", message: "Declared source footprints lack a projection target." }, "index-missing": { code: "AC_CODE_FACTS_UNAVAILABLE", message: "Required code facts have no available index. Run codegraph init." }, + "index-open-failed": { code: "AC_CODE_FACTS_UNAVAILABLE", message: "The CodeGraph index exists but could not be opened and read read-only. Run archctx doctor --json.", action: "inspect-diagnostics", retryable: false }, "code-facts-not-ready": { code: "AC_CODE_FACTS_NOT_READY", message: "Repository code facts are not ready. Run archctx doctor --json and follow the reported action.", facts: { issues: "codeFactsIssues" } }, "checkpoint-input-invalid": { code: "AC_SCHEMA_INVALID", message: "Checkpoint arguments violate the declared input schema.", fields: ["issueCount"], action: "correct-command-arguments" }, "cli-input-invalid": { code: "AC_SCHEMA_INVALID", message: "Command arguments violate the declared input contract.", action: "correct-command-arguments" }, diff --git a/packages/local-runtime/codegraph-adapter/src/index.ts b/packages/local-runtime/codegraph-adapter/src/index.ts index 8c419135..e816c1c3 100644 --- a/packages/local-runtime/codegraph-adapter/src/index.ts +++ b/packages/local-runtime/codegraph-adapter/src/index.ts @@ -8,6 +8,17 @@ import { createRequire } from "node:module"; import { basename, delimiter, dirname, isAbsolute, join, posix, relative, resolve } from "node:path"; import type { Edge as CodeGraphEdge, Node as CodeGraphNode } from "@colbymchenry/codegraph"; import { codeGraphChildEnv } from "./child-env"; +import { + REQUIRED_CODEGRAPH_PACKAGE, + REQUIRED_CODEGRAPH_VERSION, + codeGraphBundle, + codeGraphIndexBuildInProgress, + CodeGraphProviderUnavailableError, + pinnedCodeGraphProvider, + withCodeGraphReadSession, + type CodeGraphReadProvider, + type CodeGraphSdk +} from "./read-session"; import { readSnapshotQueries, type SnapshotQueryRequest, type SnapshotQueryReader } from "./snapshot-query"; import { buildArchitectureCandidateDelta, type ArchitectureDeltaDeclaredGraph, type ArchitectureDeltaGitChangeMetadata } from "@archcontext/core/architecture-delta"; import { parseJsonOrStableYaml, repoScopedArchitectureId, type CrossRepoRelation } from "@archcontext/core/architecture-domain"; @@ -27,42 +38,55 @@ import { type ArchitectureDocumentationProjectionRuntimeSnapshot, type NativeModel } from "@archcontext/core/projection-engine"; -import { digestJson, productVersionManifest, type ArchitectureCandidateDeltaV1, type ArchitectureDocumentationProjectionProvenanceV3, type ArchitectureRepositoryIdentityV1, type ArchitectureWorktreeIdentityV1, type CodeFactsPort, type CodeFactsSnapshot, type ImpactQuery, type Json, type NormalizedCodeContext, type NormalizedEdge, type NormalizedImpact, type NormalizedSymbol, type ObservedEvidence, type SourceSelector, type SymbolQuery, type WorkspaceRef } from "@archcontext/contracts"; - -export const REQUIRED_CODEGRAPH_PACKAGE = "@colbymchenry/codegraph"; -export const REQUIRED_CODEGRAPH_VERSION: string = productVersionManifest().runtime.codeGraph.requiredVersion; +import { digestJson, type ArchitectureCandidateDeltaV1, type ArchitectureDocumentationProjectionProvenanceV3, type ArchitectureRepositoryIdentityV1, type ArchitectureWorktreeIdentityV1, type CodeFactsPort, type CodeFactsSnapshot, type ImpactQuery, type Json, type NormalizedCodeContext, type NormalizedEdge, type NormalizedImpact, type NormalizedSymbol, type ObservedEvidence, type SourceSelector, type SymbolQuery, type WorkspaceRef } from "@archcontext/contracts"; + +export { + CODEGRAPH_PROVIDER_INTERNAL_EXPORTS, + CodeGraphProviderUnavailableError, + REQUIRED_CODEGRAPH_PACKAGE, + REQUIRED_CODEGRAPH_VERSION, + openCodeGraphReadSession, + pinnedCodeGraphProvider, + withCodeGraphReadSession, + type CodeGraphIndexCompatibilityV1, + type CodeGraphIndexFreshnessV1, + type CodeGraphIndexRevisionV1, + type CodeGraphReadProvider, + type CodeGraphReadSession, + type CodeGraphReadSessionOpenV1 +} from "./read-session"; export const CODEGRAPH_TELEMETRY_ENV = "DO_NOT_TRACK"; export const CODEGRAPH_TELEMETRY_DISABLED_VALUE = "1"; const DEFAULT_CODEGRAPH_BINARY = "codegraph"; const requireFromAdapter = createRequire(import.meta.url); -// Keep the CommonJS SDK in its installed package so its platform/cache resolver stays relocatable. -type CodeGraphSdk = typeof import("@colbymchenry/codegraph"); -let installedCodeGraphSdk: CodeGraphSdk | undefined; +// The SDK stays in its installed package so its platform/cache resolver stays relocatable. It is +// resolved only when a CodeGraph operation needs it; the stateless native export never loads it. function codeGraphSdk(): CodeGraphSdk { - // Stateless native export shares the CLI entry but does not use code facts. - // Resolve the installed SDK only when a CodeGraph operation actually needs it. - return installedCodeGraphSdk ??= requireFromAdapter(REQUIRED_CODEGRAPH_PACKAGE) as CodeGraphSdk; + return codeGraphBundle().sdk; } -/** The SDK and its Node executable must come from the same provider-selected platform bundle. */ +/** + * The SDK and its Node executable must come from the same provider-selected platform bundle. + * Only the private snapshot reader uses it; S4 of #574 removes both. + */ export function codeGraphSnapshotRuntime(): { node: string; sdk: string } { - const sdk = codeGraphSdk(); - // Reuse the SDK shim's platform/cache resolution. The pinned platform ships this typed export - // but the thin npm facade does not re-export it; never reconstruct its ignore/extension rules. - const platformEntry = Object.values(requireFromAdapter.cache).find(module => module?.exports === sdk && basename(module.filename) === "index.js"); - if (!platformEntry) throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable"); - const dist = dirname(platformEntry.filename); - const manifest = JSON.parse(readFileSync(join(dirname(dirname(dist)), "package.json"), "utf8")) as { version?: string }; - if (manifest.version !== REQUIRED_CODEGRAPH_VERSION) throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable"); - const node = join(dirname(dirname(dist)), process.platform === "win32" ? "node.exe" : "node"); + let bundle: ReturnType; + let version: string; + try { + bundle = codeGraphBundle(); + version = pinnedCodeGraphProvider().version; + } catch { + throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable"); + } + if (version !== REQUIRED_CODEGRAPH_VERSION) throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable"); + const node = join(bundle.platformRoot, process.platform === "win32" ? "node.exe" : "node"); if (!isExecutablePath(node)) throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable"); - return { node, sdk: platformEntry.filename }; + return { node, sdk: join(bundle.dist, "index.js") }; } function codeGraphSourceScanner(): (root: string) => string[] { - const dist = dirname(codeGraphSnapshotRuntime().sdk); - const provider = requireFromAdapter(join(dist, "extraction/index.js")) as { scanDirectory(root: string): string[] }; - return provider.scanDirectory; + try { return pinnedCodeGraphProvider().scanSourceFiles; } + catch { throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable"); } } type MutableEnv = Record; @@ -92,7 +116,11 @@ export class CodeGraphCliProvider implements CodeGraphProvider { capabilities = ["index", "context", "impact"]; private workspaceRoot: string; - constructor(workspaceRoot = process.cwd(), private readonly binary = DEFAULT_CODEGRAPH_BINARY) { + /** + * `binary` builds the index (`sync`); reads never start it. `readProvider` is the read-only + * provider readiness reads through, the pinned one unless a test injects one. + */ + constructor(workspaceRoot = process.cwd(), private readonly binary = DEFAULT_CODEGRAPH_BINARY, private readonly readProvider?: CodeGraphReadProvider) { this.workspaceRoot = workspaceRoot; } @@ -104,7 +132,7 @@ export class CodeGraphCliProvider implements CodeGraphProvider { } assertReady(workspaceRoot: string): void { - assertRepositoryCodeFactsReady(workspaceRoot, this.binary === DEFAULT_CODEGRAPH_BINARY ? {} : { binary: this.binary }); + assertRepositoryCodeFactsReady(workspaceRoot, this.readProvider ? { provider: this.readProvider } : {}); } assertCanSync(workspaceRoot: string): void { @@ -804,20 +832,23 @@ export function prepareArchitectureDocumentationProjectionSnapshot( binary?: string; importNodeLimit?: number; timeouts?: { versionMs?: number; statusMs?: number; syncMs?: number }; + /** The read-only provider the readiness gate reads through; the pinned one unless injected. */ + readProvider?: CodeGraphReadProvider; } = {} ): PreparedArchitectureDocumentationProjectionSnapshot { // Classify repository readiness with the same doctor evaluator the task commands use, before // any code-facts read. A present but incompatible index is a typed refusal; pending-only // freshness is accepted and cleared by the sync below. An absent index keeps its existing // AC_CODE_FACTS_UNAVAILABLE contract. - if (codeGraphIndexAvailable(root)) assertRepositoryCodeFactsReady(root, options.binary ? { binary: options.binary } : {}); + const { readProvider, ...projectionOptions } = options; + if (codeGraphIndexAvailable(root)) assertRepositoryCodeFactsReady(root, readProvider ? { provider: readProvider } : {}); const baseHeadSha = readProjectionHeadSha(root); const sourceFiles = listGitVisibleFiles(root); const worktreeDigest = architectureDocumentationProjectionWorktreeDigest(root, model, sourceFiles); const footprints = loadCapabilitySourceFootprints(root, model, sourceFiles); const sourceTreeDigest = digestArchitectureDocumentationSourceTree(root, model, footprints); const modelDigest = digestJson(model as unknown as Json); - const prepared = prepareProjectionCodeFacts(root, model, { ...options, sourceTreeDigest, sourceFiles, footprints }); + const prepared = prepareProjectionCodeFacts(root, model, { ...projectionOptions, sourceTreeDigest, sourceFiles, footprints }); if (architectureDocumentationProjectionWorktreeDigest(root, model, listGitVisibleFiles(root)) !== worktreeDigest) { throw new Error("architecture-docs-projection-worktree-changed-during-codegraph-sync"); } @@ -1009,25 +1040,6 @@ class CodeGraphIndexBuildInProgressError extends Error { } } -/** True while the provider's writer lock names a live build of this workspace's index. */ -function codeGraphIndexBuildInProgress(workspaceRoot: string): boolean { - let raw: string; - try { - raw = readFileSync(join(workspaceRoot, ".codegraph", "codegraph.lock"), "utf8").trim(); - } catch { - return false; - } - const pid = Number.parseInt(raw, 10); - if (!Number.isSafeInteger(pid) || pid <= 0 || String(pid) !== raw) return false; - try { - process.kill(pid, 0); - return true; - } catch (error) { - // EPERM means the process exists but belongs to another user. - return (error as NodeJS.ErrnoException).code === "EPERM"; - } -} - function readProjectionCodeGraphStatus( invocation: CodeGraphCliInvocation, root: string, @@ -1182,10 +1194,6 @@ function digestFile(path: string): string { } } -export interface RepositoryCodeFactsInspectionOptions { - binary?: string; - timeouts?: { versionMs?: number; statusMs?: number }; -} /** Bytes bound the copied source/config, Git metadata, database and WAL; entries also count the uncopied Git objects. */ export const CODEGRAPH_SNAPSHOT_MAX_BYTES = 512 * 1024 * 1024; @@ -1213,8 +1221,7 @@ export function codeGraphSnapshotQueries(nodes: NativeModel["nodes"], purpose: " export function withExistingCodeGraphSnapshot( root: string, queries: SnapshotQueryRequest, - callback: (graph: SnapshotQueryReader) => T, - options: RepositoryCodeFactsInspectionOptions = {} + callback: (graph: SnapshotQueryReader) => T ): T { const originalRoot = realpathSync.native(root); const redirectedGitInputs = ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CONFIG", "GIT_CONFIG_SYSTEM", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_COUNT", "GIT_CONFIG_PARAMETERS"]; @@ -1247,13 +1254,15 @@ export function withExistingCodeGraphSnapshot( if (JSON.stringify([...scan(originalRoot)].sort()) !== JSON.stringify([...scan(snapshotRoot)].sort())) { throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); } - const ready = inspectRepositoryCodeFacts(snapshotRoot, options); + const ready = inspectRepositoryCodeFacts(snapshotRoot); if (ready.state !== "ready") { - if (ready.state === "unavailable") throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable"); + // C2: an unopenable index keeps its own reason; any other unavailable answer is code-facts-unavailable. + if (ready.state === "unavailable") throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", ready.reasonCode === "index-open-failed" ? "index-open-failed" : "code-facts-unavailable"); throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-read-not-ready", { issues: ready.issues }); } - // Node owns all SDK/SQLite handles. Its completed process lifetime precedes removal, - // including when the caller is Bun, whose statement finalizers may otherwise retain files. + // Readiness above opened the copied index in-process, through the read session, and closed it. + // The queries below still run in the provider's Node child, whose exit releases its SQLite + // handles before the copy is removed. S4 of #574 removes this path. const graph = readSnapshotQueries(codeGraphSnapshotRuntime(), snapshotRoot, queries); const result = callback(graph); if (result !== null && typeof result === "object" && "then" in result) { @@ -1454,17 +1463,18 @@ export interface RepositoryCodeFactsPendingChangesV1 { } /** - * Read-only state of this repository's CodeGraph index. `unavailable` means CodeGraph could not be - * run or its output could not be read; no other field is then reported. `index-missing` means the - * binary answered but the repository has no `.codegraph` index. `ready` and `not-ready` carry the - * `codegraph status -j` values, and `issues` lists why `not-ready` is not ready. + * Read-only state of this repository's CodeGraph index (ADR-0058). `unavailable` means the pinned + * CodeGraph provider could not load (`codegraph-unavailable`) or the index exists but could not be + * opened and read read-only (`index-open-failed`); no other field is then reported. `index-missing` + * means the provider loaded but the repository has no `.codegraph` index. `ready` and `not-ready` + * carry the index facts, and `issues` lists why `not-ready` is not ready. */ export type RepositoryCodeFactsV1 = | { schemaVersion: "archcontext.repository-code-facts/v1"; state: "unavailable"; requiredVersion: typeof REQUIRED_CODEGRAPH_VERSION; - reasonCode: "codegraph-unavailable"; + reasonCode: "codegraph-unavailable" | "index-open-failed"; message: string; } | { @@ -1486,7 +1496,7 @@ export type RepositoryCodeFactsV1 = indexState: string | null; reindexRecommended: boolean | null; pendingRefs: number | null; - /** Source changes the index has not synced yet; `null` when `status` reported none. */ + /** Source changes the index has not synced yet; `null` when they were not measured. */ pendingChanges: RepositoryCodeFactsPendingChangesV1 | null; issues: ProjectionCodeGraphStatusIssueV1[]; }; @@ -1494,82 +1504,33 @@ export type RepositoryCodeFactsV1 = /** * The packaged CodeGraph npm shim downloads its platform bundle from GitHub Releases when the * optional platform package is absent. `CODEGRAPH_NO_DOWNLOAD` (read by the shim's selfHealBundle) - * turns that fetch off, so the read-only probe stays local whatever the egress mode is. + * turns that fetch off, so the snapshot's CLI probe stays local whatever the egress mode is. */ const CODEGRAPH_NO_DOWNLOAD_ENV: Record = { CODEGRAPH_NO_DOWNLOAD: "1" }; -/** - * Inspects the repository's code-facts state with `codegraph --version` and `codegraph status -j` - * only. It never runs `sync`, never writes the index and never throws for an unavailable CodeGraph. - * While a provider build holds the index it does not run `status` at all, because that command - * opens the live database writable and repairs it mid-build (#426). - */ -export function inspectRepositoryCodeFacts(root: string, options: RepositoryCodeFactsInspectionOptions = {}): RepositoryCodeFactsV1 { - const schemaVersion = "archcontext.repository-code-facts/v1" as const; - let codeGraphVersion = ""; - let status: CodeGraphProjectionStatusV1 | undefined; - let indexBuildInProgress = false; - const indexPresent = codeGraphIndexAvailable(root); - try { - const invocation = options.binary - ? codeGraphCliInvocation(options.binary, root, "") - : packagedCodeGraphCliInvocation(); - codeGraphVersion = runProjectionCodeGraph(invocation, root, ["--version"], options.timeouts?.versionMs ?? 10_000, CODEGRAPH_NO_DOWNLOAD_ENV).trim(); - if (indexPresent) status = readProjectionCodeGraphStatus(invocation, root, options.timeouts?.statusMs ?? 10_000, CODEGRAPH_NO_DOWNLOAD_ENV); - } catch (error) { - if (!(error instanceof CodeGraphIndexBuildInProgressError)) { - return { - schemaVersion, - state: "unavailable", - requiredVersion: REQUIRED_CODEGRAPH_VERSION, - reasonCode: "codegraph-unavailable", - message: (error instanceof Error ? error.message : String(error)).replace(/\s+/g, " ").trim().slice(0, 400) - }; - } - indexBuildInProgress = true; - } - const versionIssues: ProjectionCodeGraphStatusIssueV1[] = codeGraphVersion === REQUIRED_CODEGRAPH_VERSION - ? [] - : [{ code: "version-mismatch", message: CODE_FACTS_ISSUE_MESSAGES["version-mismatch"] }]; - if (!indexPresent) { - return { schemaVersion, state: "index-missing", indexPresent: false, requiredVersion: REQUIRED_CODEGRAPH_VERSION, codeGraphVersion, issues: versionIssues }; - } - if (indexBuildInProgress) { - // Every index field would be a mid-build guess, and reading it is exactly the writable open - // this guard exists to avoid. `null` is the contract's unknown value. - return { - schemaVersion, - state: "not-ready", - indexPresent: true, - requiredVersion: REQUIRED_CODEGRAPH_VERSION, - codeGraphVersion, - indexBuiltWithVersion: null, - extractionVersion: { builtWith: null, current: null }, - indexState: null, - reindexRecommended: null, - pendingRefs: null, - pendingChanges: null, - issues: [...versionIssues, { code: "index-incomplete", message: CODE_FACTS_ISSUE_MESSAGES["index-incomplete"] }] - }; - } - if (typeof status !== "object" || status === null || Array.isArray(status)) { - return { - schemaVersion, - state: "unavailable", - requiredVersion: REQUIRED_CODEGRAPH_VERSION, - reasonCode: "codegraph-unavailable", - message: "CodeGraph status JSON is not an object" - }; - } - const issues = [...versionIssues, ...projectionCodeGraphStatusIssues(status, root, "repository")]; +const REPOSITORY_CODE_FACTS_SCHEMA = "archcontext.repository-code-facts/v1" as const; +const PENDING_SOURCE_CHANGES_ISSUE: ProjectionCodeGraphStatusIssueV1 = { + code: "pending-source-changes", + message: CODE_FACTS_ISSUE_MESSAGES["pending-source-changes"] +}; +const INDEX_OPEN_FAILED_MESSAGE = "CodeGraph index could not be opened and read read-only"; + +/** Facts every repository-code-facts reader reports the same way, from one status snapshot. */ +function repositoryCodeFactsFromStatus( + root: string, + codeGraphVersion: string, + status: CodeGraphProjectionStatusV1, + measuresFreshness: boolean +): RepositoryCodeFactsV1 { + const issues = projectionCodeGraphStatusIssues(status, root, "repository"); const pending = status.pendingChanges; // A projection runs `sync` first, so pending changes are not a projection-status issue; for a // read-only inspection they mean the index lags the worktree. - if (!pending || pending.added !== 0 || pending.modified !== 0 || pending.removed !== 0) { - issues.push({ code: "pending-source-changes", message: "CodeGraph repository index has pending or unreported source changes" }); + if (measuresFreshness && (!pending || pending.added !== 0 || pending.modified !== 0 || pending.removed !== 0)) { + issues.push(PENDING_SOURCE_CHANGES_ISSUE); } return { - schemaVersion, + schemaVersion: REPOSITORY_CODE_FACTS_SCHEMA, state: issues.length === 0 ? "ready" : "not-ready", indexPresent: true, requiredVersion: REQUIRED_CODEGRAPH_VERSION, @@ -1587,16 +1548,121 @@ export function inspectRepositoryCodeFacts(root: string, options: RepositoryCode }; } -/** Read-only usability check. Pending edits affect freshness, not index compatibility. */ +function buildInProgressCodeFacts(codeGraphVersion: string, versionIssues: ProjectionCodeGraphStatusIssueV1[]): RepositoryCodeFactsV1 { + // Every index field would be a mid-build guess. `null` is the contract's unknown value. + return { + schemaVersion: REPOSITORY_CODE_FACTS_SCHEMA, + state: "not-ready", + indexPresent: true, + requiredVersion: REQUIRED_CODEGRAPH_VERSION, + codeGraphVersion, + indexBuiltWithVersion: null, + extractionVersion: { builtWith: null, current: null }, + indexState: null, + reindexRecommended: null, + pendingRefs: null, + pendingChanges: null, + issues: [...versionIssues, { code: "index-incomplete", message: CODE_FACTS_ISSUE_MESSAGES["index-incomplete"] }] + }; +} + +function versionIssuesOf(codeGraphVersion: string): ProjectionCodeGraphStatusIssueV1[] { + return codeGraphVersion === REQUIRED_CODEGRAPH_VERSION ? [] : [{ code: "version-mismatch", message: CODE_FACTS_ISSUE_MESSAGES["version-mismatch"] }]; +} + +function unavailableCodeFacts(reasonCode: "codegraph-unavailable" | "index-open-failed", message: string): RepositoryCodeFactsV1 { + return { schemaVersion: REPOSITORY_CODE_FACTS_SCHEMA, state: "unavailable", requiredVersion: REQUIRED_CODEGRAPH_VERSION, reasonCode, message }; +} + +export interface RepositoryCodeFactsInspectionOptions { + /** The pinned provider of this installation unless a test injects one. */ + provider?: CodeGraphReadProvider; +} + +/** + * Inspects the repository's code-facts state through one read-only session (ADR-0058): no CodeGraph + * command, no `git status`, no writable open. It measures freshness with CodeGraph's own change + * test and never throws for an unavailable provider or index. While a provider build holds the + * writer lock it does not open the index (#426). + */ +export function inspectRepositoryCodeFacts(root: string, options: RepositoryCodeFactsInspectionOptions = {}): RepositoryCodeFactsV1 { + return inspectRepositoryCodeFactsTier(root, "freshness", options).facts; +} + +/** + * When a provider build held the writer lock, at open or after the reads, or the index revision + * changed while the session read it (design G4), the facts are the not-ready build-in-progress + * answer and nothing read is reported. `retryable` is true then, unless the loaded provider has the + * wrong version: a retry after the build cannot fix that. + */ +function inspectRepositoryCodeFactsTier(root: string, tier: "compatibility" | "freshness", options: RepositoryCodeFactsInspectionOptions): { facts: RepositoryCodeFactsV1; retryable: boolean } { + let provider: CodeGraphReadProvider; + let codeGraphVersion: string; + try { + provider = options.provider ?? pinnedCodeGraphProvider(); + codeGraphVersion = provider.version; + } catch (error) { + return { facts: unavailableCodeFacts("codegraph-unavailable", error instanceof CodeGraphProviderUnavailableError ? error.message : "CodeGraph provider could not be loaded"), retryable: false }; + } + const versionIssues = versionIssuesOf(codeGraphVersion); + const moving = { facts: buildInProgressCodeFacts(codeGraphVersion, versionIssues), retryable: versionIssues.length === 0 }; + try { + return withCodeGraphReadSession(root, (opened): { facts: RepositoryCodeFactsV1; retryable: boolean } => { + switch (opened.state) { + case "index-missing": + return { facts: { schemaVersion: REPOSITORY_CODE_FACTS_SCHEMA, state: "index-missing", indexPresent: false, requiredVersion: REQUIRED_CODEGRAPH_VERSION, codeGraphVersion, issues: versionIssues }, retryable: false }; + case "index-open-failed": + return { facts: unavailableCodeFacts("index-open-failed", INDEX_OPEN_FAILED_MESSAGE), retryable: false }; + case "build-in-progress": + return moving; + case "open": { + const compatibility = opened.session.compatibility(); + const pendingChanges = tier === "freshness" ? opened.session.freshness() : undefined; + // Facts read across a build or a sync are not one index state (design G4). + if (!opened.session.unchangedSinceOpen()) return moving; + const status: CodeGraphProjectionStatusV1 = { + initialized: true, + version: compatibility.version, + projectPath: compatibility.projectPath, + lastIndexed: compatibility.lastIndexed, + ...(pendingChanges === undefined ? {} : { pendingChanges }), + // `openSync(root)` never walks up to another worktree's index (ADR-0058). + worktreeMismatch: null, + index: { + ...(compatibility.builtWithVersion === null ? {} : { builtWithVersion: compatibility.builtWithVersion }), + ...(compatibility.builtWithExtractionVersion === null ? {} : { builtWithExtractionVersion: compatibility.builtWithExtractionVersion }), + currentExtractionVersion: compatibility.currentExtractionVersion, + reindexRecommended: compatibility.reindexRecommended, + ...(compatibility.state === null ? {} : { state: compatibility.state }), + pendingRefs: compatibility.pendingRefs + } + }; + return { facts: repositoryCodeFactsFromStatus(root, codeGraphVersion, status, tier === "freshness"), retryable: false }; + } + } + }, { provider }); + } catch { + // The index opened but a read of it failed; no provider text crosses this boundary. + return { facts: unavailableCodeFacts("index-open-failed", INDEX_OPEN_FAILED_MESSAGE), retryable: false }; + } +} + +/** + * Read-only usability gate (ADR-0058): compatibility facts only. Pending edits affect freshness, not + * index compatibility, so the gate reads no source file. + */ export function assertRepositoryCodeFactsReady(root: string, options: RepositoryCodeFactsInspectionOptions = {}): void { - const facts = inspectRepositoryCodeFacts(root, options); + const { facts, retryable } = inspectRepositoryCodeFactsTier(root, "compatibility", options); if (facts.state === "index-missing") throw new ProjectionCodeFactsUnavailableError("index-missing"); - if (facts.state === "unavailable") throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable", {}, { action: "inspect-diagnostics", retryable: false }); + if (facts.state === "unavailable") { + if (facts.reasonCode === "index-open-failed") throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "index-open-failed"); + throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable", {}, { action: "inspect-diagnostics", retryable: false }); + } + // The compatibility tier never measures pending changes, so every not-ready answer refuses. if (facts.state === "not-ready") { - if (facts.issues.every(issue => issue.code === "pending-source-changes")) return; const action = facts.codeGraphVersion !== REQUIRED_CODEGRAPH_VERSION ? "install-compatible-codegraph" : "codegraph-index"; - throw new FailureError("AC_CODE_FACTS_NOT_READY", "code-facts-not-ready", { issues: facts.issues }, { action }); + throw new FailureError("AC_CODE_FACTS_NOT_READY", "code-facts-not-ready", { issues: facts.issues }, { action, ...(retryable ? { retryable: true } : {}) }); } } diff --git a/packages/local-runtime/codegraph-adapter/src/read-session.ts b/packages/local-runtime/codegraph-adapter/src/read-session.ts new file mode 100644 index 00000000..05f1e9f0 --- /dev/null +++ b/packages/local-runtime/codegraph-adapter/src/read-session.ts @@ -0,0 +1,296 @@ +import { productVersionManifest } from "@archcontext/contracts"; +import { existsSync, lstatSync, readFileSync, statSync } from "node:fs"; +import { createRequire } from "node:module"; +import { basename, dirname, join } from "node:path"; +import type { CodeGraph } from "@colbymchenry/codegraph"; + +/** + * ADR-0058: every CodeGraph read goes through one in-process, read-only handle. A read starts no + * CodeGraph command, runs no `git status` or `git diff`, never opens the index writable and never + * copies it. SQLite may create the `-wal` and `-shm` sidecars of `.codegraph/codegraph.db`; nothing + * else is written. Building the index stays with the CodeGraph CLI (`init`, `sync`). + */ + +export const REQUIRED_CODEGRAPH_PACKAGE = "@colbymchenry/codegraph"; +export const REQUIRED_CODEGRAPH_VERSION: string = productVersionManifest().runtime.codeGraph.requiredVersion; + +export type CodeGraphSdk = typeof import("@colbymchenry/codegraph"); +export type CodeGraphIndexHandle = CodeGraph; + +/** + * The provider-internal modules and exports of the pinned bundle that freshness calls (ADR-0058 + * Decision 7). The bump-guard test pins exactly this table, so a provider bump that moves or renames + * one fails at test time. A public git-free change API (colbymchenry/codegraph#3287) replaces it. + */ +export const CODEGRAPH_PROVIDER_INTERNAL_EXPORTS = Object.freeze({ + "extraction/index.js": Object.freeze(["scanDirectory", "hashContent"] as const), + "file-limits.js": Object.freeze(["readBoundedSourceSync", "oversizeStamp"] as const), + "extraction/extraction-version.js": Object.freeze(["EXTRACTION_VERSION"] as const) +}); + +/** The pinned provider as the read session uses it. Tests inject one to reach every branch. */ +export interface CodeGraphReadProvider { + /** Version of the platform bundle this process loaded; the same manifest `codegraph --version` prints. */ + version: string; + /** The extraction version the loaded provider writes. */ + extractionVersion: number; + /** `CodeGraph.openSync(root, { readOnly: true })`. */ + openReadOnly(root: string): CodeGraphIndexHandle; + /** The provider's own source enumeration (repository-relative paths). */ + scanSourceFiles(root: string): string[]; + /** + * The value CodeGraph's change detection compares with a file record's `contentHash`: the hash + * of the UTF-8 text under the size limit, of the size stamp above it. + */ + changeHash(path: string): string; +} + +export interface CodeGraphBundle { + sdk: CodeGraphSdk; + /** `lib/dist` of the provider-selected platform bundle the SDK re-exports. */ + dist: string; + /** Root of that platform bundle, which holds its `package.json` and its `node` executable. */ + platformRoot: string; +} + +const requireFromSession = createRequire(import.meta.url); +let loadedBundle: CodeGraphBundle | undefined; +let pinnedProvider: CodeGraphReadProvider | undefined; + +export class CodeGraphProviderUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = "CodeGraphProviderUnavailableError"; + } +} + +/** The SDK and the platform bundle it re-exports, resolved once and only when a read needs them. */ +export function codeGraphBundle(): CodeGraphBundle { + if (loadedBundle) return loadedBundle; + let sdk: CodeGraphSdk; + try { sdk = requireFromSession(REQUIRED_CODEGRAPH_PACKAGE) as CodeGraphSdk; } + catch { throw new CodeGraphProviderUnavailableError("CodeGraph SDK could not be loaded"); } + // The thin npm facade re-exports the platform bundle; reuse its platform/cache resolution. + const entry = Object.values(requireFromSession.cache).find(module => module?.exports === sdk && basename(module.filename) === "index.js"); + if (!entry) throw new CodeGraphProviderUnavailableError("CodeGraph platform bundle could not be located"); + const dist = dirname(entry.filename); + return loadedBundle = { sdk, dist, platformRoot: dirname(dirname(dist)) }; +} + +function providerExport(dist: string, module: keyof typeof CODEGRAPH_PROVIDER_INTERNAL_EXPORTS, name: string): T { + let loaded: Record; + try { loaded = requireFromSession(join(dist, module)) as Record; } + catch { throw new CodeGraphProviderUnavailableError(`CodeGraph provider module ${module} is missing`); } + if (!(name in loaded)) throw new CodeGraphProviderUnavailableError(`CodeGraph provider export ${module}#${name} is missing`); + return loaded[name] as T; +} + +/** The pinned provider of this installation. Throws `CodeGraphProviderUnavailableError` when it cannot load. */ +export function pinnedCodeGraphProvider(): CodeGraphReadProvider { + if (pinnedProvider) return pinnedProvider; + const { sdk, dist, platformRoot } = codeGraphBundle(); + let version: unknown; + try { version = (JSON.parse(readFileSync(join(platformRoot, "package.json"), "utf8")) as { version?: unknown }).version; } + catch { throw new CodeGraphProviderUnavailableError("CodeGraph platform manifest is unreadable"); } + if (typeof version !== "string" || version === "") throw new CodeGraphProviderUnavailableError("CodeGraph platform manifest has no version"); + const scanDirectory = providerExport<(root: string) => string[]>(dist, "extraction/index.js", "scanDirectory"); + const hashContent = providerExport<(content: string) => string>(dist, "extraction/index.js", "hashContent"); + const readBoundedSourceSync = providerExport<(path: string) => { stats: { size: number }; bytes: Buffer | null }>(dist, "file-limits.js", "readBoundedSourceSync"); + const oversizeStamp = providerExport<(size: number) => string>(dist, "file-limits.js", "oversizeStamp"); + const extractionVersion = providerExport(dist, "extraction/extraction-version.js", "EXTRACTION_VERSION"); + return pinnedProvider = { + version, + extractionVersion, + openReadOnly: (root) => sdk.CodeGraph.openSync(root, { readOnly: true }), + scanSourceFiles: (root) => scanDirectory(root), + changeHash: (path) => { + const read = readBoundedSourceSync(path); + return hashContent(read.bytes === null ? oversizeStamp(read.stats.size) : read.bytes.toString("utf8")); + } + }; +} + +/** + * `.codegraph/codegraph.lock` is the provider's cross-process writer lock, held for a whole + * `codegraph init`, `index` or `sync` build. While it names a live process the index is mid-build, + * so no reader reports its facts (#426). + */ +export function codeGraphIndexBuildInProgress(workspaceRoot: string): boolean { + let raw: string; + try { + raw = readFileSync(join(workspaceRoot, ".codegraph", "codegraph.lock"), "utf8").trim(); + } catch { + return false; + } + const pid = Number.parseInt(raw, 10); + if (!Number.isSafeInteger(pid) || pid <= 0 || String(pid) !== raw) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + // EPERM means the process exists but belongs to another user. + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +/** The compatibility facts of an open index, read from the handle. */ +export interface CodeGraphIndexCompatibilityV1 { + /** The loaded provider bundle version. */ + version: string; + projectPath: string; + lastIndexed: string | null; + builtWithVersion: string | null; + builtWithExtractionVersion: number | null; + currentExtractionVersion: number; + reindexRecommended: boolean; + state: string | null; + pendingRefs: number; +} + +/** CodeGraph's own change test against the worktree: sources it would add, re-extract or drop. */ +export interface CodeGraphIndexFreshnessV1 { + added: number; + modified: number; + removed: number; +} + +/** The cheapest marker of a moved index: last index time and file count. */ +export interface CodeGraphIndexRevisionV1 { + lastIndexedAt: number | null; + fileCount: number; +} + +export interface CodeGraphReadSession { + readonly root: string; + /** The read-only handle. Valid until `close()`. */ + readonly graph: CodeGraphIndexHandle; + version(): string; + compatibility(): CodeGraphIndexCompatibilityV1; + /** Computed once per session; the session reads one worktree state. */ + freshness(): CodeGraphIndexFreshnessV1; + revision(): CodeGraphIndexRevisionV1; + /** + * Design G4: true when no provider build holds the writer lock now and the index revision equals + * the one taken at open. Reads that span a sync are only usable when this holds after them. + */ + unchangedSinceOpen(): boolean; + close(): void; +} + +export type CodeGraphReadSessionOpenV1 = + | { state: "open"; session: CodeGraphReadSession } + /** No `.codegraph` directory, or one without `codegraph.db`. */ + | { state: "index-missing" } + /** A live provider build holds the writer lock. */ + | { state: "build-in-progress" } + /** The read-only open failed, or `.codegraph` is a symbolic link. */ + | { state: "index-open-failed" }; + +/** + * Opens the index of `root` read-only for one operation. The caller closes the session; prefer + * `withCodeGraphReadSession`, which closes it in `finally`. Throws only + * `CodeGraphProviderUnavailableError`, when the pinned provider cannot load. + */ +export function openCodeGraphReadSession(root: string, options: { provider?: CodeGraphReadProvider } = {}): CodeGraphReadSessionOpenV1 { + const provider = options.provider ?? pinnedCodeGraphProvider(); + const indexDir = join(root, ".codegraph"); + let linked: boolean; + try { linked = lstatSync(indexDir).isSymbolicLink(); } + catch (error) { return { state: absenceOrUnreadable(error) }; } + // A linked `.codegraph`, dangling or not, could carry another worktree's index; it is not this root's. + if (linked) return { state: "index-open-failed" }; + try { statSync(join(indexDir, "codegraph.db")); } + catch (error) { return { state: absenceOrUnreadable(error) }; } + if (codeGraphIndexBuildInProgress(root)) return { state: "build-in-progress" }; + const handle: { graph?: CodeGraphIndexHandle } = {}; + let atOpen: CodeGraphIndexRevisionV1; + try { + handle.graph = provider.openReadOnly(root); + atOpen = revisionOf(handle.graph); + } catch { + handle.graph?.close(); + return { state: "index-open-failed" }; + } + return { state: "open", session: readSession(root, handle, provider, atOpen) }; +} + +/** Only absence proves there is no index; any other failure (permission, I/O) is an unreadable one. */ +function absenceOrUnreadable(error: unknown): "index-missing" | "index-open-failed" { + const code = (error as NodeJS.ErrnoException).code; + return code === "ENOENT" || code === "ENOTDIR" ? "index-missing" : "index-open-failed"; +} + +function revisionOf(graph: CodeGraphIndexHandle): CodeGraphIndexRevisionV1 { + const { lastIndexedAt, fileCount } = graph.getIndexRevision(); + return { lastIndexedAt, fileCount }; +} + +/** Runs one read against a session that is closed when the read returns or throws. */ +export function withCodeGraphReadSession(root: string, read: (opened: CodeGraphReadSessionOpenV1) => T, options: { provider?: CodeGraphReadProvider } = {}): T { + const opened = openCodeGraphReadSession(root, options); + try { return read(opened); } + finally { if (opened.state === "open") opened.session.close(); } +} + +function readSession(root: string, handle: { graph?: CodeGraphIndexHandle }, provider: CodeGraphReadProvider, atOpen: CodeGraphIndexRevisionV1): CodeGraphReadSession { + let freshness: CodeGraphIndexFreshnessV1 | undefined; + const open = (): CodeGraphIndexHandle => { + if (handle.graph === undefined) throw new Error("CodeGraph read session is closed"); + return handle.graph; + }; + return { + root, + get graph() { return open(); }, + version: () => provider.version, + compatibility: () => { + const graph = open(); + const build = graph.getIndexBuildInfo(); + const lastIndexed = graph.getLastIndexedAt(); + return { + version: provider.version, + projectPath: graph.getProjectRoot(), + lastIndexed: lastIndexed === null ? null : new Date(lastIndexed).toISOString(), + builtWithVersion: build.version, + builtWithExtractionVersion: build.extractionVersion, + currentExtractionVersion: provider.extractionVersion, + reindexRecommended: graph.isIndexStale(), + state: graph.getIndexState(), + pendingRefs: graph.getPendingReferenceCount() + }; + }, + freshness: () => freshness ??= indexFreshness(root, open(), provider), + revision: () => revisionOf(open()), + unchangedSinceOpen: () => { + if (codeGraphIndexBuildInProgress(root)) return false; + const now = revisionOf(open()); + return now.lastIndexedAt === atOpen.lastIndexedAt && now.fileCount === atOpen.fileCount; + }, + close: () => { + if (handle.graph === undefined) return; + handle.graph.close(); + handle.graph = undefined; + } + }; +} + +/** + * The provider's full-scan change detection, without its `git status` candidate step: the source + * population is the provider's own enumeration, and a file is modified when CodeGraph's change + * hash differs from its record. An indexed file that left the enumeration or the disk is removed. + * A file that exists but cannot be read cannot be attested, so it counts as modified, not fresh. + */ +function indexFreshness(root: string, graph: CodeGraphIndexHandle, provider: CodeGraphReadProvider): CodeGraphIndexFreshnessV1 { + const current = new Set(provider.scanSourceFiles(root)); + const indexed = new Map(graph.getFiles().map(file => [file.path, file.contentHash] as const)); + let added = 0, modified = 0, removed = 0; + for (const [path, contentHash] of indexed) { + const absolute = join(root, path); + if (!current.has(path) || !existsSync(absolute)) { removed++; continue; } + let hash: string | undefined; + try { hash = provider.changeHash(absolute); } catch { hash = undefined; } + if (hash !== contentHash) modified++; + } + // A tracked file deleted from the worktree stays in the enumeration but is not a new source. + for (const path of current) if (!indexed.has(path) && existsSync(join(root, path))) added++; + return { added, modified, removed }; +} diff --git a/packages/local-runtime/codegraph-adapter/test/build-lock.test.ts b/packages/local-runtime/codegraph-adapter/test/build-lock.test.ts index 8b4e7c2b..94c36650 100644 --- a/packages/local-runtime/codegraph-adapter/test/build-lock.test.ts +++ b/packages/local-runtime/codegraph-adapter/test/build-lock.test.ts @@ -1,60 +1,32 @@ import { expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { inspectRepositoryCodeFacts, REQUIRED_CODEGRAPH_VERSION } from "../src/index"; +import { inspectRepositoryCodeFacts } from "../src/index"; +import { fakeReadProvider } from "./read-provider-fixture"; /** - * A CodeGraph build holds `.codegraph/codegraph.lock` for its whole run. `codegraph status` is not - * a read there: the provider opens the live database writable and repairs the indexes a bulk build - * dropped, which fails the build with `database is locked` (#426). These tests pin that the - * readiness probe does not start a `status` process while the lock is held, and that it does once - * the lock is stale. + * A CodeGraph build holds `.codegraph/codegraph.lock` for its whole run, and the index is then + * mid-build (#426). These tests pin that the readiness probe does not open the index while the + * lock names a live process, and that it does once the lock is stale or malformed. */ function fixture() { const root = realpathSync.native(mkdtempSync(join(tmpdir(), "archctx-codegraph-build-lock-"))); - mkdirSync(join(root, ".codegraph")); - writeFileSync(join(root, ".codegraph", "codegraph.db"), ""); - const calls = join(root, "calls.log"); - const statusPath = join(root, "status.json"); - writeFileSync(statusPath, JSON.stringify({ - initialized: true, - version: REQUIRED_CODEGRAPH_VERSION, - projectPath: root, - pendingChanges: { added: 0, modified: 0, removed: 0 }, - worktreeMismatch: null, - index: { - builtWithVersion: REQUIRED_CODEGRAPH_VERSION, - builtWithExtractionVersion: 24, - currentExtractionVersion: 24, - reindexRecommended: false, - state: "complete", - pendingRefs: 0 - } - })); - const binary = join(root, "codegraph-fixture.cjs"); - writeFileSync(binary, ` -const { appendFileSync, readFileSync } = require("node:fs"); -const command = process.argv[2]; -appendFileSync(${JSON.stringify(calls)}, command + "\\n"); -if (command === "--version") console.log(${JSON.stringify(REQUIRED_CODEGRAPH_VERSION)}); -else if (command === "status") process.stdout.write(readFileSync(${JSON.stringify(statusPath)}, "utf8")); -else process.exit(1); -`); - const lock = (pid: number) => writeFileSync(join(root, ".codegraph", "codegraph.lock"), String(pid)); - return { root, calls, binary, lock, cleanup: () => rmSync(root, { recursive: true, force: true }) }; + const provider = fakeReadProvider(root); + const lock = (value: string) => writeFileSync(join(root, ".codegraph", "codegraph.lock"), value); + return { root, provider, lock, cleanup: () => rmSync(root, { recursive: true, force: true }) }; } function deadPid(): number { return spawnSync(process.execPath, ["-e", "0"]).pid ?? 0; } -test("a live provider build lock suppresses the writable status probe", () => { +test("a live provider build lock keeps the index closed", () => { const input = fixture(); try { - input.lock(process.pid); - const facts = inspectRepositoryCodeFacts(input.root, { binary: input.binary }); + input.lock(String(process.pid)); + const facts = inspectRepositoryCodeFacts(input.root, { provider: input.provider }); expect(facts).toMatchObject({ state: "not-ready", indexPresent: true, @@ -65,27 +37,24 @@ test("a live provider build lock suppresses the writable status probe", () => { pendingChanges: null }); expect("issues" in facts ? facts.issues.map(issue => issue.code) : []).toEqual(["index-incomplete"]); - // Only the version probe ran: no process opened the live database. - expect(readFileSync(input.calls, "utf8")).toBe("--version\n"); + expect(input.provider.calls.open).toBe(0); } finally { input.cleanup(); } }); -test("a stale provider build lock does not suppress the status probe", () => { +test("a stale provider build lock does not keep the index closed", () => { const input = fixture(); try { - input.lock(deadPid()); - const facts = inspectRepositoryCodeFacts(input.root, { binary: input.binary }); - expect(facts).toMatchObject({ state: "ready", indexState: "complete", pendingRefs: 0 }); - expect(readFileSync(input.calls, "utf8")).toBe("--version\nstatus\n"); + input.lock(String(deadPid())); + expect(inspectRepositoryCodeFacts(input.root, { provider: input.provider })).toMatchObject({ state: "ready", indexState: "complete", pendingRefs: 0 }); + expect(input.provider.calls).toMatchObject({ open: 1, close: 1 }); } finally { input.cleanup(); } }); -test("a malformed provider build lock does not suppress the status probe", () => { +test("a malformed provider build lock does not keep the index closed", () => { const input = fixture(); try { - writeFileSync(join(input.root, ".codegraph", "codegraph.lock"), "not-a-pid"); - const facts = inspectRepositoryCodeFacts(input.root, { binary: input.binary }); - expect(facts).toMatchObject({ state: "ready" }); - expect(readFileSync(input.calls, "utf8")).toBe("--version\nstatus\n"); + input.lock("not-a-pid"); + expect(inspectRepositoryCodeFacts(input.root, { provider: input.provider })).toMatchObject({ state: "ready" }); + expect(input.provider.calls).toMatchObject({ open: 1, close: 1 }); } finally { input.cleanup(); } }); diff --git a/packages/local-runtime/codegraph-adapter/test/codegraph-adapter.test.ts b/packages/local-runtime/codegraph-adapter/test/codegraph-adapter.test.ts index a8c7cfb2..2710f88b 100644 --- a/packages/local-runtime/codegraph-adapter/test/codegraph-adapter.test.ts +++ b/packages/local-runtime/codegraph-adapter/test/codegraph-adapter.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { digestJson, type Json } from "@archcontext/contracts"; import { CODEGRAPH_TELEMETRY_ENV, CodeGraphAdapter, CodeGraphCliProvider, MultiRepoCodeGraphAdapter, REQUIRED_CODEGRAPH_VERSION, codeGraphCliInvocation, disableCodeGraphTelemetryByDefault, openCodeGraphSymbolReader, packagedCodeGraphCliInvocation, prepareProjectionCodeFacts } from "../src/index"; +import { fakeReadProvider } from "./read-provider-fixture"; import { MockCodeGraphProvider } from "./factories"; // Mirrors the synthetic id CodeGraphCliProvider assigns to a CLI node that has no hash id @@ -231,7 +232,7 @@ writeFileSync(${JSON.stringify(logPath)}, JSON.stringify({ })); `); - const provider = new CodeGraphCliProvider(root, shimPath); + const provider = new CodeGraphCliProvider(root, shimPath, fakeReadProvider(root)); await provider.indexAll(root); expect(existsSync(logPath)).toBe(true); @@ -338,7 +339,7 @@ console.error("unexpected fake codegraph args", args); process.exit(2); `); - const adapter = new CodeGraphAdapter(new CodeGraphCliProvider(root, shimPath)); + const adapter = new CodeGraphAdapter(new CodeGraphCliProvider(root, shimPath, fakeReadProvider(root))); await adapter.sync({ workspace: { root, repositoryId: "repo.test", headSha: "abc" }, changedPaths: [" src\\web\\page.ts", "../escape.ts", "/tmp/escape.ts"] diff --git a/packages/local-runtime/codegraph-adapter/test/projection-codegraph-status.test.ts b/packages/local-runtime/codegraph-adapter/test/projection-codegraph-status.test.ts index 1e87e0fd..aa05ea8a 100644 --- a/packages/local-runtime/codegraph-adapter/test/projection-codegraph-status.test.ts +++ b/packages/local-runtime/codegraph-adapter/test/projection-codegraph-status.test.ts @@ -1,15 +1,16 @@ import { describe, expect, test } from "bun:test"; -import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; -import { createRequire } from "node:module"; +import { mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { join } from "node:path"; import { + CodeGraphProviderUnavailableError, REQUIRED_CODEGRAPH_VERSION, assertRepositoryCodeFactsReady, inspectRepositoryCodeFacts, projectionCodeGraphStatusIssues, type CodeGraphProjectionStatusV1 } from "../src/index"; +import { fakeReadProvider } from "./read-provider-fixture"; function freshStatus(root: string, overrides: Partial = {}): CodeGraphProjectionStatusV1 { return { @@ -116,198 +117,146 @@ describe("projectionCodeGraphStatusIssues", () => { }); describe("inspectRepositoryCodeFacts", () => { - function fakeCodeGraph(root: string, options: { version?: string; status?: unknown; statusExit?: number; recordTo?: string }): string { - const path = join(root, "fake-codegraph.js"); - writeFileSync(path, [ - `const { appendFileSync } = require("node:fs");`, - `const args = process.argv.slice(2);`, - options.recordTo ? `appendFileSync(${JSON.stringify(options.recordTo)}, args[0] + "\\n");` : "", - `if (args[0] === "--version") { console.log(${JSON.stringify(options.version ?? REQUIRED_CODEGRAPH_VERSION)}); process.exit(0); }`, - `if (args[0] === "status") { ${options.statusExit ? `console.error("boom"); process.exit(${options.statusExit});` : `console.log(JSON.stringify(${JSON.stringify(options.status)})); process.exit(0);`} }`, - `process.exit(9);` - ].join("\n"), "utf8"); - return path; - } - - test("reports an unavailable CodeGraph without throwing or inventing values", () => { + test("reports a provider that cannot load as unavailable without inventing values", () => { withRoot((root) => { - const facts = inspectRepositoryCodeFacts(root, { binary: join(root, "does-not-exist") }); - expect(facts.state).toBe("unavailable"); - expect(facts).toMatchObject({ schemaVersion: "archcontext.repository-code-facts/v1", reasonCode: "codegraph-unavailable", requiredVersion: REQUIRED_CODEGRAPH_VERSION }); - expect("indexPresent" in facts).toBe(false); - expect("codeGraphVersion" in facts).toBe(false); + const provider = new Proxy(fakeReadProvider(root), { get: (target, key) => { + if (key === "version") throw new CodeGraphProviderUnavailableError("CodeGraph platform manifest is unreadable"); + return Reflect.get(target, key); + } }); + const facts = inspectRepositoryCodeFacts(root, { provider }); + expect(facts).toEqual({ + schemaVersion: "archcontext.repository-code-facts/v1", state: "unavailable", requiredVersion: REQUIRED_CODEGRAPH_VERSION, + reasonCode: "codegraph-unavailable", message: "CodeGraph platform manifest is unreadable" + }); + expect(() => assertRepositoryCodeFactsReady(root, { provider })).toThrow(expect.objectContaining({ code: "AC_CODE_FACTS_UNAVAILABLE", reasonCode: "code-facts-unavailable", action: "inspect-diagnostics" })); }); }); - test("reports a missing index from --version alone and never calls status or sync", () => { + test("reports a missing index from the provider version alone and opens nothing", () => { withRoot((root) => { - const log = join(root, "calls.log"); - const binary = fakeCodeGraph(root, { recordTo: log }); - const facts = inspectRepositoryCodeFacts(root, { binary }); - expect(facts).toMatchObject({ state: "index-missing", indexPresent: false, codeGraphVersion: REQUIRED_CODEGRAPH_VERSION, issues: [] }); - expect(require("node:fs").readFileSync(log, "utf8")).toBe("--version\n"); + const provider = fakeReadProvider(root); + rmSync(join(root, ".codegraph"), { recursive: true }); + expect(inspectRepositoryCodeFacts(root, { provider })).toEqual({ + schemaVersion: "archcontext.repository-code-facts/v1", state: "index-missing", indexPresent: false, + requiredVersion: REQUIRED_CODEGRAPH_VERSION, codeGraphVersion: REQUIRED_CODEGRAPH_VERSION, issues: [] + }); + expect(provider.calls.open).toBe(0); + expect(() => assertRepositoryCodeFactsReady(root, { provider })).toThrow(expect.objectContaining({ code: "AC_CODE_FACTS_UNAVAILABLE", reasonCode: "index-missing" })); }); }); - test("reports a ready index and calls only --version and status", () => { + test("reports a ready index from one read-only session and closes it", () => { withRoot((root) => { - mkdirSync(join(root, ".codegraph")); - const log = join(root, "calls.log"); - const binary = fakeCodeGraph(root, { status: freshStatus(root), recordTo: log }); - const facts = inspectRepositoryCodeFacts(root, { binary }); - expect(facts).toMatchObject({ + const provider = fakeReadProvider(root); + expect(inspectRepositoryCodeFacts(root, { provider })).toEqual({ + schemaVersion: "archcontext.repository-code-facts/v1", state: "ready", indexPresent: true, + requiredVersion: REQUIRED_CODEGRAPH_VERSION, codeGraphVersion: REQUIRED_CODEGRAPH_VERSION, + indexBuiltWithVersion: REQUIRED_CODEGRAPH_VERSION, extractionVersion: { builtWith: 7, current: 7 }, + indexState: "complete", + reindexRecommended: false, + pendingRefs: 0, pendingChanges: { added: 0, modified: 0, removed: 0 }, issues: [] }); - expect(require("node:fs").readFileSync(log, "utf8")).toBe("--version\nstatus\n"); - }); - }); - - test("doctor recommends reindexing an index built with another CodeGraph version", () => { - withRoot(root => { - mkdirSync(join(root, ".codegraph")); - const status = freshStatus(root); - status.index = { ...status.index, builtWithVersion: "1.6.1", reindexRecommended: false }; - const facts = inspectRepositoryCodeFacts(root, { binary: fakeCodeGraph(root, { status }) }); - expect(facts).toMatchObject({ - state: "not-ready", reindexRecommended: true, - issues: [{ code: "version-mismatch", message: "CodeGraph repository status version mismatch" }] - }); + expect(provider.calls).toMatchObject({ open: 1, close: 1, scan: 1 }); }); }); - test.each([ - ["not-initialized", true], ["index-incomplete", true], ["extraction-version-mismatch", true], - ["worktree-mismatch", true], ["project-path-mismatch", true], ["pending-source-changes", false] - ] as const)("doctor reports freshness while task guards reject unusable indexes: %s", (code, reindexRecommended) => { - withRoot(root => { - mkdirSync(join(root, ".codegraph")); - const status = freshStatus(root); - if (code === "not-initialized") status.initialized = false; - if (code === "index-incomplete") status.index = { ...status.index, state: "building" }; - if (code === "extraction-version-mismatch") status.index = { ...status.index, builtWithExtractionVersion: 6 }; - if (code === "worktree-mismatch") status.worktreeMismatch = { expected: "other" }; - if (code === "project-path-mismatch") status.projectPath = join(root, "absent"); - if (code === "pending-source-changes") status.pendingChanges = { added: 1, modified: 0, removed: 0 }; - const options = { binary: fakeCodeGraph(root, { status }) }; - const facts = inspectRepositoryCodeFacts(root, options); - expect(facts).toMatchObject({ state: "not-ready", reindexRecommended }); - if (code === "pending-source-changes") { - expect(() => assertRepositoryCodeFactsReady(root, options)).not.toThrow(); - return; - } - expect(() => assertRepositoryCodeFactsReady(root, options)).toThrow(); - try { assertRepositoryCodeFactsReady(root, options); } - catch (error) { expect(error).toMatchObject({ code: "AC_CODE_FACTS_NOT_READY", action: "codegraph-index", details: { issues: "issues" in facts ? facts.issues : [] } }); } + test("an open that fails is index-open-failed, and the gate refuses it with the C2 reason", () => { + withRoot((root) => { + const provider = fakeReadProvider(root, { openFails: true }); + const facts = inspectRepositoryCodeFacts(root, { provider }); + expect(facts).toMatchObject({ state: "unavailable", reasonCode: "index-open-failed" }); + expect(JSON.stringify(facts)).not.toContain("private raw provider error"); + expect(() => assertRepositoryCodeFactsReady(root, { provider })).toThrow(expect.objectContaining({ + code: "AC_CODE_FACTS_UNAVAILABLE", reasonCode: "index-open-failed", action: "inspect-diagnostics", retryable: false + })); }); }); - test("binary version diagnostics do not copy raw process output into issue messages", () => { - withRoot(root => { - mkdirSync(join(root, ".codegraph")); - const options = { binary: fakeCodeGraph(root, { version: "/private/version-output token=secret", status: freshStatus(root) }) }; - const facts = inspectRepositoryCodeFacts(root, options); - expect("issues" in facts && facts.issues).toEqual([{ code: "version-mismatch", message: "CodeGraph repository status version mismatch" }]); - expect(() => assertRepositoryCodeFactsReady(root, options)).toThrow(); - try { assertRepositoryCodeFactsReady(root, options); } - catch (error) { expect(error).toMatchObject({ action: "install-compatible-codegraph" }); } + test("a read that fails during the open is index-open-failed and closes the handle", () => { + withRoot((root) => { + const provider = fakeReadProvider(root, { readFails: true }); + expect(inspectRepositoryCodeFacts(root, { provider })).toMatchObject({ state: "unavailable", reasonCode: "index-open-failed" }); + expect(provider.calls).toMatchObject({ open: 1, close: 1 }); }); }); - test("reports stale extraction and pending source changes as issues", () => { + test("a read that fails after the open is index-open-failed and still closes the handle", () => { withRoot((root) => { - mkdirSync(join(root, ".codegraph")); - const status = freshStatus(root, { pendingChanges: { added: 1, modified: 2, removed: 0 } }); - status.index = { ...status.index, builtWithExtractionVersion: 6 }; - const facts = inspectRepositoryCodeFacts(root, { binary: fakeCodeGraph(root, { status }) }); - expect(facts).toMatchObject({ - state: "not-ready", - extractionVersion: { builtWith: 6, current: 7 }, - pendingChanges: { added: 1, modified: 2, removed: 0 } - }); - expect("issues" in facts && facts.issues.map((issue) => issue.code)).toEqual(["extraction-version-mismatch", "pending-source-changes"]); + const provider = fakeReadProvider(root, { readFailsAfterOpen: true }); + const facts = inspectRepositoryCodeFacts(root, { provider }); + expect(facts).toMatchObject({ state: "unavailable", reasonCode: "index-open-failed" }); + expect(JSON.stringify(facts)).not.toContain("private raw provider error"); + expect(provider.calls).toMatchObject({ open: 1, close: 1 }); }); }); - test("a binary of another version is a version-mismatch issue", () => { + test("a symbolic-link .codegraph is index-open-failed without an open", () => { withRoot((root) => { - const facts = inspectRepositoryCodeFacts(root, { binary: fakeCodeGraph(root, { version: "0.0.1" }) }); - expect(facts).toMatchObject({ state: "index-missing", codeGraphVersion: "0.0.1" }); - expect("issues" in facts && facts.issues.map((issue) => issue.code)).toEqual(["version-mismatch"]); + withRoot((other) => { + const provider = fakeReadProvider(other); + symlinkSync(join(other, ".codegraph"), join(root, ".codegraph"), "junction"); + expect(inspectRepositoryCodeFacts(root, { provider })).toMatchObject({ state: "unavailable", reasonCode: "index-open-failed" }); + expect(provider.calls.open).toBe(0); + }); }); }); - test("a failing or invalid status is unavailable", () => { + test("a .codegraph directory without a database is index-missing, as the snapshot path reports it", () => { withRoot((root) => { - mkdirSync(join(root, ".codegraph")); - expect(inspectRepositoryCodeFacts(root, { binary: fakeCodeGraph(root, { statusExit: 3 }) }).state).toBe("unavailable"); + const provider = fakeReadProvider(root); + rmSync(join(root, ".codegraph", "codegraph.db")); + expect(inspectRepositoryCodeFacts(root, { provider })).toMatchObject({ state: "index-missing", indexPresent: false, issues: [] }); + expect(provider.calls.open).toBe(0); + expect(() => assertRepositoryCodeFactsReady(root, { provider })).toThrow(expect.objectContaining({ code: "AC_CODE_FACTS_UNAVAILABLE", reasonCode: "index-missing" })); }); }); - test("an existing index whose status JSON is not an object is unavailable, not index-missing", () => { + test.each([ + ["bundle version", { version: "0.0.1" }, ["version-mismatch"], "install-compatible-codegraph"], + ["index build version", { builtWithVersion: "1.6.1" }, ["version-mismatch"], "codegraph-index"], + ["unknown build version", { builtWithVersion: null }, ["version-mismatch"], "codegraph-index"], + ["partial index", { state: "partial" }, ["index-incomplete"], "codegraph-index"], + ["pending references", { pendingRefs: 3 }, ["index-incomplete"], "codegraph-index"], + ["stale extraction", { builtWithExtractionVersion: 6 }, ["extraction-version-mismatch"], "codegraph-index"], + ["another project root", { projectPath: "/elsewhere/repository" }, ["project-path-mismatch"], "codegraph-index"] + ] as const)("an incompatible index (%s) is not ready, and the gate refuses it", (_name, facts, codes, action) => { withRoot((root) => { - mkdirSync(join(root, ".codegraph")); - const facts = inspectRepositoryCodeFacts(root, { binary: fakeCodeGraph(root, { status: null }) }); - expect(facts).toMatchObject({ state: "unavailable", reasonCode: "codegraph-unavailable", requiredVersion: REQUIRED_CODEGRAPH_VERSION }); - expect("indexPresent" in facts).toBe(false); + const provider = fakeReadProvider(root, facts); + const inspected = inspectRepositoryCodeFacts(root, { provider }); + expect(inspected).toMatchObject({ state: "not-ready", reindexRecommended: true }); + expect("issues" in inspected && inspected.issues.map(issue => issue.code)).toEqual([...codes]); + expect(() => assertRepositoryCodeFactsReady(root, { provider })).toThrow(expect.objectContaining({ + code: "AC_CODE_FACTS_NOT_READY", reasonCode: "code-facts-not-ready", action, details: { issues: "issues" in inspected ? inspected.issues : [] } + })); }); }); - test("always passes CODEGRAPH_NO_DOWNLOAD to both --version and status, whatever the caller environment says", () => { + test("pending source changes make the doctor not ready, while the gate reads compatibility only and passes", () => { withRoot((root) => { - mkdirSync(join(root, ".codegraph")); - const log = join(root, "env.log"); - const binary = join(root, "env-codegraph.js"); - writeFileSync(binary, [ - `const { appendFileSync } = require("node:fs");`, - `const args = process.argv.slice(2);`, - `appendFileSync(${JSON.stringify(log)}, args[0] + "=" + String(process.env.CODEGRAPH_NO_DOWNLOAD) + "\\n");`, - `if (args[0] === "--version") { console.log(${JSON.stringify(REQUIRED_CODEGRAPH_VERSION)}); process.exit(0); }`, - `if (args[0] === "status") { console.log(${JSON.stringify(JSON.stringify(freshStatus(root)))}); process.exit(0); }`, - `process.exit(9);` - ].join("\n"), "utf8"); - const previous = process.env.CODEGRAPH_NO_DOWNLOAD; - process.env.CODEGRAPH_NO_DOWNLOAD = ""; - try { - expect(inspectRepositoryCodeFacts(root, { binary }).state).toBe("ready"); - } finally { - if (previous === undefined) delete process.env.CODEGRAPH_NO_DOWNLOAD; - else process.env.CODEGRAPH_NO_DOWNLOAD = previous; - } - expect(readFileSync(log, "utf8")).toBe("--version=1\nstatus=1\n"); + const provider = fakeReadProvider(root, { pending: { added: 1, modified: 2, removed: 3 } }); + const facts = inspectRepositoryCodeFacts(root, { provider }); + expect(facts).toMatchObject({ state: "not-ready", reindexRecommended: false, pendingChanges: { added: 1, modified: 2, removed: 3 } }); + expect("issues" in facts && facts.issues.map(issue => issue.code)).toEqual(["pending-source-changes"]); + const scans = provider.calls.scan; + const hashes = provider.calls.hash; + expect(() => assertRepositoryCodeFactsReady(root, { provider })).not.toThrow(); + // The gate reads no source file: no enumeration, no hash. + expect(provider.calls.scan).toBe(scans); + expect(provider.calls.hash).toBe(hashes); }); }); - test("the packaged shim with no platform bundle is unavailable and does not download", () => { + test("issue messages are the closed vocabulary, never provider output", () => { withRoot((root) => { - const shimDir = join(root, "shim"); - mkdirSync(shimDir); - // An existing node_modules keeps Bun's resolver from auto-installing the missing platform package. - mkdirSync(join(shimDir, "node_modules")); - const packageRoot = dirname(createRequire(import.meta.url).resolve("@colbymchenry/codegraph/package.json")); - copyFileSync(join(packageRoot, "npm-shim.js"), join(shimDir, "npm-shim.js")); - copyFileSync(join(packageRoot, "package.json"), join(shimDir, "package.json")); - const installDir = join(root, "install"); - mkdirSync(installDir); - const saved = { dir: process.env.CODEGRAPH_INSTALL_DIR, base: process.env.CODEGRAPH_DOWNLOAD_BASE }; - process.env.CODEGRAPH_INSTALL_DIR = installDir; - // A download attempt would announce itself on stderr and fail against this closed local port. - process.env.CODEGRAPH_DOWNLOAD_BASE = "http://127.0.0.1:1"; - try { - const facts = inspectRepositoryCodeFacts(root, { binary: join(shimDir, "npm-shim.js") }); - expect(facts).toMatchObject({ state: "unavailable", reasonCode: "codegraph-unavailable" }); - expect("message" in facts && facts.message).toContain("network fallback is disabled"); - expect("message" in facts && facts.message).not.toContain("downloading"); - } finally { - if (saved.dir === undefined) delete process.env.CODEGRAPH_INSTALL_DIR; - else process.env.CODEGRAPH_INSTALL_DIR = saved.dir; - if (saved.base === undefined) delete process.env.CODEGRAPH_DOWNLOAD_BASE; - else process.env.CODEGRAPH_DOWNLOAD_BASE = saved.base; - } + const provider = fakeReadProvider(root, { version: "/private/version-output token=secret" }); + const facts = inspectRepositoryCodeFacts(root, { provider }); + expect("issues" in facts && facts.issues).toEqual([{ code: "version-mismatch", message: "CodeGraph repository status version mismatch" }]); }); }); }); diff --git a/packages/local-runtime/codegraph-adapter/test/read-provider-fixture.ts b/packages/local-runtime/codegraph-adapter/test/read-provider-fixture.ts new file mode 100644 index 00000000..4422ab2d --- /dev/null +++ b/packages/local-runtime/codegraph-adapter/test/read-provider-fixture.ts @@ -0,0 +1,109 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import type { CodeGraphReadProvider } from "../src/index"; +import { REQUIRED_CODEGRAPH_VERSION } from "../src/index"; + +/** What the injected index reports. Every field defaults to a ready, fresh index of `root`. */ +export interface FakeIndexFacts { + /** Version of the provider bundle "loaded" by this process. */ + version?: string; + builtWithVersion?: string | null; + builtWithExtractionVersion?: number | null; + currentExtractionVersion?: number; + state?: string | null; + reindexRecommended?: boolean; + pendingRefs?: number; + /** Project root the handle reports; `root` when omitted. */ + projectPath?: string; + lastIndexedAt?: number | null; + /** Pending source changes the provider's change test finds. */ + pending?: { added: number; modified: number; removed: number }; + /** The read-only open throws. */ + openFails?: boolean; + /** Every read on the opened handle throws, including the revision read during the open. */ + readFails?: boolean; + /** Only reads after the open throw: the build info, which `compatibility()` reads first. */ + readFailsAfterOpen?: boolean; + /** Each revision read reports a later index, as a sync that commits during the session would. */ + revisionMoves?: boolean; +} + +export interface FakeReadProvider extends CodeGraphReadProvider { + /** Counters of what the session asked the provider and the handle for. */ + readonly calls: { open: number; close: number; scan: number; hash: number }; +} + +/** + * A provider whose read-only handle answers from `facts`, read anew on every open, so a test can + * change the index between reads. Creates `.codegraph/codegraph.db` so the session opens it. + */ +export function fakeReadProvider(root: string, facts: FakeIndexFacts | (() => FakeIndexFacts) = {}): FakeReadProvider { + mkdirSync(join(root, ".codegraph", "fake-sources"), { recursive: true }); + writeFileSync(join(root, ".codegraph", "codegraph.db"), ""); + // As in a real index directory, Git ignores everything in it, so the fake sources below never + // enter the Git-visible files or a worktree digest. + writeFileSync(join(root, ".codegraph", ".gitignore"), "*\n"); + const current = () => typeof facts === "function" ? facts() : facts; + const calls = { open: 0, close: 0, scan: 0, hash: 0 }; + const extractionVersion = () => current().currentExtractionVersion ?? 7; + // The pending counts become files: `m*` indexed and changed, `r*` indexed and gone, `a*` new. + const population = () => { + const pending = current().pending ?? { added: 0, modified: 0, removed: 0 }; + const name = (prefix: string, count: number) => Array.from({ length: count }, (_, index) => `.codegraph/fake-sources/${prefix}${index}.ts`); + return { fresh: [".codegraph/fake-sources/fresh.ts"], modified: name("m", pending.modified), removed: name("r", pending.removed), added: name("a", pending.added) }; + }; + // The worktree holds what the enumeration lists; removed files are gone from both. + const materialize = () => { + const files = population(); + for (const path of [...files.fresh, ...files.modified, ...files.added]) writeFileSync(join(root, path), `// ${path}\n`); + for (const path of files.removed) rmSync(join(root, path), { force: true }); + return files; + }; + materialize(); + return { + calls, + get version() { return current().version ?? REQUIRED_CODEGRAPH_VERSION; }, + get extractionVersion() { return extractionVersion(); }, + openReadOnly: (openedRoot) => { + calls.open++; + const value = current(); + if (value.openFails) throw new Error("private raw provider error /outside/repository"); + const read = (answer: () => T): T => { + if (value.readFails) throw new Error("private raw provider error /outside/repository"); + return answer(); + }; + const files = population(); + let revisions = 0; + return { + getIndexBuildInfo: () => read(() => { + if (value.readFailsAfterOpen) throw new Error("private raw provider error /outside/repository"); + return { + version: value.builtWithVersion === undefined ? REQUIRED_CODEGRAPH_VERSION : value.builtWithVersion, + extractionVersion: value.builtWithExtractionVersion === undefined ? extractionVersion() : value.builtWithExtractionVersion + }; + }), + getLastIndexedAt: () => read(() => value.lastIndexedAt === undefined ? Date.UTC(2026, 0, 1) : value.lastIndexedAt), + getProjectRoot: () => read(() => value.projectPath ?? openedRoot), + isIndexStale: () => read(() => value.reindexRecommended ?? false), + getIndexState: () => read(() => value.state === undefined ? "complete" : value.state), + getPendingReferenceCount: () => read(() => value.pendingRefs ?? 0), + getIndexRevision: () => read(() => ({ + lastIndexedAt: Date.UTC(2026, 0, 1) + (value.revisionMoves ? revisions++ : 0), + fileCount: files.fresh.length + files.modified.length + files.removed.length + })), + getFiles: () => read(() => [...files.fresh, ...files.modified, ...files.removed].map(path => ({ path, contentHash: `hash:${path}` }))), + close: () => { calls.close++; } + } as unknown as ReturnType; + }, + scanSourceFiles: () => { + calls.scan++; + const files = materialize(); + return [...files.fresh, ...files.modified, ...files.added]; + }, + changeHash: (path) => { + calls.hash++; + const relative = path.slice(root.length + 1).replaceAll("\\", "/"); + return population().modified.includes(relative) ? "hash:changed" : `hash:${relative}`; + } + }; +} diff --git a/packages/local-runtime/codegraph-adapter/test/read-session.test.ts b/packages/local-runtime/codegraph-adapter/test/read-session.test.ts new file mode 100644 index 00000000..9ed847bd --- /dev/null +++ b/packages/local-runtime/codegraph-adapter/test/read-session.test.ts @@ -0,0 +1,326 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; +import { + CODEGRAPH_PROVIDER_INTERNAL_EXPORTS, + REQUIRED_CODEGRAPH_VERSION, + assertRepositoryCodeFactsReady, + inspectRepositoryCodeFacts, + openCodeGraphReadSession, + pinnedCodeGraphProvider, + withCodeGraphReadSession, + withExistingCodeGraphSnapshot +} from "../src/index"; +import { codeGraphBundle } from "../src/read-session"; +import { fakeReadProvider } from "./read-provider-fixture"; +import { indexCodeGraphFixture } from "./real-index"; + +function withRoot(run: (root: string) => T): T { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "archctx-read-session-"))); + try { return run(root); } finally { rmSync(root, { recursive: true, force: true }); } +} + +describe("openCodeGraphReadSession", () => { + test("no .codegraph is index-missing and opens nothing", () => { + withRoot((root) => { + const provider = fakeReadProvider(root); + rmSync(join(root, ".codegraph"), { recursive: true }); + expect(openCodeGraphReadSession(root, { provider })).toEqual({ state: "index-missing" }); + expect(provider.calls.open).toBe(0); + }); + }); + + test("a .codegraph without a database is index-missing and opens nothing", () => { + withRoot((root) => { + const provider = fakeReadProvider(root); + rmSync(join(root, ".codegraph", "codegraph.db")); + expect(openCodeGraphReadSession(root, { provider })).toEqual({ state: "index-missing" }); + expect(provider.calls.open).toBe(0); + }); + }); + + test("a dangling .codegraph link is index-open-failed, not index-missing", () => { + withRoot((root) => { + const provider = fakeReadProvider(join(root, "elsewhere")); + symlinkSync(join(root, "absent"), join(root, ".codegraph"), "junction"); + expect(openCodeGraphReadSession(root, { provider })).toEqual({ state: "index-open-failed" }); + expect(provider.calls.open).toBe(0); + }); + }); + + test("a live build lock is build-in-progress and opens nothing", () => { + withRoot((root) => { + const provider = fakeReadProvider(root); + writeFileSync(join(root, ".codegraph", "codegraph.lock"), String(process.pid)); + expect(openCodeGraphReadSession(root, { provider })).toEqual({ state: "build-in-progress" }); + expect(provider.calls.open).toBe(0); + }); + }); + + test("a linked .codegraph is index-open-failed and opens nothing", () => { + withRoot((root) => { + withRoot((other) => { + const provider = fakeReadProvider(other); + symlinkSync(join(other, ".codegraph"), join(root, ".codegraph"), "junction"); + expect(openCodeGraphReadSession(root, { provider })).toEqual({ state: "index-open-failed" }); + expect(provider.calls.open).toBe(0); + }); + }); + }); + + test.skipIf(process.platform === "win32" || process.getuid?.() === 0)("an unreadable .codegraph or database is index-open-failed, not index-missing", () => { + withRoot((root) => { + const project = join(root, "project"); + const provider = fakeReadProvider(project); + const indexDir = join(project, ".codegraph"); + try { + // The database check: .codegraph exists, but codegraph.db cannot be stat'ed. + chmodSync(indexDir, 0o000); + expect(openCodeGraphReadSession(project, { provider })).toEqual({ state: "index-open-failed" }); + chmodSync(indexDir, 0o755); + // The .codegraph check: the project directory cannot be searched. + chmodSync(project, 0o000); + expect(openCodeGraphReadSession(project, { provider })).toEqual({ state: "index-open-failed" }); + } finally { + chmodSync(project, 0o755); + chmodSync(indexDir, 0o755); + } + expect(provider.calls.open).toBe(0); + }); + }); + + test("a read-only open that throws is index-open-failed", () => { + withRoot((root) => { + const provider = fakeReadProvider(root, { openFails: true }); + expect(openCodeGraphReadSession(root, { provider })).toEqual({ state: "index-open-failed" }); + expect(provider.calls.open).toBe(1); + }); + }); + + test("compatibility() and revision() report the handle's facts and the loaded version", () => { + withRoot((root) => { + const provider = fakeReadProvider(root, { version: "9.9.9", builtWithVersion: null, builtWithExtractionVersion: 6, currentExtractionVersion: 7, state: null, pendingRefs: 2, reindexRecommended: true, lastIndexedAt: null }); + withCodeGraphReadSession(root, (opened) => { + if (opened.state !== "open") throw new Error(opened.state); + expect(opened.session.version()).toBe("9.9.9"); + expect(opened.session.compatibility()).toEqual({ + version: "9.9.9", projectPath: root, lastIndexed: null, builtWithVersion: null, + builtWithExtractionVersion: 6, currentExtractionVersion: 7, reindexRecommended: true, state: null, pendingRefs: 2 + }); + expect(opened.session.revision()).toEqual({ lastIndexedAt: Date.UTC(2026, 0, 1), fileCount: 1 }); + }, { provider }); + }); + }); + + test("a revision that moves between reads is not one index state: not ready, retryable, never a pass (G4)", () => { + withRoot((root) => { + const provider = fakeReadProvider(root, { revisionMoves: true }); + withCodeGraphReadSession(root, (opened) => { + if (opened.state !== "open") throw new Error(opened.state); + opened.session.compatibility(); + expect(opened.session.unchangedSinceOpen()).toBe(false); + }, { provider }); + expect(inspectRepositoryCodeFacts(root, { provider })).toMatchObject({ + state: "not-ready", indexState: null, pendingChanges: null, issues: [{ code: "index-incomplete" }] + }); + expect(() => assertRepositoryCodeFactsReady(root, { provider })).toThrow(expect.objectContaining({ + code: "AC_CODE_FACTS_NOT_READY", reasonCode: "code-facts-not-ready", retryable: true + })); + expect(provider.calls.open).toBe(provider.calls.close); + // A moving index whose provider also has the wrong version cannot be fixed by a retry. + const mismatched = fakeReadProvider(root, { revisionMoves: true, version: "0.0.1" }); + expect(() => assertRepositoryCodeFactsReady(root, { provider: mismatched })).toThrow(expect.objectContaining({ + code: "AC_CODE_FACTS_NOT_READY", action: "install-compatible-codegraph", retryable: false + })); + }); + }); + + test("freshness() is CodeGraph's change test, computed once per session", () => { + withRoot((root) => { + const provider = fakeReadProvider(root, { pending: { added: 2, modified: 1, removed: 3 } }); + withCodeGraphReadSession(root, (opened) => { + if (opened.state !== "open") throw new Error(opened.state); + expect(opened.session.freshness()).toEqual({ added: 2, modified: 1, removed: 3 }); + expect(opened.session.freshness()).toEqual({ added: 2, modified: 1, removed: 3 }); + }, { provider }); + expect(provider.calls.scan).toBe(1); + // Only files in both the index and the enumeration are hashed: fresh.ts and the modified one. + expect(provider.calls.hash).toBe(2); + }); + }); + + test("a source that cannot be read is not attested: it counts as modified", () => { + withRoot((root) => { + const provider = fakeReadProvider(root); + const unreadable = { ...provider, changeHash: () => { throw new Error("EACCES"); } }; + withCodeGraphReadSession(root, (opened) => { + if (opened.state !== "open") throw new Error(opened.state); + expect(opened.session.freshness()).toEqual({ added: 0, modified: 1, removed: 0 }); + }, { provider: unreadable }); + }); + }); + + test("the session closes when the read returns and when it throws, and refuses use after close", () => { + withRoot((root) => { + const provider = fakeReadProvider(root); + let kept: ReturnType | undefined; + withCodeGraphReadSession(root, (opened) => { kept = opened; }, { provider }); + expect(provider.calls.close).toBe(1); + if (kept?.state !== "open") throw new Error("not open"); + const session = kept.session; + expect(() => session.compatibility()).toThrow("closed"); + expect(() => session.graph).toThrow("closed"); + session.close(); + expect(provider.calls.close).toBe(1); + expect(() => withCodeGraphReadSession(root, () => { throw new Error("read failed"); }, { provider })).toThrow("read failed"); + expect(provider.calls.close).toBe(2); + }); + }); +}); + +describe("pinned provider bump guard (ADR-0058 Decision 7)", () => { + const types: Record = { + scanDirectory: "function", hashContent: "function", readBoundedSourceSync: "function", oversizeStamp: "function", EXTRACTION_VERSION: "number" + }; + + test("the provider-internal modules and exports are exactly the pinned table", () => { + expect(CODEGRAPH_PROVIDER_INTERNAL_EXPORTS).toEqual({ + "extraction/index.js": ["scanDirectory", "hashContent"], + "file-limits.js": ["readBoundedSourceSync", "oversizeStamp"], + "extraction/extraction-version.js": ["EXTRACTION_VERSION"] + }); + }); + + test("the pinned bundle ships every listed module path and export", () => { + const { dist } = codeGraphBundle(); + const load = createRequire(import.meta.url); + for (const [module, names] of Object.entries(CODEGRAPH_PROVIDER_INTERNAL_EXPORTS)) { + const loaded = load(join(dist, module)) as Record; + for (const name of names) expect({ module, name, type: typeof loaded[name] }).toEqual({ module, name, type: types[name] }); + } + }); + + test("the loaded bundle is the pinned version", () => { + const provider = pinnedCodeGraphProvider(); + expect(provider.version).toBe(REQUIRED_CODEGRAPH_VERSION); + expect(Number.isInteger(provider.extractionVersion)).toBe(true); + }); +}); + +/** + * A real index read in this process through the pinned provider: no holder process, so the open + * also proves the read-only open works after the writer removed its sidecars. + */ +describe("read session over a real index", () => { + let root = ""; + const oversize = "src/big.ts"; + + beforeAll(() => { + root = realpathSync.native(mkdtempSync(join(tmpdir(), "archctx-read-session-real-"))); + execFileSync("git", ["init", "-q"], { cwd: root }); + writeFileSync(join(root, ".gitignore"), ".codegraph/\n"); + mkdirSync(join(root, "src")); + writeFileSync(join(root, "src/a.ts"), 'import { b } from "./b";\nexport function a() { return b(); }\n'); + writeFileSync(join(root, "src/b.ts"), "export function b() { return 1; }\n"); + // Above CodeGraph's source size limit: the index keeps a size stamp, not a text hash. + writeFileSync(join(root, oversize), `export const big = "${"x".repeat(1024 * 1024 + 16)}";\n`); + execFileSync("git", ["add", "-A"], { cwd: root }); + execFileSync("git", ["-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "-c", `core.hooksPath=${join(root, ".git/no-hooks")}`, "commit", "-qm", "fixture"], { cwd: root }); + indexCodeGraphFixture(root); + }, 120_000); + afterAll(() => { if (root) rmSync(root, { recursive: true, force: true }); }); + + const digest = (path: string) => createHash("sha256").update(readFileSync(path)).digest("hex"); + + test("a fresh index is ready; the read changes neither the database nor .git/index, and runs no git status or diff", () => { + const gitIndex = join(root, ".git", "index"); + const database = join(root, ".codegraph", "codegraph.db"); + const before = { gitIndex: statSync(gitIndex).mtimeMs, database: digest(database) }; + const spy = process.platform === "win32" ? undefined : gitSpy(); + let commands: string[] = []; + try { + expect(inspectRepositoryCodeFacts(root)).toMatchObject({ + state: "ready", codeGraphVersion: REQUIRED_CODEGRAPH_VERSION, indexBuiltWithVersion: REQUIRED_CODEGRAPH_VERSION, + pendingChanges: { added: 0, modified: 0, removed: 0 }, issues: [] + }); + expect(() => assertRepositoryCodeFactsReady(root)).not.toThrow(); + commands = spy?.commands() ?? []; + } finally { spy?.restore(); } + if (spy) { + // The spy saw the provider enumeration, so an absent status or diff is a real absence. + expect(commands.some(command => command.startsWith("ls-files"))).toBe(true); + expect(commands.filter(command => /^(status|diff)\b/.test(command))).toEqual([]); + } + expect({ gitIndex: statSync(gitIndex).mtimeMs, database: digest(database) }).toEqual(before); + }); + + test("the oversize file is indexed as a stamp and reads as fresh", () => { + withCodeGraphReadSession(root, (opened) => { + if (opened.state !== "open") throw new Error(opened.state); + expect(opened.session.graph.getFile(oversize)).not.toBeNull(); + expect(opened.session.freshness()).toEqual({ added: 0, modified: 0, removed: 0 }); + }); + }); + + test("an edit, a new source and a deletion are counted; the gate still passes", () => { + const edited = join(root, "src/a.ts"); + const removed = join(root, "src/b.ts"); + const added = join(root, "src/c.ts"); + const originals = { edited: readFileSync(edited), removed: readFileSync(removed) }; + try { + writeFileSync(edited, "export function a() { return 2; }\n"); + unlinkSync(removed); + writeFileSync(added, "export const c = 3;\n"); + expect(inspectRepositoryCodeFacts(root)).toMatchObject({ + state: "not-ready", pendingChanges: { added: 1, modified: 1, removed: 1 }, + issues: [{ code: "pending-source-changes" }] + }); + expect(() => assertRepositoryCodeFactsReady(root)).not.toThrow(); + } finally { + writeFileSync(edited, originals.edited); + writeFileSync(removed, originals.removed); + rmSync(added, { force: true }); + } + expect(inspectRepositoryCodeFacts(root)).toMatchObject({ state: "ready" }); + }); +}); + +describe("private snapshot readiness mapping (C2)", () => { + test("an index the session cannot open keeps index-open-failed through the snapshot", () => { + withRoot((root) => { + execFileSync("git", ["init", "-q"], { cwd: root }); + writeFileSync(join(root, ".gitignore"), ".codegraph/\n"); + mkdirSync(join(root, "src")); + writeFileSync(join(root, "src/a.ts"), "export const a = 1;\n"); + mkdirSync(join(root, ".codegraph")); + // Not a SQLite database: the snapshot copies it, and the session cannot open or read it. + writeFileSync(join(root, ".codegraph", "codegraph.db"), "not a sqlite database\n"); + expect(() => withExistingCodeGraphSnapshot(root, { names: [], sources: [], includeFiles: false }, () => true)) + .toThrow(expect.objectContaining({ code: "AC_CODE_FACTS_UNAVAILABLE", reasonCode: "index-open-failed" })); + }); + }); +}); + +/** A `git` first on PATH that logs each command and runs the real one. POSIX only. */ +function gitSpy(): { commands(): string[]; restore(): void } { + const dir = realpathSync.native(mkdtempSync(join(tmpdir(), "archctx-git-spy-"))); + const log = join(dir, "git.log"); + const real = execFileSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).trim(); + writeFileSync(log, ""); + writeFileSync(join(dir, "git"), `#!/bin/sh\nprintf '%s\\n' "$*" >> '${log}'\nexec '${real}' "$@"\n`); + chmodSync(join(dir, "git"), 0o755); + const previous = process.env.PATH; + process.env.PATH = `${dir}${delimiter}${previous ?? ""}`; + return { + // `-C ` is a location, not the command. + commands: () => readFileSync(log, "utf8").split("\n").filter(Boolean).map(line => line.replace(/^-C \S+ /, "")), + restore: () => { + process.env.PATH = previous; + rmSync(dir, { recursive: true, force: true }); + } + }; +} diff --git a/packages/local-runtime/runtime-daemon/src/architecture-debt-code-facts.ts b/packages/local-runtime/runtime-daemon/src/architecture-debt-code-facts.ts index ed0a2ca7..e7f6619f 100644 --- a/packages/local-runtime/runtime-daemon/src/architecture-debt-code-facts.ts +++ b/packages/local-runtime/runtime-daemon/src/architecture-debt-code-facts.ts @@ -3,14 +3,14 @@ import { checkArchitectureFlowSelectors, loadCapabilitySourceFootprints, type Na import type { ArchitectureDebtEvidence } from "@archcontext/core/review-engine"; import { inspectRepositoryCodeFacts, loadCapabilityCodeGraphProjectionInputs, - type RepositoryCodeFactsV1 + type CodeGraphReadProvider, type RepositoryCodeFactsV1 } from "@archcontext/local-runtime/codegraph-adapter"; import { listGitVisibleFiles } from "@archcontext/local-runtime/git-adapter"; import { projectionArchitectureDebt } from "./projection-architecture-debt"; type CodeFactsDebtEvidence = Pick; type ReadOptions = Pick[2]>, - "binary" | "selectorIndexFactory" | "importNodeLimit">; + "binary" | "selectorIndexFactory" | "importNodeLimit"> & { provider?: CodeGraphReadProvider }; /** * Full dependency/flow evidence for the completion consumer. Deliberately separate from the @@ -18,21 +18,23 @@ type ReadOptions = Pick; try { inputs = loadCapabilityCodeGraphProjectionInputs(root, model, { - ...options, readOnly: true, sourceFiles, + ...readOptions, readOnly: true, sourceFiles, footprints: loadCapabilitySourceFootprints(root, model, sourceFiles) }); } catch { // Read-only open/query refusal is observable. Never retry with a writable handle or sync. return unavailable(["codegraph-read-failed"]); } - const after = inspectRepositoryCodeFacts(root, { binary: options.binary }); + const after = inspectRepositoryCodeFacts(root, inspection); if (after.state !== "ready") return unavailable(inspectionReasons(after)); if (worktreeDigest !== computeWorktreeDigest(root, listGitVisibleFiles(root))) { return unavailable(["worktree-changed-during-read"]); diff --git a/packages/local-runtime/runtime-daemon/src/flow-selector-check.ts b/packages/local-runtime/runtime-daemon/src/flow-selector-check.ts index 9008e125..62e200f7 100644 --- a/packages/local-runtime/runtime-daemon/src/flow-selector-check.ts +++ b/packages/local-runtime/runtime-daemon/src/flow-selector-check.ts @@ -29,7 +29,7 @@ export function runFlowSelectorCheckCommand(cwd: string, options: { binary?: str getOutgoingEdges: id => graph.getOutgoingEdges(id), close: () => undefined }) - }).selectorEvidence, { binary: options.binary }); + }).selectorEvidence); return okEnvelope("flows.check", checkArchitectureFlowSelectors({ model, selectorEvidence, codeGraph: { availability: "ready", version: REQUIRED_CODEGRAPH_VERSION } }) as unknown as Json); diff --git a/packages/local-runtime/runtime-daemon/test/architecture-debt-code-facts.test.ts b/packages/local-runtime/runtime-daemon/test/architecture-debt-code-facts.test.ts index 9d69367d..a00eac09 100644 --- a/packages/local-runtime/runtime-daemon/test/architecture-debt-code-facts.test.ts +++ b/packages/local-runtime/runtime-daemon/test/architecture-debt-code-facts.test.ts @@ -1,6 +1,6 @@ import { afterEach, expect, spyOn, test } from "bun:test"; import { execFileSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -9,6 +9,7 @@ import type { NativeModel } from "@archcontext/core/projection-engine"; import { evaluateArchitectureDebt } from "@archcontext/core/review-engine"; import { loadCapabilityCodeGraphProjectionInputs, REQUIRED_CODEGRAPH_VERSION, type CodeGraphSelectorIndex } from "@archcontext/local-runtime/codegraph-adapter"; import { readArchitectureDebtCodeFacts } from "../src/architecture-debt-code-facts"; +import { fakeReadProvider } from "../../codegraph-adapter/test/read-provider-fixture"; const roots: string[] = []; afterEach(() => { @@ -34,23 +35,20 @@ function fixture(options: { missing?: boolean; stale?: boolean; queryFails?: boo mkdirSync(join(root, "src")); writeFileSync(join(root, "src/a.ts"), 'import { sink } from "./b";\nexport function entry() { return sink(); }\n'); writeFileSync(join(root, "src/b.ts"), "export function sink() { return 1; }\n"); - if (!options.missing) mkdirSync(join(root, ".codegraph")); const log = join(base, "calls.log"); + writeFileSync(log, ""); const stale = join(base, "stale"); if (options.stale) writeFileSync(stale, "stale"); + // Readiness reads the index through the read-only provider (ADR-0058); `stale` is its change test. + const provider = fakeReadProvider(root, () => ({ pending: { added: 0, modified: existsSync(stale) ? 1 : 0, removed: 0 } })); + if (options.missing) rmSync(join(root, ".codegraph"), { recursive: true, force: true }); + // The import population is still a CLI query until S2 of #574; nothing else starts CodeGraph. const binary = join(base, "codegraph.cjs"); writeFileSync(binary, ` -const { appendFileSync, existsSync, writeFileSync } = require("node:fs"); +const { appendFileSync, writeFileSync } = require("node:fs"); const args = process.argv.slice(2); appendFileSync(${JSON.stringify(log)}, args[0] + "=" + process.env.CODEGRAPH_NO_DOWNLOAD + "\\n"); if (process.env.CODEGRAPH_NO_DOWNLOAD !== "1") throw new Error("download guard missing"); -if (args[0] === "--version") { console.log(${JSON.stringify(REQUIRED_CODEGRAPH_VERSION)}); process.exit(0); } -if (args[0] === "status") { - console.log(JSON.stringify({ initialized: true, version: ${JSON.stringify(REQUIRED_CODEGRAPH_VERSION)}, projectPath: ${JSON.stringify(root)}, - worktreeMismatch: null, pendingChanges: { added: 0, modified: existsSync(${JSON.stringify(stale)}) ? 1 : 0, removed: 0 }, - index: { builtWithVersion: ${JSON.stringify(REQUIRED_CODEGRAPH_VERSION)}, builtWithExtractionVersion: 7, - currentExtractionVersion: 7, reindexRecommended: false, state: "complete", pendingRefs: 0 } })); process.exit(0); -} if (args[0] === "query") { if (${Boolean(options.queryFails)}) throw new Error("private raw producer error"); if (${Boolean(options.queryMakesStale)}) writeFileSync(${JSON.stringify(stale)}, "stale"); @@ -58,7 +56,7 @@ if (args[0] === "query") { } throw new Error("unexpected command " + args[0]); `); - return { root, binary, calls: () => readFileSync(log, "utf8") }; + return { root, binary, provider, calls: () => readFileSync(log, "utf8") }; } function selectorIndex(): CodeGraphSelectorIndex { @@ -76,13 +74,13 @@ function selectorIndex(): CodeGraphSelectorIndex { } test("full debt reads S2 readiness, guards every shim spawn and closes the readonly selector", () => { - const { root, binary, calls } = fixture(); + const { root, binary, provider, calls } = fixture(); const index = selectorIndex(); const close = spyOn(index, "close"); const old = process.env.CODEGRAPH_NO_DOWNLOAD; process.env.CODEGRAPH_NO_DOWNLOAD = "0"; try { - const evidence = readArchitectureDebtCodeFacts(root, model, { binary, selectorIndexFactory: (path, options) => { + const evidence = readArchitectureDebtCodeFacts(root, model, { binary, provider, selectorIndexFactory: (path, options) => { expect(path).toBe(root); expect(options).toEqual({ readOnly: true }); return index; @@ -92,7 +90,9 @@ test("full debt reads S2 readiness, guards every shim spawn and closes the reado ] }); expect(evidence.flows?.selectors[0]?.status).toBe("proven"); expect(close).toHaveBeenCalledTimes(1); - expect(calls()).toBe("--version=1\nstatus=1\nquery=1\n--version=1\nstatus=1\n"); + // Readiness opened the index read-only twice, around the reads, and started no process. + expect(provider.calls).toMatchObject({ open: 2, close: 2 }); + expect(calls()).toBe("query=1\n"); expect(process.env.CODEGRAPH_NO_DOWNLOAD).toBe("0"); } finally { close.mockRestore(); @@ -101,13 +101,13 @@ test("full debt reads S2 readiness, guards every shim spawn and closes the reado }); for (const [name, options, reason, expectedCalls] of [ - ["missing index", { missing: true }, "index-missing", "--version=1\n"], - ["stale index", { stale: true }, "pending-source-changes", "--version=1\nstatus=1\n"], - ["query refusal", { queryFails: true }, "codegraph-read-failed", "--version=1\nstatus=1\nquery=1\n"], - ["index becomes stale during query", { queryMakesStale: true }, "pending-source-changes", "--version=1\nstatus=1\nquery=1\n--version=1\nstatus=1\n"] + ["missing index", { missing: true }, "index-missing", ""], + ["stale index", { stale: true }, "pending-source-changes", ""], + ["query refusal", { queryFails: true }, "codegraph-read-failed", "query=1\n"], + ["index becomes stale during query", { queryMakesStale: true }, "pending-source-changes", "query=1\n"] ] as const) test(`${name} is unknown coverage without invented dependency/flow debt`, () => { - const { root, binary, calls } = fixture(options); - const evidence = readArchitectureDebtCodeFacts(root, model, { binary, selectorIndexFactory: () => { + const { root, binary, provider, calls } = fixture(options); + const evidence = readArchitectureDebtCodeFacts(root, model, { binary, provider, selectorIndexFactory: () => { if (options.missing || options.stale || options.queryFails) throw new Error("selector must not open"); return selectorIndex(); } }); @@ -120,9 +120,9 @@ for (const [name, options, reason, expectedCalls] of [ }); test("read-only selector refusal is reported without a writable retry", () => { - const { root, binary } = fixture(); + const { root, binary, provider } = fixture(); let opens = 0; - const result = readArchitectureDebtCodeFacts(root, model, { binary, selectorIndexFactory: (_path, options) => { + const result = readArchitectureDebtCodeFacts(root, model, { binary, provider, selectorIndexFactory: (_path, options) => { opens++; expect(options.readOnly).toBe(true); throw new Error("readonly open refused"); @@ -132,11 +132,11 @@ test("read-only selector refusal is reported without a writable retry", () => { }); test("source edits during selector reads invalidate otherwise ready facts and still close the index", () => { - const { root, binary } = fixture(); + const { root, binary, provider } = fixture(); const index = selectorIndex(); const close = spyOn(index, "close"); try { - const result = readArchitectureDebtCodeFacts(root, model, { binary, selectorIndexFactory: () => { + const result = readArchitectureDebtCodeFacts(root, model, { binary, provider, selectorIndexFactory: () => { writeFileSync(join(root, "src/b.ts"), "export function sink() { return 2; }\n"); return index; } }); diff --git a/packages/local-runtime/runtime-daemon/test/flow-selector-check.test.ts b/packages/local-runtime/runtime-daemon/test/flow-selector-check.test.ts index cf36dd34..58804dde 100644 --- a/packages/local-runtime/runtime-daemon/test/flow-selector-check.test.ts +++ b/packages/local-runtime/runtime-daemon/test/flow-selector-check.test.ts @@ -64,7 +64,8 @@ for (const state of ["missing", "incomplete", "ready", "stale", "foreign"] as co if (state === "ready") { expect(result.ok).toBe(true); expect(result.data).toMatchObject({ selectors: [{ status: "proven" }] }); - const cli = fileURLToPath(new URL("../../../surfaces/cli/src/main.ts", import.meta.url)); + // The dev launcher, so the CLI process loads the same SQLite library as this one (#574). + const cli = fileURLToPath(new URL("../../../surfaces/cli/bin/archctx", import.meta.url)); const output = execFileSync(process.execPath, [cli, "flows", "check", "--no-sync", "--json"], { cwd: target, encoding: "utf8" }); expect(JSON.parse(output)).toEqual(result); expect(runFlowSelectorCheckCommand(target, { noSync: true })).toEqual(result); diff --git a/packages/surfaces/cli/bin/archctx b/packages/surfaces/cli/bin/archctx index 18788fe8..c13e91a5 100755 --- a/packages/surfaces/cli/bin/archctx +++ b/packages/surfaces/cli/bin/archctx @@ -1,10 +1,15 @@ #!/usr/bin/env bun +// First: under Bun on darwin, load the read-only-capable SQLite before anything opens SQLite. +import { darwinSqliteLibrary } from "./bun-darwin-sqlite.ts"; import { spawn } from "node:child_process"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { runFastHookEnqueue } from "../src/hook-fast.ts"; -const entry = resolve(dirname(fileURLToPath(import.meta.url)), "../src/main.ts"); +const binDir = dirname(fileURLToPath(import.meta.url)); +const entry = resolve(binDir, "../src/main.ts"); +// The CLI process loads the same SQLite library through a preload of the same module. +const runtimeArgs = darwinSqliteLibrary === undefined ? [] : ["--preload", resolve(binDir, "bun-darwin-sqlite.ts")]; if (process.argv[2] === "hook" && (process.argv[3] ?? "enqueue") === "enqueue") { const result = await runFastHookEnqueue(process.argv.slice(2), process.cwd()); @@ -14,7 +19,7 @@ if (process.argv[2] === "hook" && (process.argv[3] ?? "enqueue") === "enqueue") } } -const child = spawn(process.execPath, [entry, ...process.argv.slice(2)], { +const child = spawn(process.execPath, [...runtimeArgs, entry, ...process.argv.slice(2)], { stdio: "inherit", env: process.env }); diff --git a/packages/surfaces/cli/bin/bun-darwin-sqlite.ts b/packages/surfaces/cli/bin/bun-darwin-sqlite.ts new file mode 100644 index 00000000..6063441c --- /dev/null +++ b/packages/surfaces/cli/bin/bun-darwin-sqlite.ts @@ -0,0 +1,39 @@ +/** + * Development runtime only; never part of the npm tarball (#574, ADR-0058 Decision 8). + * + * Bun on macOS loads the system SQLite, which cannot open a WAL database read-only before its + * `-wal` and `-shm` sidecars exist, and the CodeGraph writer deletes them when it closes. The + * packaged product runs on Node, whose SQLite opens such a database read-only. This module makes + * Bun on darwin load a SQLite library from a closed list instead, so the development CLI and the + * Bun test runner read the CodeGraph index through the same SDK calls as the product. It acts only + * under Bun on darwin and fails loudly when no listed library exists. + * + * Imported first by `bunfig.toml` (test preload) and by `bin/archctx`, which also preloads it into + * the CLI process it starts. + */ +import { existsSync } from "node:fs"; + +/** The only libraries this module loads, in order: Homebrew on Apple silicon, then on Intel. */ +export const DARWIN_SQLITE_LIBRARY_CANDIDATES: readonly string[] = Object.freeze([ + "/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib", + "/usr/local/opt/sqlite/lib/libsqlite3.dylib" +]); + +export function selectDarwinSqliteLibrary(exists: (path: string) => boolean = existsSync): string { + const library = DARWIN_SQLITE_LIBRARY_CANDIDATES.find(path => exists(path)); + if (library === undefined) { + throw new Error(`Bun on macOS needs a SQLite library that opens a WAL database read-only; none of ${DARWIN_SQLITE_LIBRARY_CANDIDATES.join(", ")} exists. Install it with \`brew install sqlite\`.`); + } + return library; +} + +/** The library this process now uses, or `undefined` when the runtime is not Bun on darwin. */ +export async function loadDarwinSqliteForBun(): Promise { + if (process.platform !== "darwin" || process.versions.bun === undefined) return undefined; + const library = selectDarwinSqliteLibrary(); + const { Database } = await import("bun:sqlite"); + Database.setCustomSQLite(library); + return library; +} + +export const darwinSqliteLibrary = await loadDarwinSqliteForBun(); diff --git a/packages/surfaces/cli/src/main.ts b/packages/surfaces/cli/src/main.ts index 1560fcdf..36b4719c 100755 --- a/packages/surfaces/cli/src/main.ts +++ b/packages/surfaces/cli/src/main.ts @@ -3448,7 +3448,10 @@ async function startBackgroundDaemon(args: string[], cwd: string) { let childExit: { code: number | null; signal: NodeJS.Signals | null } | undefined; let childError: Error | undefined; const idleTimeoutFlag = readFlag(args, "--idle-timeout-ms"); + // Under Bun the runtime flags (the dev SQLite preload, #574) carry over to the daemon. Under + // Node nothing extra is passed. const child = spawn(process.execPath, [ + ...(process.versions.bun === undefined ? [] : process.execArgv), CLI_ENTRY, "daemon", "start", diff --git a/packages/surfaces/cli/test/code-facts-not-ready.test.ts b/packages/surfaces/cli/test/code-facts-not-ready.test.ts index 6d56e738..cad73af8 100644 --- a/packages/surfaces/cli/test/code-facts-not-ready.test.ts +++ b/packages/surfaces/cli/test/code-facts-not-ready.test.ts @@ -13,55 +13,57 @@ import { ArchctxDaemon } from "@archcontext/local-runtime/runtime-daemon"; import { runArchitectureDebtCommand } from "@archcontext/local-runtime/architecture-debt"; import { TestLocalStore } from "@archcontext/local-runtime/test/local-store-factories"; import { incompleteCodeGraphFixture } from "../../../local-runtime/codegraph-adapter/test/real-index"; +import { fakeReadProvider, type FakeIndexFacts } from "../../../local-runtime/codegraph-adapter/test/read-provider-fixture"; import { runCli } from "../src/main"; +/** + * Readiness reads the index through the injected read-only provider (ADR-0058); it starts no + * CodeGraph process. The fake binary only builds (`sync`), and logs every start, so a test proves + * that no readiness probe spawned anything. `facts.json` is the index the provider reports; the + * fake `sync` rewrites it, as a real sync rewrites the index. + */ function fixture(allowSync = false, clearPending = false) { const root = realpathSync.native(mkdtempSync(join(tmpdir(), "archctx-code-facts-not-ready-"))); execFileSync("git", ["init", "-q"], { cwd: root }); initializeArchContextModel(root, "Code facts readiness"); execFileSync("git", ["add", "."], { cwd: root }); execFileSync("git", ["-c", `core.hooksPath=${join(root, ".git/no-hooks")}`, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-qm", "fixture"], { cwd: root }); - mkdirSync(join(root, ".codegraph")); const calls = join(root, "calls.log"); + writeFileSync(calls, ""); const binary = join(root, "codegraph-fixture.cjs"); - const status = { - initialized: true, version: REQUIRED_CODEGRAPH_VERSION, projectPath: root, - pendingChanges: { added: 0, modified: 0, removed: 0 }, worktreeMismatch: null, - index: { builtWithVersion: "1.6.1", builtWithExtractionVersion: 24, currentExtractionVersion: 24, reindexRecommended: false, state: "complete", pendingRefs: 0 } - }; - const statusPath = join(root, "codegraph-status.json"); - writeFileSync(statusPath, JSON.stringify(status)); + const facts: FakeIndexFacts = { builtWithVersion: "1.6.1", builtWithExtractionVersion: 24, currentExtractionVersion: 24 }; + const factsPath = join(root, "codegraph-facts.json"); + writeFileSync(factsPath, JSON.stringify(facts)); + const provider = fakeReadProvider(root, () => JSON.parse(readFileSync(factsPath, "utf8")) as FakeIndexFacts); writeFileSync(binary, ` const {appendFileSync, readFileSync, writeFileSync} = require("node:fs"); const command = process.argv[2]; appendFileSync(${JSON.stringify(calls)}, command + "\\n"); -if (command === "--version") { console.log(${JSON.stringify(REQUIRED_CODEGRAPH_VERSION)}); } -else if (command === "status") { process.stdout.write(readFileSync(${JSON.stringify(statusPath)}, "utf8")); } -else if (command === "sync" && ${allowSync}) { +if (command === "sync" && ${allowSync}) { if (${clearPending}) { - const status = JSON.parse(readFileSync(${JSON.stringify(statusPath)}, "utf8")); - status.pendingChanges = {added: 0, modified: 0, removed: 0}; - writeFileSync(${JSON.stringify(statusPath)}, JSON.stringify(status)); + const facts = JSON.parse(readFileSync(${JSON.stringify(factsPath)}, "utf8")); + facts.pending = {added: 0, modified: 0, removed: 0}; + writeFileSync(${JSON.stringify(factsPath)}, JSON.stringify(facts)); } process.exit(0); } -else { console.error("private raw process output /outside/repository"); process.exit(1); } +console.error("private raw process output /outside/repository"); process.exit(1); `); - return { root, calls, binary, status, statusPath, cleanup: () => rmSync(root, { recursive: true, force: true }) }; + const setFacts = (next: FakeIndexFacts) => writeFileSync(factsPath, JSON.stringify(next)); + return { root, calls, binary, facts, setFacts, provider, cleanup: () => rmSync(root, { recursive: true, force: true }) }; } test.each(["complete", "prepare", "prepare-empty-budget", "complete-subdirectory", "prepare-subdirectory", "checkpoint"])("%s refuses a version-mismatched index with the doctor issues", async command => { const input = fixture(); const store = new TestLocalStore(); - const daemon = new ArchctxDaemon({ localStore: store, codeFacts: new CodeGraphAdapter(new CodeGraphCliProvider(input.root, input.binary)) }); + const daemon = new ArchctxDaemon({ localStore: store, codeFacts: new CodeGraphAdapter(new CodeGraphCliProvider(input.root, input.binary, input.provider)) }); try { - const doctor = inspectRepositoryCodeFacts(input.root, { binary: input.binary }); - writeFileSync(input.calls, ""); + const doctor = inspectRepositoryCodeFacts(input.root, { provider: input.provider }); await daemon.start(); const cwd = command.endsWith("subdirectory") ? join(input.root, "nested") : input.root; mkdirSync(cwd, { recursive: true }); const result = command === "checkpoint" - ? runArchitectureDebtCommand(input.root, {}, { binary: input.binary }) + ? runArchitectureDebtCommand(input.root, {}, { provider: input.provider }) : await runCli(command.startsWith("prepare") ? "prepare" : "complete", command === "prepare-empty-budget" ? ["--task", "Inspect readiness", "--max-items", "0"] : command.startsWith("prepare") ? ["--task", "Inspect readiness"] : [], cwd, { runtimeClient: daemon }); expect(result).toMatchObject({ ok: false, error: { @@ -71,20 +73,19 @@ test.each(["complete", "prepare", "prepare-empty-budget", "complete-subdirectory expect(failureIssues("error" in result ? result.error : undefined)).toEqual([]); expect(JSON.stringify(result)).not.toContain("private raw process output"); expect(JSON.stringify(result)).not.toContain(input.root); - expect(readFileSync(input.calls, "utf8")).toBe("--version\nstatus\n"); + // Readiness started no CodeGraph process. + expect(readFileSync(input.calls, "utf8")).toBe(""); expect(await store.listRepositorySessions()).toEqual([]); } finally { await daemon.stop(); input.cleanup(); } }); -test("CLI checkpoint refuses a present uninitialized index without a runtime", async () => { +test("CLI checkpoint refuses a .codegraph without a database as index-missing, without a runtime", async () => { const input = fixture(); try { + rmSync(join(input.root, ".codegraph", "codegraph.db")); const result = await runCli("checkpoint", [], input.root); - const doctor = inspectRepositoryCodeFacts(input.root); - expect("issues" in doctor && doctor.issues.map(issue => issue.code)).toContain("not-initialized"); - expect(result).toMatchObject({ ok: false, error: { code: "AC_CODE_FACTS_NOT_READY", details: { - issues: "issues" in doctor ? doctor.issues : [] - } } }); + expect(inspectRepositoryCodeFacts(input.root)).toMatchObject({ state: "index-missing", indexPresent: false }); + expect(result).toMatchObject({ ok: false, error: { code: "AC_CODE_FACTS_UNAVAILABLE", reasonCode: "index-missing" } }); rmSync(join(input.root, ".codegraph"), { recursive: true }); expect(await runCli("checkpoint", [], input.root)).toMatchObject({ ok: true }); } finally { input.cleanup(); } @@ -93,39 +94,37 @@ test("CLI checkpoint refuses a present uninitialized index without a runtime", a test("checkpoint accepts pending edits without sync but still refuses an incompatible index", () => { const input = fixture(); try { - const pending = { ...input.status, pendingChanges: { added: 1, modified: 1, removed: 0 }, - index: { ...input.status.index, builtWithVersion: REQUIRED_CODEGRAPH_VERSION } }; - writeFileSync(input.statusPath, JSON.stringify(pending)); - const doctor = inspectRepositoryCodeFacts(input.root, { binary: input.binary }); + const pending = { ...input.facts, builtWithVersion: REQUIRED_CODEGRAPH_VERSION, pending: { added: 1, modified: 1, removed: 0 } }; + input.setFacts(pending); + const doctor = inspectRepositoryCodeFacts(input.root, { provider: input.provider }); expect(doctor).toMatchObject({ state: "not-ready", reindexRecommended: false }); - writeFileSync(input.calls, ""); - expect(runArchitectureDebtCommand(input.root, {}, { binary: input.binary })).toMatchObject({ ok: true }); - expect(readFileSync(input.calls, "utf8")).toBe("--version\nstatus\n"); + expect(runArchitectureDebtCommand(input.root, {}, { provider: input.provider })).toMatchObject({ ok: true }); - writeFileSync(input.statusPath, JSON.stringify({ ...pending, index: input.status.index })); - const mixedDoctor = inspectRepositoryCodeFacts(input.root, { binary: input.binary }); + input.setFacts({ ...pending, builtWithVersion: "1.6.1" }); + const mixedDoctor = inspectRepositoryCodeFacts(input.root, { provider: input.provider }); expect("issues" in mixedDoctor && mixedDoctor.issues.map(issue => issue.code)).toEqual(["version-mismatch", "pending-source-changes"]); - writeFileSync(input.calls, ""); - expect(runArchitectureDebtCommand(input.root, {}, { binary: input.binary })).toMatchObject({ ok: false, error: { + // The gate reads compatibility only: it refuses with the compatibility issue and reads no source. + const hashes = input.provider.calls.hash; + expect(runArchitectureDebtCommand(input.root, {}, { provider: input.provider })).toMatchObject({ ok: false, error: { code: "AC_CODE_FACTS_NOT_READY", action: "codegraph-index", - details: { issues: "issues" in mixedDoctor ? mixedDoctor.issues : [] } + details: { issues: [{ code: "version-mismatch", message: "CodeGraph repository status version mismatch" }] } } }); - expect(readFileSync(input.calls, "utf8")).toBe("--version\nstatus\n"); + expect(input.provider.calls.hash).toBe(hashes); + expect(readFileSync(input.calls, "utf8")).toBe(""); } finally { input.cleanup(); } }); test("a cached code-facts snapshot cannot hide a later index version mismatch", async () => { const input = fixture(true); try { - const adapter = new CodeGraphAdapter(new CodeGraphCliProvider(input.root, input.binary)); + const adapter = new CodeGraphAdapter(new CodeGraphCliProvider(input.root, input.binary, input.provider)); const workspace = { root: input.root, repositoryId: "repo.fixture", headSha: "a".repeat(40) }; - const ready = { ...input.status, index: { ...input.status.index, builtWithVersion: REQUIRED_CODEGRAPH_VERSION } }; - writeFileSync(input.statusPath, JSON.stringify(ready)); + input.setFacts({ ...input.facts, builtWithVersion: REQUIRED_CODEGRAPH_VERSION }); await adapter.ensureReady(workspace); - writeFileSync(input.statusPath, JSON.stringify(input.status)); + input.setFacts(input.facts); writeFileSync(input.calls, ""); await expect(adapter.ensureReady(workspace)).rejects.toMatchObject({ code: "AC_CODE_FACTS_NOT_READY" }); - expect(readFileSync(input.calls, "utf8")).toBe("--version\nstatus\n"); + expect(readFileSync(input.calls, "utf8")).toBe(""); } finally { input.cleanup(); } }); @@ -133,20 +132,19 @@ for (const cached of [false, true]) { test.each([false, true])(`pending edits preserve cold sync and cached reads (cached=${cached}, clears=%s)`, async clearPending => { const input = fixture(true, clearPending); try { - const adapter = new CodeGraphAdapter(new CodeGraphCliProvider(input.root, input.binary)); + const adapter = new CodeGraphAdapter(new CodeGraphCliProvider(input.root, input.binary, input.provider)); const workspace = { root: input.root, repositoryId: "repo.fixture", headSha: "a".repeat(40) }; - const ready = { ...input.status, index: { ...input.status.index, builtWithVersion: REQUIRED_CODEGRAPH_VERSION } }; - writeFileSync(input.statusPath, JSON.stringify(ready)); + const ready = { ...input.facts, builtWithVersion: REQUIRED_CODEGRAPH_VERSION }; + input.setFacts(ready); if (cached) await adapter.ensureReady(workspace); - writeFileSync(input.statusPath, JSON.stringify({ ...ready, pendingChanges: { added: 1, modified: 0, removed: 0 } })); + input.setFacts({ ...ready, pending: { added: 1, modified: 0, removed: 0 } }); writeFileSync(input.calls, ""); await expect(adapter.ensureReady(workspace)).resolves.toMatchObject({ provider: "codegraph" }); - expect(readFileSync(input.calls, "utf8").trim().split("\n")).toEqual(cached - ? ["--version", "status"] : ["--version", "status", "sync", "--version", "status"]); + // Only the build starts CodeGraph; readiness reads the index in process. + expect(readFileSync(input.calls, "utf8")).toBe(cached ? "" : "sync\n"); if (cached) { - writeFileSync(input.calls, ""); await expect(adapter.sync({ workspace })).resolves.toMatchObject({ provider: "codegraph" }); - expect(readFileSync(input.calls, "utf8").trim().split("\n")).toEqual(["--version", "status", "sync", "--version", "status"]); + expect(readFileSync(input.calls, "utf8")).toBe("sync\n"); } } finally { input.cleanup(); } }); @@ -180,15 +178,11 @@ test("projection snapshot preparation refuses an indexing version-mismatched ind const input = fixture(); try { // The reported #425 state: an index still being built, with no compatible build version. - writeFileSync(input.statusPath, JSON.stringify({ - ...input.status, - index: { ...input.status.index, state: "indexing", pendingRefs: 128 } - })); - const doctor = inspectRepositoryCodeFacts(input.root, { binary: input.binary }); + input.setFacts({ ...input.facts, state: "indexing", pendingRefs: 128 }); + const doctor = inspectRepositoryCodeFacts(input.root, { provider: input.provider }); expect("issues" in doctor && doctor.issues.map(issue => issue.code)).toEqual(["version-mismatch", "index-incomplete"]); - writeFileSync(input.calls, ""); let failure: unknown; - try { prepareArchitectureDocumentationProjectionSnapshot(input.root, loadNativeModelFromArchContext(input.root), { binary: input.binary }); } + try { prepareArchitectureDocumentationProjectionSnapshot(input.root, loadNativeModelFromArchContext(input.root), { binary: input.binary, readProvider: input.provider }); } catch (error) { failure = error; } expect(failure).toMatchObject({ code: "AC_CODE_FACTS_NOT_READY", reasonCode: "code-facts-not-ready", retryable: false, action: "codegraph-index", @@ -197,14 +191,15 @@ test("projection snapshot preparation refuses an indexing version-mismatched ind expect(failureIssues((failure as { failure?: any })?.failure)).toEqual([]); expect(JSON.stringify(failure)).not.toContain("private raw process output"); expect(JSON.stringify(failure)).not.toContain(input.root); - // The readiness probe runs version and status only: no sync, rebuild or retry. - expect(readFileSync(input.calls, "utf8")).toBe("--version\nstatus\n"); + // The readiness gate starts no process: no version probe, status, sync, rebuild or retry. + expect(readFileSync(input.calls, "utf8")).toBe(""); } finally { input.cleanup(); } }); test("CLI projection run check classifies an incomplete index instead of failing unclassified", async () => { const input = fixture(); try { + rmSync(join(input.root, ".codegraph"), { recursive: true, force: true }); trimProjectionFixtureModel(input.root); // A real package-local index with no completed extraction stamps reproduces the reported // not-ready state: version-mismatch and index-incomplete, without a shim. @@ -217,7 +212,8 @@ test("CLI projection run check classifies an incomplete index instead of failing const result = await runCli("projection", ["run", "--request-json", JSON.stringify(projectionCheckRequest(input.root, "projection_request.code_facts_not_ready"))], input.root); expect(result).toMatchObject({ ok: false, error: { code: "AC_CODE_FACTS_NOT_READY", reasonCode: "code-facts-not-ready", retryable: false, action: "codegraph-index", - details: { issues: "issues" in doctor ? doctor.issues : [] } + // The gate reads compatibility only, so its issues omit the doctor's freshness issue. + details: { issues: "issues" in doctor ? doctor.issues.filter(issue => issue.code !== "pending-source-changes") : [] } } }); expect((result as { error?: { code?: string } }).error?.code).not.toBe("AC_UNCLASSIFIED_FAILURE"); expect(JSON.stringify(result)).not.toContain(input.root); diff --git a/packages/surfaces/cli/test/doctor-code-facts.test.ts b/packages/surfaces/cli/test/doctor-code-facts.test.ts index b938b704..c1395d7e 100644 --- a/packages/surfaces/cli/test/doctor-code-facts.test.ts +++ b/packages/surfaces/cli/test/doctor-code-facts.test.ts @@ -52,13 +52,12 @@ describe("archctx doctor code facts", () => { }); expect(withoutIndex.ok).toBe(expectedOk(withoutIndex)); - // An empty `.codegraph` directory makes `codegraph status -j` report `initialized: false`, so - // code facts are deterministically not-ready. It is informational: doctor's own `ok` must not move. + // An empty `.codegraph` directory holds no database, so code facts are index-missing (ADR-0058). + // It is informational: doctor's own `ok` must not move. mkdirSync(join(root, ".codegraph")); const brokenIndex = doctorJson(root, stateDir).data; - expect(brokenIndex.codeFacts.state).toBe("not-ready"); - expect(brokenIndex.codeFacts.indexPresent).toBe(true); - expect(brokenIndex.codeFacts.issues.map((issue: { code: string }) => issue.code)).toContain("not-initialized"); + expect(brokenIndex.codeFacts.state).toBe("index-missing"); + expect(brokenIndex.codeFacts.indexPresent).toBe(false); expect(brokenIndex.ok).toBe(withoutIndex.ok); expect(brokenIndex.ok).toBe(expectedOk(brokenIndex)); } finally { diff --git a/packages/surfaces/cli/test/projection-protocol-lifecycle.test.ts b/packages/surfaces/cli/test/projection-protocol-lifecycle.test.ts index e8e675a4..a13f0f76 100644 --- a/packages/surfaces/cli/test/projection-protocol-lifecycle.test.ts +++ b/packages/surfaces/cli/test/projection-protocol-lifecycle.test.ts @@ -292,7 +292,8 @@ async function withProtocolFixture(prefix: string, run: (context: { } finally { await daemon.stop(); rmSync(stateRoot, { recursive: true, force: true }); - rmSync(root, { recursive: true, force: true }); + // This process holds no file under root after stop(); a transient Windows hold outside it is retried. + rmSync(root, { recursive: true, force: true, maxRetries: process.platform === "win32" ? 5 : 0, retryDelay: 100 }); } } diff --git a/packages/surfaces/cli/test/ref-impact.test.ts b/packages/surfaces/cli/test/ref-impact.test.ts index e7c94b57..e035692f 100644 --- a/packages/surfaces/cli/test/ref-impact.test.ts +++ b/packages/surfaces/cli/test/ref-impact.test.ts @@ -10,6 +10,9 @@ import { type RefImpactV1 } from "@archcontext/contracts"; import { createNativeFixture, commitFixture, writeFixtureFile } from "../../../local-runtime/model-store-yaml/test/fixtures/native-context/fixture"; import { packagedCodeGraphCliInvocation } from "../../../local-runtime/codegraph-adapter/src/index"; +// The dev launcher, so the CLI process loads the same SQLite library as the test process (#574). +const CLI_LAUNCHER = fileURLToPath(new URL("../bin/archctx", import.meta.url)); + function indexBytes(root: string): Record { const files: Record = {}; const visit = (path: string) => { @@ -42,7 +45,7 @@ test("real CLI binds CodeGraph ranges to Git blobs without touching the source i execFileSync(provider.command, [...provider.argsPrefix, "init", fixture.root], { cwd: fixture.root, env: providerEnv, stdio: "pipe" }); const frozen = indexBytes(fixture.root); const state = join(fixture.root, "runtime-state"); - const invoke = () => execFileSync(process.execPath, [fileURLToPath(new URL("../src/main.ts", import.meta.url)), "impact", "--base", base, "--head", head, "--json"], { + const invoke = () => execFileSync(process.execPath, [CLI_LAUNCHER, "impact", "--base", base, "--head", head, "--json"], { cwd: fixture.root, encoding: "utf8", env: { ...providerEnv, ARCHCONTEXT_STATE_DIR: state } }); const bytes = invoke(); @@ -72,7 +75,7 @@ test("real CodeGraph distinguishes ambiguous declarations from a renamed selecto const env = { ...process.env, DO_NOT_TRACK: "1", CODEGRAPH_NO_DOWNLOAD: "1" }; execFileSync(provider.command, [...provider.argsPrefix, "init", fixture.root], { cwd: fixture.root, env, stdio: "pipe" }); const frozen = indexBytes(fixture.root); - const invoke = (ref: string, base = ref) => JSON.parse(execFileSync(process.execPath, [fileURLToPath(new URL("../src/main.ts", import.meta.url)), "impact", "--base", base, "--head", ref, "--json"], { cwd: fixture.root, env, encoding: "utf8" })); + const invoke = (ref: string, base = ref) => JSON.parse(execFileSync(process.execPath, [CLI_LAUNCHER, "impact", "--base", base, "--head", ref, "--json"], { cwd: fixture.root, env, encoding: "utf8" })); const ambiguous = invoke(head); expect(ambiguous.ok).toBe(true); expect(ambiguous.data.touchedFlowSteps).toMatchObject({ status: "unknown", reasonCodes: ["selector-evidence-unavailable"] }); @@ -107,7 +110,7 @@ describe("impact CLI", () => { const text = paths.join(newline) + newline + newline; writeFixtureFile(fixture.root, "paths.txt", text); const state = join(fixture.root, "runtime-state"); - const invoke = (source: string): RefImpactV1 => JSON.parse(execFileSync(process.execPath, [fileURLToPath(new URL("../src/main.ts", import.meta.url)), "impact", "--paths-from", source, "--json"], { cwd: fixture.root, encoding: "utf8", input: text, env: { ...process.env, ARCHCONTEXT_STATE_DIR: state } })).data; + const invoke = (source: string): RefImpactV1 => JSON.parse(execFileSync(process.execPath, [CLI_LAUNCHER, "impact", "--paths-from", source, "--json"], { cwd: fixture.root, encoding: "utf8", input: text, env: { ...process.env, ARCHCONTEXT_STATE_DIR: state } })).data; for (const source of ["paths.txt", "-"]) { const data = invoke(source); expect(data.changedPaths.map(row => row.path).sort()).toEqual([...paths].sort()); @@ -133,10 +136,10 @@ describe("impact CLI", () => { expect(result.ok).toBe(true); expect((result as any).data.touchedNodes).toEqual([{ nodeId: "component.demo.worker", pathCount: 1 }]); expect((result as any).data.touchedFlowSteps.status).toBe("unknown"); - const bytes = execFileSync(process.execPath, [fileURLToPath(new URL("../src/main.ts", import.meta.url)), "impact", "--base", fixture.commit, "--head", head, "--json"], { cwd: fixture.root, encoding: "utf8" }); + const bytes = execFileSync(process.execPath, [CLI_LAUNCHER, "impact", "--base", fixture.commit, "--head", head, "--json"], { cwd: fixture.root, encoding: "utf8" }); const processResult = JSON.parse(bytes); expect(processResult).toEqual(result); - const help = JSON.parse(execFileSync(process.execPath, [fileURLToPath(new URL("../src/main.ts", import.meta.url)), "impact", "--help", "--json"], { cwd: fixture.root, encoding: "utf8" })); + const help = JSON.parse(execFileSync(process.execPath, [CLI_LAUNCHER, "impact", "--help", "--json"], { cwd: fixture.root, encoding: "utf8" })); expect(help.data.command).toBe("impact"); } finally { fixture.cleanup(); } }); diff --git a/scripts/codegraph-selector-node-integration.mjs b/scripts/codegraph-selector-node-integration.mjs index 46b33787..f7b9b48f 100644 --- a/scripts/codegraph-selector-node-integration.mjs +++ b/scripts/codegraph-selector-node-integration.mjs @@ -1,10 +1,11 @@ #!/usr/bin/env node import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { delimiter, dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -218,6 +219,56 @@ try { assert.deepEqual(indexFiles(), before, `private Node snapshot must preserve original DB and sidecars, liveWal=${liveWal}`); } finally { holder?.close(); } } + // ADR-0058: the read session under Node, in this process, with no holder: the writer of the + // last sync removed its WAL sidecars, and Node's read-only open must still succeed. + const gitAdded = spawnSync("git", ["add", "-A"], { cwd: fixtureRoot, encoding: "utf8" }); + assert.equal(gitAdded.status, 0, gitAdded.stderr); + const gitIndex = join(fixtureRoot, ".git", "index"); + const database = join(fixtureRoot, ".codegraph", "codegraph.db"); + const digestOf = (path) => createHash("sha256").update(readFileSync(path)).digest("hex"); + const beforeRead = { gitIndex: statSync(gitIndex).mtimeMs, database: digestOf(database) }; + const spy = process.platform === "win32" ? undefined : gitSpy(); + let gitCommands = []; + let readyFacts; + let session; + try { + readyFacts = adapter.inspectRepositoryCodeFacts(fixtureRoot); + adapter.assertRepositoryCodeFactsReady(fixtureRoot); + session = adapter.withCodeGraphReadSession(fixtureRoot, (opened) => opened.state === "open" + ? { version: opened.session.version(), compatibility: opened.session.compatibility(), freshness: opened.session.freshness() } + : { state: opened.state }); + gitCommands = spy?.commands() ?? []; + } finally { spy?.restore(); } + assert.equal(readyFacts.state, "ready", JSON.stringify(readyFacts)); + assert.deepEqual(readyFacts.pendingChanges, { added: 0, modified: 0, removed: 0 }); + assert.equal(session.version, adapter.REQUIRED_CODEGRAPH_VERSION, JSON.stringify(session)); + assert.equal(session.compatibility.state, "complete"); + assert.equal(session.compatibility.builtWithVersion, adapter.REQUIRED_CODEGRAPH_VERSION); + assert.deepEqual(session.freshness, { added: 0, modified: 0, removed: 0 }); + if (spy) { + assert.ok(gitCommands.some(command => command.startsWith("ls-files")), "the git spy must see the provider enumeration"); + assert.deepEqual(gitCommands.filter(command => /^(status|diff)\b/.test(command)), [], "a read runs no git status or diff"); + } + assert.deepEqual({ gitIndex: statSync(gitIndex).mtimeMs, database: digestOf(database) }, beforeRead, "a read changes neither .git/index nor the database"); + const sources = { edited: join(fixtureRoot, "src", "util.ts"), removed: join(fixtureRoot, "src", "imported.ts"), added: join(fixtureRoot, "src", "added.ts") }; + const originals = { edited: readFileSync(sources.edited), removed: readFileSync(sources.removed) }; + try { + writeFileSync(sources.edited, "export function resolve(value: string): string { return value + value; }\n"); + unlinkSync(sources.removed); + writeFileSync(sources.added, "export const added = true;\n"); + const pending = adapter.inspectRepositoryCodeFacts(fixtureRoot); + assert.equal(pending.state, "not-ready"); + assert.deepEqual(pending.pendingChanges, { added: 1, modified: 1, removed: 1 }); + assert.deepEqual(pending.issues.map(issue => issue.code), ["pending-source-changes"]); + // The gate reads compatibility only, so pending edits pass it. + adapter.assertRepositoryCodeFactsReady(fixtureRoot); + } finally { + writeFileSync(sources.edited, originals.edited); + writeFileSync(sources.removed, originals.removed); + rmSync(sources.added, { force: true }); + } + assert.equal(adapter.inspectRepositoryCodeFacts(fixtureRoot).state, "ready"); + const beforeStale = indexFiles(); writeFileSync(join(fixtureRoot, "src/render.ts"), 'export function renderBlock() { return "changed"; }\n'); assert.throws(() => adapter.withExistingCodeGraphSnapshot(fixtureRoot, { names: [], sources: [], includeFiles: false }, () => true), error => error.reasonCode === "code-facts-read-not-ready"); @@ -229,9 +280,30 @@ try { nodeVersion: process.version, codeGraphVersion: adapter.REQUIRED_CODEGRAPH_VERSION, privateSnapshots: "closed-and-live-wal-preserved; stale-refused", + readSession: "ready-without-holder; pending-counted; gate-compatibility-only; no-git-status", callSite: { path: "src/main.ts", line: 4 } })); } finally { rmSync(fixtureRoot, { recursive: true, force: true }); rmSync(buildRoot, { recursive: true, force: true }); } + +/** A `git` first on PATH that logs each command and runs the real one. POSIX only. */ +function gitSpy() { + const dir = mkdtempSync(join(tmpdir(), "archctx-git-spy-")); + const log = join(dir, "git.log"); + const real = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim(); + writeFileSync(log, ""); + writeFileSync(join(dir, "git"), `#!/bin/sh\nprintf '%s\\n' "$*" >> '${log}'\nexec '${real}' "$@"\n`); + chmodSync(join(dir, "git"), 0o755); + const previous = process.env.PATH; + process.env.PATH = `${dir}${delimiter}${previous ?? ""}`; + return { + // `-C ` is a location, not the command. + commands: () => readFileSync(log, "utf8").split("\n").filter(Boolean).map(line => line.replace(/^-C \S+ /, "")), + restore: () => { + process.env.PATH = previous; + rmSync(dir, { recursive: true, force: true }); + } + }; +}