From dea5880c22cf2ffef3de24ff957e37a4213ba115 Mon Sep 17 00:00:00 2001 From: Ancienttwo <215803476+Ancienttwo@users.noreply.github.com> Date: Fri, 13 Nov 2026 16:50:00 +0800 Subject: [PATCH 01/10] feat(codegraph-adapter): one in-process read-only read session (#574 S1) Draft for the CI probe: readiness reads the index through openCodeGraphReadSession; Bun on darwin loads a read-only-capable SQLite through packages/surfaces/cli/bin/bun-darwin-sqlite.ts (test preload and dev bin/archctx). Refs #574 --- bunfig.toml | 2 +- packages/contracts/src/failure.ts | 1 + .../codegraph-adapter/src/index.ts | 343 +++++++++++------- .../codegraph-adapter/src/read-session.ts | 271 ++++++++++++++ .../codegraph-adapter/test/build-lock.test.ts | 73 ++-- .../test/codegraph-adapter.test.ts | 5 +- .../test/projection-codegraph-status.test.ts | 247 +++++-------- .../test/read-provider-fixture.ts | 87 +++++ .../src/architecture-debt-code-facts.ts | 12 +- .../test/architecture-debt-code-facts.test.ts | 48 +-- packages/surfaces/cli/bin/archctx | 9 +- .../surfaces/cli/bin/bun-darwin-sqlite.ts | 43 +++ .../cli/test/code-facts-not-ready.test.ts | 116 +++--- 13 files changed, 833 insertions(+), 424 deletions(-) create mode 100644 packages/local-runtime/codegraph-adapter/src/read-session.ts create mode 100644 packages/local-runtime/codegraph-adapter/test/read-provider-fixture.ts create mode 100644 packages/surfaces/cli/bin/bun-darwin-sqlite.ts 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..1b3d914d 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,7 +1194,8 @@ function digestFile(path: string): string { } } -export interface RepositoryCodeFactsInspectionOptions { +/** Options of the private snapshot's CLI probe; S4 of #574 removes them with the snapshot. */ +export interface CodeGraphSnapshotOptions { binary?: string; timeouts?: { versionMs?: number; statusMs?: number }; } @@ -1214,7 +1227,7 @@ export function withExistingCodeGraphSnapshot( root: string, queries: SnapshotQueryRequest, callback: (graph: SnapshotQueryReader) => T, - options: RepositoryCodeFactsInspectionOptions = {} + options: CodeGraphSnapshotOptions = {} ): 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,7 +1260,7 @@ 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 = inspectSnapshotCodeFactsByCli(snapshotRoot, options); if (ready.state !== "ready") { if (ready.state === "unavailable") throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable"); throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-read-not-ready", { issues: ready.issues }); @@ -1454,17 +1467,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 +1500,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 +1508,34 @@ 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, + extraIssues: ProjectionCodeGraphStatusIssueV1[], + measuresFreshness: boolean +): RepositoryCodeFactsV1 { + const issues = [...extraIssues, ...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,11 +1553,134 @@ 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); +} + +function inspectRepositoryCodeFactsTier(root: string, tier: "compatibility" | "freshness", options: RepositoryCodeFactsInspectionOptions): RepositoryCodeFactsV1 { + let provider: CodeGraphReadProvider; + let codeGraphVersion: string; + try { + provider = options.provider ?? pinnedCodeGraphProvider(); + codeGraphVersion = provider.version; + } catch (error) { + return unavailableCodeFacts("codegraph-unavailable", error instanceof CodeGraphProviderUnavailableError ? error.message : "CodeGraph provider could not be loaded"); + } + try { + return withCodeGraphReadSession(root, (opened): RepositoryCodeFactsV1 => { + switch (opened.state) { + case "index-missing": + return { schemaVersion: REPOSITORY_CODE_FACTS_SCHEMA, state: "index-missing", indexPresent: false, requiredVersion: REQUIRED_CODEGRAPH_VERSION, codeGraphVersion, issues: versionIssuesOf(codeGraphVersion) }; + case "index-open-failed": + return unavailableCodeFacts("index-open-failed", INDEX_OPEN_FAILED_MESSAGE); + case "build-in-progress": + return buildInProgressCodeFacts(codeGraphVersion, versionIssuesOf(codeGraphVersion)); + case "not-initialized": + return repositoryCodeFactsFromStatus(root, codeGraphVersion, { initialized: false, version: codeGraphVersion, projectPath: root, lastIndexed: null }, [], tier === "freshness"); + case "open": { + const compatibility = opened.session.compatibility(); + const status: CodeGraphProjectionStatusV1 = { + initialized: true, + version: compatibility.version, + projectPath: compatibility.projectPath, + lastIndexed: compatibility.lastIndexed, + ...(tier === "freshness" ? { pendingChanges: opened.session.freshness() } : {}), + // `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 repositoryCodeFactsFromStatus(root, codeGraphVersion, status, [], tier === "freshness"); + } + } + }, { provider }); + } catch { + // The index opened but a read of it failed; no provider text crosses this boundary. + return unavailableCodeFacts("index-open-failed", INDEX_OPEN_FAILED_MESSAGE); + } +} + +/** + * The private snapshot's readiness probe: `codegraph --version` and `codegraph status -j` against + * the snapshot copy, never the original root. S4 of #574 removes it together with the snapshot. + */ +function inspectSnapshotCodeFactsByCli(root: string, options: CodeGraphSnapshotOptions): RepositoryCodeFactsV1 { + let codeGraphVersion = ""; + let status: CodeGraphProjectionStatusV1 | undefined; + 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) { + if (codeGraphIndexBuildInProgress(root)) return buildInProgressCodeFacts(codeGraphVersion, versionIssuesOf(codeGraphVersion)); + status = readProjectionCodeGraphStatus(invocation, root, options.timeouts?.statusMs ?? 10_000, CODEGRAPH_NO_DOWNLOAD_ENV); + } + } catch (error) { + return unavailableCodeFacts("codegraph-unavailable", (error instanceof Error ? error.message : String(error)).replace(/\s+/g, " ").trim().slice(0, 400)); + } + if (!indexPresent) { + return { schemaVersion: REPOSITORY_CODE_FACTS_SCHEMA, state: "index-missing", indexPresent: false, requiredVersion: REQUIRED_CODEGRAPH_VERSION, codeGraphVersion, issues: versionIssuesOf(codeGraphVersion) }; + } + if (typeof status !== "object" || status === null || Array.isArray(status)) return unavailableCodeFacts("codegraph-unavailable", "CodeGraph status JSON is not an object"); + return repositoryCodeFactsFromStatus(root, codeGraphVersion, status, versionIssuesOf(codeGraphVersion), true); +} + +/** + * 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 = 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 }); + } 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" 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..f531c955 --- /dev/null +++ b/packages/local-runtime/codegraph-adapter/src/read-session.ts @@ -0,0 +1,271 @@ +import { productVersionManifest } from "@archcontext/contracts"; +import { existsSync, lstatSync, readFileSync } 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; + close(): void; +} + +export type CodeGraphReadSessionOpenV1 = + | { state: "open"; session: CodeGraphReadSession } + /** No `.codegraph` directory. */ + | { state: "index-missing" } + /** A `.codegraph` directory without `codegraph.db`. */ + | { state: "not-initialized" } + /** 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"); + if (!existsSync(indexDir)) return { state: "index-missing" }; + // A linked `.codegraph` could carry another worktree's index; it is not this root's index. + if (lstatSync(indexDir).isSymbolicLink()) return { state: "index-open-failed" }; + if (!existsSync(join(indexDir, "codegraph.db"))) return { state: "not-initialized" }; + if (codeGraphIndexBuildInProgress(root)) return { state: "build-in-progress" }; + let graph: CodeGraphIndexHandle; + try { graph = provider.openReadOnly(root); } + catch { return { state: "index-open-failed" }; } + return { state: "open", session: readSession(root, graph, provider) }; +} + +/** 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, graph: CodeGraphIndexHandle, provider: CodeGraphReadProvider): CodeGraphReadSession { + let closed = false; + let freshness: CodeGraphIndexFreshnessV1 | undefined; + const open = () => { + if (closed) throw new Error("CodeGraph read session is closed"); + return graph; + }; + return { + root, + get graph() { return open(); }, + version: () => provider.version, + compatibility: () => { + const handle = open(); + const build = handle.getIndexBuildInfo(); + const lastIndexed = handle.getLastIndexedAt(); + return { + version: provider.version, + projectPath: handle.getProjectRoot(), + lastIndexed: lastIndexed === null ? null : new Date(lastIndexed).toISOString(), + builtWithVersion: build.version, + builtWithExtractionVersion: build.extractionVersion, + currentExtractionVersion: provider.extractionVersion, + reindexRecommended: handle.isIndexStale(), + state: handle.getIndexState(), + pendingRefs: handle.getPendingReferenceCount() + }; + }, + freshness: () => freshness ??= indexFreshness(root, open(), provider), + revision: () => { + const { lastIndexedAt, fileCount } = open().getIndexRevision(); + return { lastIndexedAt, fileCount }; + }, + close: () => { + if (closed) return; + closed = true; + graph.close(); + } + }; +} + +/** + * 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. A file that cannot be read cannot be attested, so it counts as + * modified rather than 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 of indexed.keys()) if (!current.has(path)) removed++; + for (const path of current) { + const contentHash = indexed.get(path); + if (contentHash === undefined) { added++; continue; } + let hash: string | undefined; + try { hash = provider.changeHash(join(root, path)); } catch { hash = undefined; } + if (hash !== contentHash) modified++; + } + 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..a9468077 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,138 @@ 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", () => { - 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); + test("reports a provider that cannot load as unavailable without inventing values", () => { + withRoot((root) => { + 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" }] - }); - }); - }); - - 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 : [] } }); } + expect(provider.calls).toMatchObject({ open: 1, close: 1, scan: 1 }); }); }); - 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("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("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, { readFails: true }); + expect(inspectRepositoryCodeFacts(root, { provider })).toMatchObject({ state: "unavailable", reasonCode: "index-open-failed" }); + 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 not initialized", () => { 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")); + const facts = inspectRepositoryCodeFacts(root, { provider }); + expect(facts).toMatchObject({ state: "not-ready", indexPresent: true, pendingChanges: null }); + expect("issues" in facts && facts.issues.map(issue => issue.code)).toEqual(["not-initialized", "pending-source-changes"]); + expect(provider.calls.open).toBe(0); + expect(() => assertRepositoryCodeFactsReady(root, { provider })).toThrow(expect.objectContaining({ code: "AC_CODE_FACTS_NOT_READY" })); }); }); - 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..bac51233 --- /dev/null +++ b/packages/local-runtime/codegraph-adapter/test/read-provider-fixture.ts @@ -0,0 +1,87 @@ +import { mkdirSync, 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; + /** A read on the opened handle throws. */ + readFails?: 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"), { recursive: true }); + writeFileSync(join(root, ".codegraph", "codegraph.db"), ""); + 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) => `src/${prefix}${index}.ts`); + return { fresh: ["src/fresh.ts"], modified: name("m", pending.modified), removed: name("r", pending.removed), added: name("a", pending.added) }; + }; + 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(); + return { + getIndexBuildInfo: () => read(() => ({ + 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), 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 = population(); + 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/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/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/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..f25485f5 --- /dev/null +++ b/packages/surfaces/cli/bin/bun-darwin-sqlite.ts @@ -0,0 +1,43 @@ +/** + * 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(); +// One line per CI test process, so a run shows which library the macOS runner provided. +if (darwinSqliteLibrary !== undefined && process.env.CI === "true" && process.env.NODE_ENV === "test") { + process.stderr.write(`[bun-darwin-sqlite] ${darwinSqliteLibrary}\n`); +} 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..0ad2f5e5 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,7 +73,8 @@ 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(); } }); @@ -79,11 +82,13 @@ test.each(["complete", "prepare", "prepare-empty-budget", "complete-subdirectory test("CLI checkpoint refuses a present uninitialized index 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"); + // The gate reads compatibility only, so its issues omit the doctor's freshness issue. expect(result).toMatchObject({ ok: false, error: { code: "AC_CODE_FACTS_NOT_READY", details: { - issues: "issues" in doctor ? doctor.issues : [] + issues: "issues" in doctor ? doctor.issues.filter(issue => issue.code !== "pending-source-changes") : [] } } }); rmSync(join(input.root, ".codegraph"), { recursive: true }); expect(await runCli("checkpoint", [], input.root)).toMatchObject({ ok: true }); @@ -93,39 +98,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 +136,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 +182,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 +195,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 +216,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); From a352068237ce429aee48d61f540d7b255a8902e7 Mon Sep 17 00:00:00 2001 From: Ancienttwo <215803476+Ancienttwo@users.noreply.github.com> Date: Fri, 13 Nov 2026 18:05:33 +0800 Subject: [PATCH 02/10] feat(codegraph-adapter): read-session tests, Node integration, Bun runtime fixes (#574 S1) - read-session tests: one per branch, a real index without a holder, the git spy, and the provider bump guard (ADR-0058 Decision 7). - freshness counts an indexed file gone from disk as removed; a deleted tracked file is not a new source. - close() runs Bun.gc(true) under Bun on Windows only (cached statements hold the database file until GC). - main.ts forwards process.execArgv to the daemon only under Bun. - codegraph-selector-node-integration exercises the session under Node. Refs #574 --- .../codegraph-adapter/src/read-session.ts | 18 +- .../test/read-provider-fixture.ts | 21 +- .../test/read-session.test.ts | 256 ++++++++++++++++++ packages/surfaces/cli/src/main.ts | 3 + .../codegraph-selector-node-integration.mjs | 76 +++++- 5 files changed, 360 insertions(+), 14 deletions(-) create mode 100644 packages/local-runtime/codegraph-adapter/test/read-session.test.ts diff --git a/packages/local-runtime/codegraph-adapter/src/read-session.ts b/packages/local-runtime/codegraph-adapter/src/read-session.ts index f531c955..820dfe09 100644 --- a/packages/local-runtime/codegraph-adapter/src/read-session.ts +++ b/packages/local-runtime/codegraph-adapter/src/read-session.ts @@ -245,6 +245,9 @@ function readSession(root: string, graph: CodeGraphIndexHandle, provider: CodeGr if (closed) return; closed = true; graph.close(); + // Bun keeps the SDK's cached prepared statements, and with them the database file, until GC + // finalizes them; Windows cannot then delete or replace the file. + if (process.versions.bun !== undefined && process.platform === "win32") Bun.gc(true); } }; } @@ -252,20 +255,21 @@ function readSession(root: string, graph: CodeGraphIndexHandle, provider: CodeGr /** * 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. A file that cannot be read cannot be attested, so it counts as - * modified rather than fresh. + * 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 of indexed.keys()) if (!current.has(path)) removed++; - for (const path of current) { - const contentHash = indexed.get(path); - if (contentHash === undefined) { added++; continue; } + 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(join(root, path)); } catch { hash = 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/read-provider-fixture.ts b/packages/local-runtime/codegraph-adapter/test/read-provider-fixture.ts index bac51233..69beb9d2 100644 --- a/packages/local-runtime/codegraph-adapter/test/read-provider-fixture.ts +++ b/packages/local-runtime/codegraph-adapter/test/read-provider-fixture.ts @@ -1,4 +1,4 @@ -import { mkdirSync, writeFileSync } from "node:fs"; +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"; @@ -34,17 +34,28 @@ export interface FakeReadProvider extends CodeGraphReadProvider { * 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"), { recursive: true }); + 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) => `src/${prefix}${index}.ts`); - return { fresh: ["src/fresh.ts"], modified: name("m", pending.modified), removed: name("r", pending.removed), added: name("a", pending.added) }; + 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; }, @@ -75,7 +86,7 @@ export function fakeReadProvider(root: string, facts: FakeIndexFacts | (() => Fa }, scanSourceFiles: () => { calls.scan++; - const files = population(); + const files = materialize(); return [...files.fresh, ...files.modified, ...files.added]; }, changeHash: (path) => { 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..c1836b4c --- /dev/null +++ b/packages/local-runtime/codegraph-adapter/test/read-session.test.ts @@ -0,0 +1,256 @@ +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 +} 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 not-initialized and opens nothing", () => { + withRoot((root) => { + const provider = fakeReadProvider(root); + rmSync(join(root, ".codegraph", "codegraph.db")); + expect(openCodeGraphReadSession(root, { provider })).toEqual({ state: "not-initialized" }); + 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("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("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" }); + }); +}); + +/** 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/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/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 }); + } + }; +} From 6aca2b0f0827357cc794ae0635eba807fab52658 Mon Sep 17 00:00:00 2001 From: Ancienttwo <215803476+Ancienttwo@users.noreply.github.com> Date: Fri, 13 Nov 2026 18:50:00 +0800 Subject: [PATCH 03/10] fix(codegraph-adapter): release the Bun handle before GC on Windows (#574 S1) close() drops the session's only reference to the handle before Bun.gc(true); a GC that runs while the handle is reachable cannot finalize its cached statements, so the database file stayed open. Refs #574 --- .../codegraph-adapter/src/read-session.ts | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/packages/local-runtime/codegraph-adapter/src/read-session.ts b/packages/local-runtime/codegraph-adapter/src/read-session.ts index 820dfe09..35fe14da 100644 --- a/packages/local-runtime/codegraph-adapter/src/read-session.ts +++ b/packages/local-runtime/codegraph-adapter/src/read-session.ts @@ -196,10 +196,11 @@ export function openCodeGraphReadSession(root: string, options: { provider?: Cod if (lstatSync(indexDir).isSymbolicLink()) return { state: "index-open-failed" }; if (!existsSync(join(indexDir, "codegraph.db"))) return { state: "not-initialized" }; if (codeGraphIndexBuildInProgress(root)) return { state: "build-in-progress" }; - let graph: CodeGraphIndexHandle; - try { graph = provider.openReadOnly(root); } + // The session owns the only reference to the handle, so close() can release it (see below). + const handle: { graph?: CodeGraphIndexHandle } = {}; + try { handle.graph = provider.openReadOnly(root); } catch { return { state: "index-open-failed" }; } - return { state: "open", session: readSession(root, graph, provider) }; + return { state: "open", session: readSession(root, handle, provider) }; } /** Runs one read against a session that is closed when the read returns or throws. */ @@ -209,31 +210,30 @@ export function withCodeGraphReadSession(root: string, read: (opened: CodeGra finally { if (opened.state === "open") opened.session.close(); } } -function readSession(root: string, graph: CodeGraphIndexHandle, provider: CodeGraphReadProvider): CodeGraphReadSession { - let closed = false; +function readSession(root: string, handle: { graph?: CodeGraphIndexHandle }, provider: CodeGraphReadProvider): CodeGraphReadSession { let freshness: CodeGraphIndexFreshnessV1 | undefined; - const open = () => { - if (closed) throw new Error("CodeGraph read session is closed"); - return graph; + 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 handle = open(); - const build = handle.getIndexBuildInfo(); - const lastIndexed = handle.getLastIndexedAt(); + const graph = open(); + const build = graph.getIndexBuildInfo(); + const lastIndexed = graph.getLastIndexedAt(); return { version: provider.version, - projectPath: handle.getProjectRoot(), + projectPath: graph.getProjectRoot(), lastIndexed: lastIndexed === null ? null : new Date(lastIndexed).toISOString(), builtWithVersion: build.version, builtWithExtractionVersion: build.extractionVersion, currentExtractionVersion: provider.extractionVersion, - reindexRecommended: handle.isIndexStale(), - state: handle.getIndexState(), - pendingRefs: handle.getPendingReferenceCount() + reindexRecommended: graph.isIndexStale(), + state: graph.getIndexState(), + pendingRefs: graph.getPendingReferenceCount() }; }, freshness: () => freshness ??= indexFreshness(root, open(), provider), @@ -242,11 +242,12 @@ function readSession(root: string, graph: CodeGraphIndexHandle, provider: CodeGr return { lastIndexedAt, fileCount }; }, close: () => { - if (closed) return; - closed = true; - graph.close(); - // Bun keeps the SDK's cached prepared statements, and with them the database file, until GC - // finalizes them; Windows cannot then delete or replace the file. + if (handle.graph === undefined) return; + handle.graph.close(); + handle.graph = undefined; + // Bun keeps the SDK's cached prepared statements, and with them the database file, open until + // GC finalizes them, which it can do only once the handle is unreachable; Windows cannot + // delete or replace an open file. if (process.versions.bun !== undefined && process.platform === "win32") Bun.gc(true); } }; From 6f18a34093fd2117c0755c66b586c152be0ae11c Mon Sep 17 00:00:00 2001 From: Ancienttwo <215803476+Ancienttwo@users.noreply.github.com> Date: Fri, 13 Nov 2026 21:16:53 +0800 Subject: [PATCH 04/10] fix(codegraph-adapter): release every in-process Bun handle before GC on Windows (#574 S1) The selector index and the symbol reader close the same way as the read session: drop the only reference, then collect under Bun on Windows. Refs #574 --- .../local-runtime/codegraph-adapter/src/index.ts | 12 ++++++++++-- .../codegraph-adapter/src/read-session.ts | 15 +++++++++++---- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/local-runtime/codegraph-adapter/src/index.ts b/packages/local-runtime/codegraph-adapter/src/index.ts index 1b3d914d..85abe790 100644 --- a/packages/local-runtime/codegraph-adapter/src/index.ts +++ b/packages/local-runtime/codegraph-adapter/src/index.ts @@ -13,6 +13,7 @@ import { REQUIRED_CODEGRAPH_VERSION, codeGraphBundle, codeGraphIndexBuildInProgress, + collectReleasedCodeGraphHandles, CodeGraphProviderUnavailableError, pinnedCodeGraphProvider, withCodeGraphReadSession, @@ -1822,7 +1823,12 @@ export function openCodeGraphSymbolReader( } return result; }, - close: () => graph.close() + close: () => { + graph.close(); + // Drop the only reference so the closed handle can be collected (see collectReleasedCodeGraphHandles). + graph = undefined as unknown as typeof graph; + collectReleasedCodeGraphHandles(); + } } }; } @@ -1895,7 +1901,7 @@ export function loadCapabilityCodeGraphProjectionInputs( options.readOnly ? CODEGRAPH_NO_DOWNLOAD_ENV : undefined) : undefined; const importGraphs = imports?.importGraphs ?? []; - const selectorIndex = entrypointNodes.length > 0 + let selectorIndex = entrypointNodes.length > 0 ? (options.selectorIndexFactory ?? ((path, openOptions) => codeGraphSdk().CodeGraph.openSync(path, openOptions)))(root, { readOnly: options.readOnly === true }) : undefined; let selectorEvidence: ArchitectureSelectorEvidenceV1[]; @@ -1912,6 +1918,8 @@ export function loadCapabilityCodeGraphProjectionInputs( }))))); } finally { selectorIndex?.close(); + selectorIndex = undefined; + collectReleasedCodeGraphHandles(); } return { importGraphs, selectorEvidence, ...(imports ? { importPopulation: imports.importPopulation } : {}) }; } diff --git a/packages/local-runtime/codegraph-adapter/src/read-session.ts b/packages/local-runtime/codegraph-adapter/src/read-session.ts index 35fe14da..d2c3a3fe 100644 --- a/packages/local-runtime/codegraph-adapter/src/read-session.ts +++ b/packages/local-runtime/codegraph-adapter/src/read-session.ts @@ -133,6 +133,16 @@ export function codeGraphIndexBuildInProgress(workspaceRoot: string): boolean { } } +/** + * Bun keeps the SDK's cached prepared statements, and with them the database file, open after + * `close()` until GC finalizes them, which it can do only once the handle is unreachable; Windows + * cannot delete or replace an open file. Call this after dropping the last reference to a closed + * handle. It acts only under Bun on Windows. + */ +export function collectReleasedCodeGraphHandles(): void { + if (process.versions.bun !== undefined && process.platform === "win32") Bun.gc(true); +} + /** The compatibility facts of an open index, read from the handle. */ export interface CodeGraphIndexCompatibilityV1 { /** The loaded provider bundle version. */ @@ -245,10 +255,7 @@ function readSession(root: string, handle: { graph?: CodeGraphIndexHandle }, pro if (handle.graph === undefined) return; handle.graph.close(); handle.graph = undefined; - // Bun keeps the SDK's cached prepared statements, and with them the database file, open until - // GC finalizes them, which it can do only once the handle is unreachable; Windows cannot - // delete or replace an open file. - if (process.versions.bun !== undefined && process.platform === "win32") Bun.gc(true); + collectReleasedCodeGraphHandles(); } }; } From b94c91223c068ac256b94f6581b8d01ea2b2d20b Mon Sep 17 00:00:00 2001 From: Ancienttwo <215803476+Ancienttwo@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:09:13 +0800 Subject: [PATCH 05/10] docs(codegraph-adapter): name the GC cause at each released-handle site (#574 S1) The bun.lock hand-edit this commit carried is dropped: main's lock, regenerated by Bun 1.4.4 (#595), already points the workspace archctx bin at cli/bin/archctx. Refs #574 --- packages/local-runtime/codegraph-adapter/src/index.ts | 5 ++++- packages/local-runtime/codegraph-adapter/src/read-session.ts | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/local-runtime/codegraph-adapter/src/index.ts b/packages/local-runtime/codegraph-adapter/src/index.ts index 85abe790..65c3bbd1 100644 --- a/packages/local-runtime/codegraph-adapter/src/index.ts +++ b/packages/local-runtime/codegraph-adapter/src/index.ts @@ -1825,7 +1825,8 @@ export function openCodeGraphSymbolReader( }, close: () => { graph.close(); - // Drop the only reference so the closed handle can be collected (see collectReleasedCodeGraphHandles). + // Bun keeps cached statements, and the database file, open until GC can collect the handle; + // Windows cannot then delete it. Drop the only reference, then collect (Bun on win32 only). graph = undefined as unknown as typeof graph; collectReleasedCodeGraphHandles(); } @@ -1918,6 +1919,8 @@ export function loadCapabilityCodeGraphProjectionInputs( }))))); } finally { selectorIndex?.close(); + // Bun keeps cached statements, and the database file, open until GC can collect the handle; + // Windows cannot then delete it. Drop the only reference, then collect (Bun on win32 only). selectorIndex = undefined; collectReleasedCodeGraphHandles(); } diff --git a/packages/local-runtime/codegraph-adapter/src/read-session.ts b/packages/local-runtime/codegraph-adapter/src/read-session.ts index d2c3a3fe..36cce5eb 100644 --- a/packages/local-runtime/codegraph-adapter/src/read-session.ts +++ b/packages/local-runtime/codegraph-adapter/src/read-session.ts @@ -254,6 +254,8 @@ function readSession(root: string, handle: { graph?: CodeGraphIndexHandle }, pro close: () => { if (handle.graph === undefined) return; handle.graph.close(); + // Bun keeps cached statements, and the database file, open until GC can collect the handle; + // Windows cannot then delete it. Drop the only reference, then collect (Bun on win32 only). handle.graph = undefined; collectReleasedCodeGraphHandles(); } From e9a1f2ecb372281d6ae1ff1f9c2045b059692f37 Mon Sep 17 00:00:00 2001 From: Ancienttwo <215803476+Ancienttwo@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:43:33 +0800 Subject: [PATCH 06/10] test(projection): collect Bun handles before removing the protocol fixture on Windows (#574 S1) Refs #574 --- .../surfaces/cli/test/projection-protocol-lifecycle.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/surfaces/cli/test/projection-protocol-lifecycle.test.ts b/packages/surfaces/cli/test/projection-protocol-lifecycle.test.ts index e8e675a4..8e6fcdac 100644 --- a/packages/surfaces/cli/test/projection-protocol-lifecycle.test.ts +++ b/packages/surfaces/cli/test/projection-protocol-lifecycle.test.ts @@ -292,6 +292,8 @@ async function withProtocolFixture(prefix: string, run: (context: { } finally { await daemon.stop(); rmSync(stateRoot, { recursive: true, force: true }); + // Bun keeps a closed CodeGraph handle's database file open until GC; Windows cannot then delete it. + if (process.versions.bun !== undefined && process.platform === "win32") Bun.gc(true); rmSync(root, { recursive: true, force: true }); } } From 895a08ce67e0fa037cb99d80805fa20e6ee6ef28 Mon Sep 17 00:00:00 2001 From: Ancienttwo <215803476+Ancienttwo@users.noreply.github.com> Date: Sat, 14 Nov 2026 00:42:01 +0800 Subject: [PATCH 07/10] fix(codegraph-adapter): S1 round 2: index-missing, coherent reads, one readiness reader (#574 S1) - A .codegraph without codegraph.db is index-missing; lstat runs first, so a dangling .codegraph link is index-open-failed. - Design G4: after the reads, the session re-checks the writer lock and the revision taken at open; a moved index is not-ready and retryable. - withExistingCodeGraphSnapshot reads readiness through the session; the CLI probe and CodeGraphSnapshotOptions are gone. - Dead pending branch in the gate removed; selector GC guarded; probe stderr line removed. - Protocol fixture cleanup retries a transient Windows hold after GC. Refs #574 --- .../codegraph-adapter/src/index.ts | 89 +++++++------------ .../codegraph-adapter/src/read-session.ts | 50 ++++++++--- .../test/projection-codegraph-status.test.ts | 8 +- .../test/read-provider-fixture.ts | 8 +- .../test/read-session.test.ts | 31 ++++++- .../runtime-daemon/src/flow-selector-check.ts | 2 +- .../test/flow-selector-check.test.ts | 3 +- .../surfaces/cli/bin/bun-darwin-sqlite.ts | 4 - .../cli/test/code-facts-not-ready.test.ts | 10 +-- .../cli/test/doctor-code-facts.test.ts | 9 +- .../projection-protocol-lifecycle.test.ts | 3 +- packages/surfaces/cli/test/ref-impact.test.ts | 13 +-- 12 files changed, 127 insertions(+), 103 deletions(-) diff --git a/packages/local-runtime/codegraph-adapter/src/index.ts b/packages/local-runtime/codegraph-adapter/src/index.ts index 65c3bbd1..3dbd6d9e 100644 --- a/packages/local-runtime/codegraph-adapter/src/index.ts +++ b/packages/local-runtime/codegraph-adapter/src/index.ts @@ -1195,11 +1195,6 @@ function digestFile(path: string): string { } } -/** Options of the private snapshot's CLI probe; S4 of #574 removes them with the snapshot. */ -export interface CodeGraphSnapshotOptions { - 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; @@ -1227,8 +1222,7 @@ export function codeGraphSnapshotQueries(nodes: NativeModel["nodes"], purpose: " export function withExistingCodeGraphSnapshot( root: string, queries: SnapshotQueryRequest, - callback: (graph: SnapshotQueryReader) => T, - options: CodeGraphSnapshotOptions = {} + 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"]; @@ -1261,7 +1255,7 @@ 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 = inspectSnapshotCodeFactsByCli(snapshotRoot, options); + const ready = inspectRepositoryCodeFacts(snapshotRoot); if (ready.state !== "ready") { if (ready.state === "unavailable") throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable"); throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-read-not-ready", { issues: ready.issues }); @@ -1525,10 +1519,9 @@ function repositoryCodeFactsFromStatus( root: string, codeGraphVersion: string, status: CodeGraphProjectionStatusV1, - extraIssues: ProjectionCodeGraphStatusIssueV1[], measuresFreshness: boolean ): RepositoryCodeFactsV1 { - const issues = [...extraIssues, ...projectionCodeGraphStatusIssues(status, root, "repository")]; + 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. @@ -1592,37 +1585,44 @@ export interface RepositoryCodeFactsInspectionOptions { * writer lock it does not open the index (#426). */ export function inspectRepositoryCodeFacts(root: string, options: RepositoryCodeFactsInspectionOptions = {}): RepositoryCodeFactsV1 { - return inspectRepositoryCodeFactsTier(root, "freshness", options); + return inspectRepositoryCodeFactsTier(root, "freshness", options).facts; } -function inspectRepositoryCodeFactsTier(root: string, tier: "compatibility" | "freshness", options: RepositoryCodeFactsInspectionOptions): RepositoryCodeFactsV1 { +/** + * `moving` is true 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 then the not-ready + * build-in-progress answer: a retry after the build can succeed, and nothing read is reported. + */ +function inspectRepositoryCodeFactsTier(root: string, tier: "compatibility" | "freshness", options: RepositoryCodeFactsInspectionOptions): { facts: RepositoryCodeFactsV1; moving: boolean } { let provider: CodeGraphReadProvider; let codeGraphVersion: string; try { provider = options.provider ?? pinnedCodeGraphProvider(); codeGraphVersion = provider.version; } catch (error) { - return unavailableCodeFacts("codegraph-unavailable", error instanceof CodeGraphProviderUnavailableError ? error.message : "CodeGraph provider could not be loaded"); + return { facts: unavailableCodeFacts("codegraph-unavailable", error instanceof CodeGraphProviderUnavailableError ? error.message : "CodeGraph provider could not be loaded"), moving: false }; } + const moving = { facts: buildInProgressCodeFacts(codeGraphVersion, versionIssuesOf(codeGraphVersion)), moving: true }; try { - return withCodeGraphReadSession(root, (opened): RepositoryCodeFactsV1 => { + return withCodeGraphReadSession(root, (opened): { facts: RepositoryCodeFactsV1; moving: boolean } => { switch (opened.state) { case "index-missing": - return { schemaVersion: REPOSITORY_CODE_FACTS_SCHEMA, state: "index-missing", indexPresent: false, requiredVersion: REQUIRED_CODEGRAPH_VERSION, codeGraphVersion, issues: versionIssuesOf(codeGraphVersion) }; + return { facts: { schemaVersion: REPOSITORY_CODE_FACTS_SCHEMA, state: "index-missing", indexPresent: false, requiredVersion: REQUIRED_CODEGRAPH_VERSION, codeGraphVersion, issues: versionIssuesOf(codeGraphVersion) }, moving: false }; case "index-open-failed": - return unavailableCodeFacts("index-open-failed", INDEX_OPEN_FAILED_MESSAGE); + return { facts: unavailableCodeFacts("index-open-failed", INDEX_OPEN_FAILED_MESSAGE), moving: false }; case "build-in-progress": - return buildInProgressCodeFacts(codeGraphVersion, versionIssuesOf(codeGraphVersion)); - case "not-initialized": - return repositoryCodeFactsFromStatus(root, codeGraphVersion, { initialized: false, version: codeGraphVersion, projectPath: root, lastIndexed: null }, [], tier === "freshness"); + 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, - ...(tier === "freshness" ? { pendingChanges: opened.session.freshness() } : {}), + ...(pendingChanges === undefined ? {} : { pendingChanges }), // `openSync(root)` never walks up to another worktree's index (ADR-0058). worktreeMismatch: null, index: { @@ -1634,59 +1634,32 @@ function inspectRepositoryCodeFactsTier(root: string, tier: "compatibility" | "f pendingRefs: compatibility.pendingRefs } }; - return repositoryCodeFactsFromStatus(root, codeGraphVersion, status, [], tier === "freshness"); + return { facts: repositoryCodeFactsFromStatus(root, codeGraphVersion, status, tier === "freshness"), moving: false }; } } }, { provider }); } catch { // The index opened but a read of it failed; no provider text crosses this boundary. - return unavailableCodeFacts("index-open-failed", INDEX_OPEN_FAILED_MESSAGE); + return { facts: unavailableCodeFacts("index-open-failed", INDEX_OPEN_FAILED_MESSAGE), moving: false }; } } -/** - * The private snapshot's readiness probe: `codegraph --version` and `codegraph status -j` against - * the snapshot copy, never the original root. S4 of #574 removes it together with the snapshot. - */ -function inspectSnapshotCodeFactsByCli(root: string, options: CodeGraphSnapshotOptions): RepositoryCodeFactsV1 { - let codeGraphVersion = ""; - let status: CodeGraphProjectionStatusV1 | undefined; - 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) { - if (codeGraphIndexBuildInProgress(root)) return buildInProgressCodeFacts(codeGraphVersion, versionIssuesOf(codeGraphVersion)); - status = readProjectionCodeGraphStatus(invocation, root, options.timeouts?.statusMs ?? 10_000, CODEGRAPH_NO_DOWNLOAD_ENV); - } - } catch (error) { - return unavailableCodeFacts("codegraph-unavailable", (error instanceof Error ? error.message : String(error)).replace(/\s+/g, " ").trim().slice(0, 400)); - } - if (!indexPresent) { - return { schemaVersion: REPOSITORY_CODE_FACTS_SCHEMA, state: "index-missing", indexPresent: false, requiredVersion: REQUIRED_CODEGRAPH_VERSION, codeGraphVersion, issues: versionIssuesOf(codeGraphVersion) }; - } - if (typeof status !== "object" || status === null || Array.isArray(status)) return unavailableCodeFacts("codegraph-unavailable", "CodeGraph status JSON is not an object"); - return repositoryCodeFactsFromStatus(root, codeGraphVersion, status, versionIssuesOf(codeGraphVersion), true); -} - /** * 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 = inspectRepositoryCodeFactsTier(root, "compatibility", options); + const { facts, moving } = inspectRepositoryCodeFactsTier(root, "compatibility", options); if (facts.state === "index-missing") throw new ProjectionCodeFactsUnavailableError("index-missing"); 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, ...(moving ? { retryable: true } : {}) }); } } @@ -1918,11 +1891,13 @@ export function loadCapabilityCodeGraphProjectionInputs( sinkSymbol: sink.symbol }))))); } finally { - selectorIndex?.close(); - // Bun keeps cached statements, and the database file, open until GC can collect the handle; - // Windows cannot then delete it. Drop the only reference, then collect (Bun on win32 only). - selectorIndex = undefined; - collectReleasedCodeGraphHandles(); + if (selectorIndex) { + selectorIndex.close(); + // Bun keeps cached statements, and the database file, open until GC can collect the handle; + // Windows cannot then delete it. Drop the only reference, then collect (Bun on win32 only). + selectorIndex = undefined; + collectReleasedCodeGraphHandles(); + } } return { importGraphs, selectorEvidence, ...(imports ? { importPopulation: imports.importPopulation } : {}) }; } diff --git a/packages/local-runtime/codegraph-adapter/src/read-session.ts b/packages/local-runtime/codegraph-adapter/src/read-session.ts index 36cce5eb..d30c59a8 100644 --- a/packages/local-runtime/codegraph-adapter/src/read-session.ts +++ b/packages/local-runtime/codegraph-adapter/src/read-session.ts @@ -179,15 +179,18 @@ export interface CodeGraphReadSession { /** 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. */ + /** No `.codegraph` directory, or one without `codegraph.db`. */ | { state: "index-missing" } - /** A `.codegraph` directory without `codegraph.db`. */ - | { state: "not-initialized" } /** A live provider build holds the writer lock. */ | { state: "build-in-progress" } /** The read-only open failed, or `.codegraph` is a symbolic link. */ @@ -201,16 +204,33 @@ export type CodeGraphReadSessionOpenV1 = export function openCodeGraphReadSession(root: string, options: { provider?: CodeGraphReadProvider } = {}): CodeGraphReadSessionOpenV1 { const provider = options.provider ?? pinnedCodeGraphProvider(); const indexDir = join(root, ".codegraph"); - if (!existsSync(indexDir)) return { state: "index-missing" }; - // A linked `.codegraph` could carry another worktree's index; it is not this root's index. - if (lstatSync(indexDir).isSymbolicLink()) return { state: "index-open-failed" }; - if (!existsSync(join(indexDir, "codegraph.db"))) return { state: "not-initialized" }; + let linked: boolean; + try { linked = lstatSync(indexDir).isSymbolicLink(); } + catch { return { state: "index-missing" }; } + // 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" }; + if (!existsSync(join(indexDir, "codegraph.db"))) return { state: "index-missing" }; if (codeGraphIndexBuildInProgress(root)) return { state: "build-in-progress" }; // The session owns the only reference to the handle, so close() can release it (see below). const handle: { graph?: CodeGraphIndexHandle } = {}; - try { handle.graph = provider.openReadOnly(root); } - catch { return { state: "index-open-failed" }; } - return { state: "open", session: readSession(root, handle, provider) }; + let atOpen: CodeGraphIndexRevisionV1; + try { + handle.graph = provider.openReadOnly(root); + atOpen = revisionOf(handle.graph); + } catch { + if (handle.graph !== undefined) { + handle.graph.close(); + handle.graph = undefined; + collectReleasedCodeGraphHandles(); + } + return { state: "index-open-failed" }; + } + return { state: "open", session: readSession(root, handle, provider, atOpen) }; +} + +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. */ @@ -220,7 +240,7 @@ export function withCodeGraphReadSession(root: string, read: (opened: CodeGra finally { if (opened.state === "open") opened.session.close(); } } -function readSession(root: string, handle: { graph?: CodeGraphIndexHandle }, provider: CodeGraphReadProvider): CodeGraphReadSession { +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"); @@ -247,9 +267,11 @@ function readSession(root: string, handle: { graph?: CodeGraphIndexHandle }, pro }; }, freshness: () => freshness ??= indexFreshness(root, open(), provider), - revision: () => { - const { lastIndexedAt, fileCount } = open().getIndexRevision(); - return { lastIndexedAt, fileCount }; + 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; 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 a9468077..21dcf01a 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 @@ -197,15 +197,13 @@ describe("inspectRepositoryCodeFacts", () => { }); }); - test("a .codegraph directory without a database is not initialized", () => { + test("a .codegraph directory without a database is index-missing, as the snapshot path reports it", () => { withRoot((root) => { const provider = fakeReadProvider(root); rmSync(join(root, ".codegraph", "codegraph.db")); - const facts = inspectRepositoryCodeFacts(root, { provider }); - expect(facts).toMatchObject({ state: "not-ready", indexPresent: true, pendingChanges: null }); - expect("issues" in facts && facts.issues.map(issue => issue.code)).toEqual(["not-initialized", "pending-source-changes"]); + 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_NOT_READY" })); + expect(() => assertRepositoryCodeFactsReady(root, { provider })).toThrow(expect.objectContaining({ code: "AC_CODE_FACTS_UNAVAILABLE", reasonCode: "index-missing" })); }); }); diff --git a/packages/local-runtime/codegraph-adapter/test/read-provider-fixture.ts b/packages/local-runtime/codegraph-adapter/test/read-provider-fixture.ts index 69beb9d2..f91c5e98 100644 --- a/packages/local-runtime/codegraph-adapter/test/read-provider-fixture.ts +++ b/packages/local-runtime/codegraph-adapter/test/read-provider-fixture.ts @@ -22,6 +22,8 @@ export interface FakeIndexFacts { openFails?: boolean; /** A read on the opened handle throws. */ readFails?: boolean; + /** Each revision read reports a later index, as a sync that commits during the session would. */ + revisionMoves?: boolean; } export interface FakeReadProvider extends CodeGraphReadProvider { @@ -69,6 +71,7 @@ export function fakeReadProvider(root: string, facts: FakeIndexFacts | (() => Fa return answer(); }; const files = population(); + let revisions = 0; return { getIndexBuildInfo: () => read(() => ({ version: value.builtWithVersion === undefined ? REQUIRED_CODEGRAPH_VERSION : value.builtWithVersion, @@ -79,7 +82,10 @@ export function fakeReadProvider(root: string, facts: FakeIndexFacts | (() => Fa 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), fileCount: files.fresh.length + files.modified.length + files.removed.length })), + 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; diff --git a/packages/local-runtime/codegraph-adapter/test/read-session.test.ts b/packages/local-runtime/codegraph-adapter/test/read-session.test.ts index c1836b4c..7f242386 100644 --- a/packages/local-runtime/codegraph-adapter/test/read-session.test.ts +++ b/packages/local-runtime/codegraph-adapter/test/read-session.test.ts @@ -33,11 +33,20 @@ describe("openCodeGraphReadSession", () => { }); }); - test("a .codegraph without a database is not-initialized and opens nothing", () => { + 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: "not-initialized" }); + 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); }); }); @@ -85,6 +94,24 @@ describe("openCodeGraphReadSession", () => { }); }); + 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); + }); + }); + test("freshness() is CodeGraph's change test, computed once per session", () => { withRoot((root) => { const provider = fakeReadProvider(root, { pending: { added: 2, modified: 1, removed: 3 } }); 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/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/bun-darwin-sqlite.ts b/packages/surfaces/cli/bin/bun-darwin-sqlite.ts index f25485f5..6063441c 100644 --- a/packages/surfaces/cli/bin/bun-darwin-sqlite.ts +++ b/packages/surfaces/cli/bin/bun-darwin-sqlite.ts @@ -37,7 +37,3 @@ export async function loadDarwinSqliteForBun(): Promise { } export const darwinSqliteLibrary = await loadDarwinSqliteForBun(); -// One line per CI test process, so a run shows which library the macOS runner provided. -if (darwinSqliteLibrary !== undefined && process.env.CI === "true" && process.env.NODE_ENV === "test") { - process.stderr.write(`[bun-darwin-sqlite] ${darwinSqliteLibrary}\n`); -} 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 0ad2f5e5..cad73af8 100644 --- a/packages/surfaces/cli/test/code-facts-not-ready.test.ts +++ b/packages/surfaces/cli/test/code-facts-not-ready.test.ts @@ -79,17 +79,13 @@ test.each(["complete", "prepare", "prepare-empty-budget", "complete-subdirectory } 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"); - // The gate reads compatibility only, so its issues omit the doctor's freshness issue. - expect(result).toMatchObject({ ok: false, error: { code: "AC_CODE_FACTS_NOT_READY", details: { - issues: "issues" in doctor ? doctor.issues.filter(issue => issue.code !== "pending-source-changes") : [] - } } }); + 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(); } 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 8e6fcdac..247cffbe 100644 --- a/packages/surfaces/cli/test/projection-protocol-lifecycle.test.ts +++ b/packages/surfaces/cli/test/projection-protocol-lifecycle.test.ts @@ -294,7 +294,8 @@ async function withProtocolFixture(prefix: string, run: (context: { rmSync(stateRoot, { recursive: true, force: true }); // Bun keeps a closed CodeGraph handle's database file open until GC; Windows cannot then delete it. if (process.versions.bun !== undefined && process.platform === "win32") Bun.gc(true); - rmSync(root, { recursive: true, force: true }); + // After that GC this process holds no file under root; 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(); } }); From 2989b08b17743bbad012654c1b18d6dafdb20fa8 Mon Sep 17 00:00:00 2001 From: Ancienttwo <215803476+Ancienttwo@users.noreply.github.com> Date: Sat, 14 Nov 2026 01:19:20 +0800 Subject: [PATCH 08/10] fix(codegraph-adapter): S1 round 3: C2 through the snapshot, lstat errors, retryable only on a matching version (#574 S1) Refs #574 --- .../codegraph-adapter/src/index.ts | 31 ++++++++++--------- .../codegraph-adapter/src/read-session.ts | 6 +++- .../test/projection-codegraph-status.test.ts | 12 ++++++- .../test/read-provider-fixture.ts | 11 +++++-- .../test/read-session.test.ts | 24 +++++++++++++- 5 files changed, 64 insertions(+), 20 deletions(-) diff --git a/packages/local-runtime/codegraph-adapter/src/index.ts b/packages/local-runtime/codegraph-adapter/src/index.ts index 3dbd6d9e..b4a6194d 100644 --- a/packages/local-runtime/codegraph-adapter/src/index.ts +++ b/packages/local-runtime/codegraph-adapter/src/index.ts @@ -1257,7 +1257,8 @@ export function withExistingCodeGraphSnapshot( } 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, @@ -1589,27 +1590,29 @@ export function inspectRepositoryCodeFacts(root: string, options: RepositoryCode } /** - * `moving` is true 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 then the not-ready - * build-in-progress answer: a retry after the build can succeed, and nothing read is reported. + * 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; moving: boolean } { +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"), moving: false }; + return { facts: unavailableCodeFacts("codegraph-unavailable", error instanceof CodeGraphProviderUnavailableError ? error.message : "CodeGraph provider could not be loaded"), retryable: false }; } - const moving = { facts: buildInProgressCodeFacts(codeGraphVersion, versionIssuesOf(codeGraphVersion)), moving: true }; + const versionIssues = versionIssuesOf(codeGraphVersion); + const moving = { facts: buildInProgressCodeFacts(codeGraphVersion, versionIssues), retryable: versionIssues.length === 0 }; try { - return withCodeGraphReadSession(root, (opened): { facts: RepositoryCodeFactsV1; moving: boolean } => { + 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: versionIssuesOf(codeGraphVersion) }, moving: false }; + 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), moving: false }; + return { facts: unavailableCodeFacts("index-open-failed", INDEX_OPEN_FAILED_MESSAGE), retryable: false }; case "build-in-progress": return moving; case "open": { @@ -1634,13 +1637,13 @@ function inspectRepositoryCodeFactsTier(root: string, tier: "compatibility" | "f pendingRefs: compatibility.pendingRefs } }; - return { facts: repositoryCodeFactsFromStatus(root, codeGraphVersion, status, tier === "freshness"), moving: false }; + 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), moving: false }; + return { facts: unavailableCodeFacts("index-open-failed", INDEX_OPEN_FAILED_MESSAGE), retryable: false }; } } @@ -1649,7 +1652,7 @@ function inspectRepositoryCodeFactsTier(root: string, tier: "compatibility" | "f * index compatibility, so the gate reads no source file. */ export function assertRepositoryCodeFactsReady(root: string, options: RepositoryCodeFactsInspectionOptions = {}): void { - const { facts, moving } = inspectRepositoryCodeFactsTier(root, "compatibility", options); + const { facts, retryable } = inspectRepositoryCodeFactsTier(root, "compatibility", options); if (facts.state === "index-missing") throw new ProjectionCodeFactsUnavailableError("index-missing"); if (facts.state === "unavailable") { if (facts.reasonCode === "index-open-failed") throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "index-open-failed"); @@ -1659,7 +1662,7 @@ export function assertRepositoryCodeFactsReady(root: string, options: Repository if (facts.state === "not-ready") { 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, ...(moving ? { retryable: true } : {}) }); + 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 index d30c59a8..262cd314 100644 --- a/packages/local-runtime/codegraph-adapter/src/read-session.ts +++ b/packages/local-runtime/codegraph-adapter/src/read-session.ts @@ -206,7 +206,11 @@ export function openCodeGraphReadSession(root: string, options: { provider?: Cod const indexDir = join(root, ".codegraph"); let linked: boolean; try { linked = lstatSync(indexDir).isSymbolicLink(); } - catch { return { state: "index-missing" }; } + catch (error) { + // Only absence proves there is no index; any other failure (permission, I/O) is an unreadable one. + const code = (error as NodeJS.ErrnoException).code; + return { state: code === "ENOENT" || code === "ENOTDIR" ? "index-missing" : "index-open-failed" }; + } // 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" }; if (!existsSync(join(indexDir, "codegraph.db"))) return { state: "index-missing" }; 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 21dcf01a..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 @@ -178,7 +178,7 @@ describe("inspectRepositoryCodeFacts", () => { }); }); - test("a read that fails after the open is index-open-failed and still closes the handle", () => { + 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" }); @@ -186,6 +186,16 @@ describe("inspectRepositoryCodeFacts", () => { }); }); + test("a read that fails after the open is index-open-failed and still closes the handle", () => { + withRoot((root) => { + 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 symbolic-link .codegraph is index-open-failed without an open", () => { withRoot((root) => { withRoot((other) => { diff --git a/packages/local-runtime/codegraph-adapter/test/read-provider-fixture.ts b/packages/local-runtime/codegraph-adapter/test/read-provider-fixture.ts index f91c5e98..4422ab2d 100644 --- a/packages/local-runtime/codegraph-adapter/test/read-provider-fixture.ts +++ b/packages/local-runtime/codegraph-adapter/test/read-provider-fixture.ts @@ -20,8 +20,10 @@ export interface FakeIndexFacts { pending?: { added: number; modified: number; removed: number }; /** The read-only open throws. */ openFails?: boolean; - /** A read on the opened handle throws. */ + /** 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; } @@ -73,10 +75,13 @@ export function fakeReadProvider(root: string, facts: FakeIndexFacts | (() => Fa const files = population(); let revisions = 0; return { - getIndexBuildInfo: () => read(() => ({ + 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), diff --git a/packages/local-runtime/codegraph-adapter/test/read-session.test.ts b/packages/local-runtime/codegraph-adapter/test/read-session.test.ts index 7f242386..85848ca6 100644 --- a/packages/local-runtime/codegraph-adapter/test/read-session.test.ts +++ b/packages/local-runtime/codegraph-adapter/test/read-session.test.ts @@ -12,7 +12,8 @@ import { inspectRepositoryCodeFacts, openCodeGraphReadSession, pinnedCodeGraphProvider, - withCodeGraphReadSession + withCodeGraphReadSession, + withExistingCodeGraphSnapshot } from "../src/index"; import { codeGraphBundle } from "../src/read-session"; import { fakeReadProvider } from "./read-provider-fixture"; @@ -109,6 +110,11 @@ describe("openCodeGraphReadSession", () => { 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 + })); }); }); @@ -262,6 +268,22 @@ describe("read session over a real index", () => { }); }); +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-"))); From 9db4488bb936bc17d090dc7d868fba9729081750 Mon Sep 17 00:00:00 2001 From: Ancienttwo <215803476+Ancienttwo@users.noreply.github.com> Date: Sat, 14 Nov 2026 02:05:42 +0800 Subject: [PATCH 09/10] refactor(codegraph-adapter): drop the Windows GC workaround under Bun 1.4.4 (#574 S1) Bun 1.4.4 releases the database files on close(): measured with lsof, 0 files stay open after close() (Bun 1.4.3 kept 5-8 until GC), and the protocol fixtures hold no file under root after stop() without any GC. The read session, selector index and symbol reader close as on main; the protocol fixture keeps only rmSync retries for a transient Windows hold outside the process. Refs #574 --- .../codegraph-adapter/src/index.ts | 19 +++--------------- .../codegraph-adapter/src/read-session.ts | 20 +------------------ .../projection-protocol-lifecycle.test.ts | 4 +--- 3 files changed, 5 insertions(+), 38 deletions(-) diff --git a/packages/local-runtime/codegraph-adapter/src/index.ts b/packages/local-runtime/codegraph-adapter/src/index.ts index b4a6194d..94852dc4 100644 --- a/packages/local-runtime/codegraph-adapter/src/index.ts +++ b/packages/local-runtime/codegraph-adapter/src/index.ts @@ -13,7 +13,6 @@ import { REQUIRED_CODEGRAPH_VERSION, codeGraphBundle, codeGraphIndexBuildInProgress, - collectReleasedCodeGraphHandles, CodeGraphProviderUnavailableError, pinnedCodeGraphProvider, withCodeGraphReadSession, @@ -1799,13 +1798,7 @@ export function openCodeGraphSymbolReader( } return result; }, - close: () => { - graph.close(); - // Bun keeps cached statements, and the database file, open until GC can collect the handle; - // Windows cannot then delete it. Drop the only reference, then collect (Bun on win32 only). - graph = undefined as unknown as typeof graph; - collectReleasedCodeGraphHandles(); - } + close: () => graph.close() } }; } @@ -1878,7 +1871,7 @@ export function loadCapabilityCodeGraphProjectionInputs( options.readOnly ? CODEGRAPH_NO_DOWNLOAD_ENV : undefined) : undefined; const importGraphs = imports?.importGraphs ?? []; - let selectorIndex = entrypointNodes.length > 0 + const selectorIndex = entrypointNodes.length > 0 ? (options.selectorIndexFactory ?? ((path, openOptions) => codeGraphSdk().CodeGraph.openSync(path, openOptions)))(root, { readOnly: options.readOnly === true }) : undefined; let selectorEvidence: ArchitectureSelectorEvidenceV1[]; @@ -1894,13 +1887,7 @@ export function loadCapabilityCodeGraphProjectionInputs( sinkSymbol: sink.symbol }))))); } finally { - if (selectorIndex) { - selectorIndex.close(); - // Bun keeps cached statements, and the database file, open until GC can collect the handle; - // Windows cannot then delete it. Drop the only reference, then collect (Bun on win32 only). - selectorIndex = undefined; - collectReleasedCodeGraphHandles(); - } + selectorIndex?.close(); } return { importGraphs, selectorEvidence, ...(imports ? { importPopulation: imports.importPopulation } : {}) }; } diff --git a/packages/local-runtime/codegraph-adapter/src/read-session.ts b/packages/local-runtime/codegraph-adapter/src/read-session.ts index 262cd314..2296ba3f 100644 --- a/packages/local-runtime/codegraph-adapter/src/read-session.ts +++ b/packages/local-runtime/codegraph-adapter/src/read-session.ts @@ -133,16 +133,6 @@ export function codeGraphIndexBuildInProgress(workspaceRoot: string): boolean { } } -/** - * Bun keeps the SDK's cached prepared statements, and with them the database file, open after - * `close()` until GC finalizes them, which it can do only once the handle is unreachable; Windows - * cannot delete or replace an open file. Call this after dropping the last reference to a closed - * handle. It acts only under Bun on Windows. - */ -export function collectReleasedCodeGraphHandles(): void { - if (process.versions.bun !== undefined && process.platform === "win32") Bun.gc(true); -} - /** The compatibility facts of an open index, read from the handle. */ export interface CodeGraphIndexCompatibilityV1 { /** The loaded provider bundle version. */ @@ -215,18 +205,13 @@ export function openCodeGraphReadSession(root: string, options: { provider?: Cod if (linked) return { state: "index-open-failed" }; if (!existsSync(join(indexDir, "codegraph.db"))) return { state: "index-missing" }; if (codeGraphIndexBuildInProgress(root)) return { state: "build-in-progress" }; - // The session owns the only reference to the handle, so close() can release it (see below). const handle: { graph?: CodeGraphIndexHandle } = {}; let atOpen: CodeGraphIndexRevisionV1; try { handle.graph = provider.openReadOnly(root); atOpen = revisionOf(handle.graph); } catch { - if (handle.graph !== undefined) { - handle.graph.close(); - handle.graph = undefined; - collectReleasedCodeGraphHandles(); - } + handle.graph?.close(); return { state: "index-open-failed" }; } return { state: "open", session: readSession(root, handle, provider, atOpen) }; @@ -280,10 +265,7 @@ function readSession(root: string, handle: { graph?: CodeGraphIndexHandle }, pro close: () => { if (handle.graph === undefined) return; handle.graph.close(); - // Bun keeps cached statements, and the database file, open until GC can collect the handle; - // Windows cannot then delete it. Drop the only reference, then collect (Bun on win32 only). handle.graph = undefined; - collectReleasedCodeGraphHandles(); } }; } diff --git a/packages/surfaces/cli/test/projection-protocol-lifecycle.test.ts b/packages/surfaces/cli/test/projection-protocol-lifecycle.test.ts index 247cffbe..a13f0f76 100644 --- a/packages/surfaces/cli/test/projection-protocol-lifecycle.test.ts +++ b/packages/surfaces/cli/test/projection-protocol-lifecycle.test.ts @@ -292,9 +292,7 @@ async function withProtocolFixture(prefix: string, run: (context: { } finally { await daemon.stop(); rmSync(stateRoot, { recursive: true, force: true }); - // Bun keeps a closed CodeGraph handle's database file open until GC; Windows cannot then delete it. - if (process.versions.bun !== undefined && process.platform === "win32") Bun.gc(true); - // After that GC this process holds no file under root; a transient Windows hold outside it is retried. + // 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 }); } } From 86495797f4e86d4002fb9242b99ce429ceae72f7 Mon Sep 17 00:00:00 2001 From: Ancienttwo <215803476+Ancienttwo@users.noreply.github.com> Date: Sat, 14 Nov 2026 02:15:36 +0800 Subject: [PATCH 10/10] fix(codegraph-adapter): S1 round 4: an unreadable database is index-open-failed (#574 S1) The codegraph.db check uses stat with the .codegraph rule: ENOENT or ENOTDIR is index-missing, any other error is index-open-failed. Also corrects the snapshot comment: readiness opens the copy in-process. Refs #574 --- .../codegraph-adapter/src/index.ts | 5 +++-- .../codegraph-adapter/src/read-session.ts | 17 ++++++++------- .../test/read-session.test.ts | 21 +++++++++++++++++++ 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/packages/local-runtime/codegraph-adapter/src/index.ts b/packages/local-runtime/codegraph-adapter/src/index.ts index 94852dc4..e816c1c3 100644 --- a/packages/local-runtime/codegraph-adapter/src/index.ts +++ b/packages/local-runtime/codegraph-adapter/src/index.ts @@ -1260,8 +1260,9 @@ export function withExistingCodeGraphSnapshot( 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) { diff --git a/packages/local-runtime/codegraph-adapter/src/read-session.ts b/packages/local-runtime/codegraph-adapter/src/read-session.ts index 2296ba3f..05f1e9f0 100644 --- a/packages/local-runtime/codegraph-adapter/src/read-session.ts +++ b/packages/local-runtime/codegraph-adapter/src/read-session.ts @@ -1,5 +1,5 @@ import { productVersionManifest } from "@archcontext/contracts"; -import { existsSync, lstatSync, readFileSync } from "node:fs"; +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"; @@ -196,14 +196,11 @@ export function openCodeGraphReadSession(root: string, options: { provider?: Cod const indexDir = join(root, ".codegraph"); let linked: boolean; try { linked = lstatSync(indexDir).isSymbolicLink(); } - catch (error) { - // Only absence proves there is no index; any other failure (permission, I/O) is an unreadable one. - const code = (error as NodeJS.ErrnoException).code; - return { state: code === "ENOENT" || code === "ENOTDIR" ? "index-missing" : "index-open-failed" }; - } + 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" }; - if (!existsSync(join(indexDir, "codegraph.db"))) return { state: "index-missing" }; + 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; @@ -217,6 +214,12 @@ export function openCodeGraphReadSession(root: string, options: { provider?: Cod 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 }; diff --git a/packages/local-runtime/codegraph-adapter/test/read-session.test.ts b/packages/local-runtime/codegraph-adapter/test/read-session.test.ts index 85848ca6..9ed847bd 100644 --- a/packages/local-runtime/codegraph-adapter/test/read-session.test.ts +++ b/packages/local-runtime/codegraph-adapter/test/read-session.test.ts @@ -72,6 +72,27 @@ describe("openCodeGraphReadSession", () => { }); }); + 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 });