From cde037188de19aeb6e17d8f98cf71be5fe0cec6f Mon Sep 17 00:00:00 2001 From: Ancienttwo <215803476+Ancienttwo@users.noreply.github.com> Date: Sat, 14 Nov 2026 04:28:03 +0800 Subject: [PATCH 1/5] feat(codegraph-adapter): delete the private snapshot; flows --no-sync and ref impact read the ready index in-process (#574 S4) Refs #574 --- docs/runbooks/ref-impact.md | 51 ++- packages/contracts/src/failure.ts | 4 - .../contracts/test/failure-envelope.test.ts | 2 +- .../codegraph-adapter/src/index.ts | 8 +- .../codegraph-adapter/src/ready-index-read.ts | 54 ++++ .../codegraph-adapter/src/ref-impact-facts.ts | 29 +- .../codegraph-adapter/src/snapshot-query.ts | 235 -------------- .../codegraph-adapter/src/snapshot.ts | 298 ------------------ .../test/capability-projection-inputs.test.ts | 194 +----------- .../codegraph-adapter/test/git-spy.ts | 40 +++ .../test/read-session.test.ts | 19 +- .../codegraph-adapter/test/real-index.ts | 19 +- .../runtime-daemon/src/flow-selector-check.ts | 10 +- .../test/flow-selector-check.test.ts | 32 +- .../cli/test/failure-envelope.test.ts | 2 +- packages/surfaces/cli/test/ref-impact.test.ts | 43 ++- .../codegraph-selector-node-integration.mjs | 21 +- 17 files changed, 236 insertions(+), 825 deletions(-) create mode 100644 packages/local-runtime/codegraph-adapter/src/ready-index-read.ts delete mode 100644 packages/local-runtime/codegraph-adapter/src/snapshot-query.ts delete mode 100644 packages/local-runtime/codegraph-adapter/src/snapshot.ts create mode 100644 packages/local-runtime/codegraph-adapter/test/git-spy.ts diff --git a/docs/runbooks/ref-impact.md b/docs/runbooks/ref-impact.md index 6aa3698c..a47d349c 100644 --- a/docs/runbooks/ref-impact.md +++ b/docs/runbooks/ref-impact.md @@ -20,12 +20,14 @@ whitespace belongs to the path and is preserved, including whitespace-only paths ## Ref-bound flow evidence -The CLI reads the existing CodeGraph index through a disposable private snapshot. -It never syncs or opens the original index. CodeGraph's own source enumeration and -readiness checks validate the snapshot. Indexed source hashes must match the -selected immutable Git blobs before declaration ranges are bound to that ref. -Only matching refs receive evidence; historical refs without matching facts stay -unknown. Hunk coordinates come from bounded comparisons of the same raw blobs. +The CLI reads the repository's existing CodeGraph index in-process through one +read-only handle (ADR-0058). It never syncs the index, opens it writable or copies +it, and it starts no CodeGraph process. The index must be ready first: compatible +with the pinned CodeGraph, and current with the worktree by CodeGraph's own change +test. Indexed source hashes must then match the selected immutable Git blobs before +declaration ranges are bound to that ref. Only matching refs receive evidence; +historical refs without matching facts stay unknown. Hunk coordinates come from +bounded comparisons of the same raw blobs. Changed head ranges report the affected flow step. An authoritative zero-match lookup in an indexed, supported, error-free file reports @@ -34,29 +36,20 @@ ranges identify the step with `selector-evidence-unavailable`. Partial positive findings remain visible while aggregate coverage stays unknown. Deleted base lines require separately bound base evidence. -The snapshot uses CodeGraph 1.6.2's shipped source scanner, including its include, -includeIgnored and extension settings. The selected source/configuration files, -Git metadata and DB/WAL copy are limited to 512 MiB and 100,000 entries. Unsafe -symlinks in selected inputs, external Git object stores, unsupported path/config -overrides and changing inputs refuse evidence. Impact summarizes these snapshot -refusals as `ref-code-facts-unavailable`; it does not distinguish a missing index -from an unsupported snapshot layout. `flows check --no-sync` exposes the specific -snapshot failure reason. Ordinary ignored dependency trees -are not copied. Private temporary files are removed after reading. - -SDK queries run in the Node executable shipped with the same pinned CodeGraph -platform bundle. The caller receives complete query-result tables, without live -SQLite handles; the worker must exit successfully before strict snapshot removal. -The query boundary accepts at most 1 MiB of requests, 10,000 keys per table, -100,000 result items and 32 MiB of output, with a 30-second deadline. Missing or -malformed query coverage fails closed; an unrequested lookup is never an empty match. - -Installed Git filter definitions alone do not prevent a snapshot. Git's effective -attributes are checked across all tracked paths, including embedded repositories; -selected clean/process filters are refused before provider status can run them. -Configured drivers named `unset` or `unspecified` are also refused when their names -make Git's attribute response ambiguous. The provider's name-status diff does not -request patches, so diff/textconv configuration alone does not execute a callback. +The source population is CodeGraph 1.6.2's own file enumeration, including its +include, includeIgnored and extension settings. Impact summarizes every code-facts +refusal as `ref-code-facts-unavailable`: a missing index, an index that cannot be +opened, an index behind the worktree and an index that moved during the read. +`flows check --no-sync` reads the same way and exposes the specific reason: +`index-missing`, `index-open-failed` or `code-facts-read-not-ready`. + +The read runs only the git commands ADR-0058 allows: `ls-files`, `rev-parse`, +`check-ignore` and `config --get core.excludesFile`, plus the `rev-parse`, +`ls-tree`, `cat-file` and `log` reads of the immutable refs. It never runs `git +status` or `git diff`, so configured clean/process filters, `core.fsmonitor` and +index hooks are not executed, and a repository that uses them still gets evidence. +SQLite may leave the `codegraph.db-wal` and `codegraph.db-shm` sidecar files in +`.codegraph/`; nothing else is written. Consumers must inspect reason codes, dependency coverage and truncation fields; unknown or truncated output is not proof that no impact exists. Limits are diff --git a/packages/contracts/src/failure.ts b/packages/contracts/src/failure.ts index b5cbdd94..fa116501 100644 --- a/packages/contracts/src/failure.ts +++ b/packages/contracts/src/failure.ts @@ -46,9 +46,6 @@ export const CODE_FACTS_ISSUE_MESSAGES = Object.freeze({ const policies: Record = { "history-shallow-repository": { code: "AC_PRECONDITION_FAILED", message: "Architecture history requires a complete Git repository.", action: "unshallow-repository", facts: { historyReadReason: "optional:historyReadReason" } }, "code-facts-read-not-ready": { code: "AC_CODE_FACTS_UNAVAILABLE", message: "The existing CodeGraph index does not attest to the current source population.", facts: { issues: "codeFactsIssues" }, action: "codegraph-sync", retryable: false }, - "code-facts-snapshot-changed": { code: "AC_CODE_FACTS_UNAVAILABLE", message: "Source or index inputs changed while reading the private snapshot.", action: "retry-read", retryable: true, facts: {} }, - "code-facts-snapshot-limit-exceeded": { code: "AC_CODE_FACTS_UNAVAILABLE", message: "The private CodeGraph snapshot copy exceeds its 512 MiB or 100000 entry limit.", action: "reduce-snapshot-size", retryable: false, facts: { limit: "snapshotLimit", maxBytes: "uint", maxEntries: "uint", sourceBytes: "uint", sourceEntries: "uint", gitBytes: "uint", gitEntries: "uint", gitObjectEntries: "uint", indexBytes: "uint", indexEntries: "uint" } }, - "code-facts-snapshot-layout-unsupported": { code: "AC_CODE_FACTS_UNAVAILABLE", message: "The repository layout cannot be safely isolated for a zero-write CodeGraph read.", action: "inspect-repository-layout", retryable: false, facts: {} }, "daemon-root-missing": { code: "AC_RUNTIME_UNAVAILABLE", message: "The daemon start worktree no longer exists. Stop this daemon using its workspace state directory.", action: "stop-targeted-daemon", retryable: false, facts: {} }, "changeset-receipt-journal-unavailable": { code: "AC_PRECONDITION_FAILED", message: "No committed ChangeSet journal exists for this repository and journal id.", action: "select-committed-journal" }, "changeset-receipt-transition-unavailable": { code: "AC_PRECONDITION_FAILED", message: "This journal has no recorded model transition; a complete receipt cannot be exported.", action: "inspect-journal-evidence" }, @@ -394,7 +391,6 @@ const localEnums: Readonly> = { depsField: ["from", "into", "importNodeLimit", "edgeLimit"], depsIssue: ["unknown-node", "invalid-limit", "missing-value"], snapshotField: ["repositoryId", "workspaceId", "headSha", "worktreeDigest"], - snapshotLimit: ["bytes", "entries"], jobsField: ["workerId"], observedDomain: ["observed"], inputAvailability: ["unavailable"], diff --git a/packages/contracts/test/failure-envelope.test.ts b/packages/contracts/test/failure-envelope.test.ts index 7834036e..44a48afe 100644 --- a/packages/contracts/test/failure-envelope.test.ts +++ b/packages/contracts/test/failure-envelope.test.ts @@ -16,7 +16,7 @@ test("every registered failure reason has the strict four fields and bounded UTF expectedBaseSha: "a".repeat(40), actualBaseSha: "b".repeat(40), expectedWorktreeDigest: "sha256:"+"c".repeat(64), actualWorktreeDigest: "sha256:"+"d".repeat(64), expectedNamespace: "product.fixture", actualNamespace: "product.next", measuredBytes: 10, limitBytes: 9, kind: "timeout", confirmationToken: "public:github.com/acme/widgets:"+"a".repeat(40)+":audit_run.fixture", recordCommand: null, verifyCommand: null, observation: "before-resolve-append", field: reason === "projection-snapshot-mismatch" ? "worktreeDigest" : reason === "jobs-input-invalid" ? "workerId" : "into", issue: "unknown-node", inputDomain: "observed", availability: "unavailable", cause: "unclassified" }; - Object.assign(facts, {journalId:"journal.fixture",previousJournalId:"journal.previous",capabilityId:"capability.fixture",changeSetId:"changeset.fixture",manualRecoveryRequired:true,expectedModelDigest:"sha256:"+"a".repeat(64),actualModelDigest:"sha256:"+"b".repeat(64),expectedPlanDigest:"sha256:"+"a".repeat(64),actualPlanDigest:"sha256:"+"b".repeat(64),lookupKey:"sha256:"+"a".repeat(64),applyId:"sha256:"+"b".repeat(64),driftCount:1,rejectedCount:1,digestStable:false,pathCount:1,paths:["docs/file.md"],action:"remove-whole-file-outputs-and-reapply",commands:["archctx docs apply --approved"],omittedPathCount:0,omittedCommandCount:0,manifestPath:"docs/architecture/.projection-manifest.json",foundSchemaVersion:"archcontext.architecture-docs-projection-manifest/v1",supportedSchemaVersion:"archcontext.architecture-docs-projection-manifest/v3",upgradeCommand:"archctx docs apply --approved --profile ",migrationGuide:"docs/runbooks/architecture-documentation-projections.md",subpath:"./sub",limit:"bytes",maxBytes:9,maxEntries:9,sourceBytes:10,sourceEntries:1,gitBytes:0,gitEntries:0,gitObjectEntries:1,indexBytes:0,indexEntries:0,historyReadReason:"history-file-mismatch"}); + Object.assign(facts, {journalId:"journal.fixture",previousJournalId:"journal.previous",capabilityId:"capability.fixture",changeSetId:"changeset.fixture",manualRecoveryRequired:true,expectedModelDigest:"sha256:"+"a".repeat(64),actualModelDigest:"sha256:"+"b".repeat(64),expectedPlanDigest:"sha256:"+"a".repeat(64),actualPlanDigest:"sha256:"+"b".repeat(64),lookupKey:"sha256:"+"a".repeat(64),applyId:"sha256:"+"b".repeat(64),driftCount:1,rejectedCount:1,digestStable:false,pathCount:1,paths:["docs/file.md"],action:"remove-whole-file-outputs-and-reapply",commands:["archctx docs apply --approved"],omittedPathCount:0,omittedCommandCount:0,manifestPath:"docs/architecture/.projection-manifest.json",foundSchemaVersion:"archcontext.architecture-docs-projection-manifest/v1",supportedSchemaVersion:"archcontext.architecture-docs-projection-manifest/v3",upgradeCommand:"archctx docs apply --approved --profile ",migrationGuide:"docs/runbooks/architecture-documentation-projections.md",subpath:"./sub",historyReadReason:"history-file-mismatch"}); if(reason==="runtime-ledger-mode-invalid") facts.field="rolloutMode"; if(reason==="projection-inputs-changed-during-run") facts.field="sourceFiles"; if(reason==="state-machine-model-invalid") Object.assign(facts,{issues:[".archcontext/model/state-machines/order.yaml: regions[0].initial: state-machine-initial-unknown"],omittedCount:0}); diff --git a/packages/local-runtime/codegraph-adapter/src/index.ts b/packages/local-runtime/codegraph-adapter/src/index.ts index 47145aaf..b65b48a8 100644 --- a/packages/local-runtime/codegraph-adapter/src/index.ts +++ b/packages/local-runtime/codegraph-adapter/src/index.ts @@ -52,13 +52,7 @@ export { type ProjectionCodeGraphStatusIssueCode, type ProjectionCodeGraphStatusIssueV1 } from "./projection-handshake"; -export { - CODEGRAPH_SNAPSHOT_MAX_BYTES, - CODEGRAPH_SNAPSHOT_MAX_ENTRIES, - codeGraphSnapshotQueries, - codeGraphSnapshotRuntime, - withExistingCodeGraphSnapshot -} from "./snapshot"; +export { withReadyCodeGraphIndex } from "./ready-index-read"; export { CODEGRAPH_IMPORT_NODE_READ_LIMIT, repositoryImportPairs, diff --git a/packages/local-runtime/codegraph-adapter/src/ready-index-read.ts b/packages/local-runtime/codegraph-adapter/src/ready-index-read.ts new file mode 100644 index 00000000..6fd7a400 --- /dev/null +++ b/packages/local-runtime/codegraph-adapter/src/ready-index-read.ts @@ -0,0 +1,54 @@ +import { CODE_FACTS_ISSUE_MESSAGES, FailureError } from "@archcontext/contracts"; +import { withCodeGraphReadSession, type CodeGraphIndexHandle } from "./read-session"; +import { inspectRepositoryCodeFacts } from "./repository-code-facts"; + +/** + * Runs `read` against the repository's own index through one read-only session (ADR-0058), and + * only while that index attests to the worktree: it is compatible and CodeGraph's change test finds + * no pending source change. Nothing syncs, copies or writes the index; `flows check --no-sync` and + * ref impact read through here. + * + * - no `.codegraph/codegraph.db`: `AC_CODE_FACTS_UNAVAILABLE` / `index-missing` + * - a linked `.codegraph`, or an index that cannot be opened and read: `index-open-failed` + * - an incompatible or stale index, a live build, or an index that moved during the read: + * `code-facts-read-not-ready` with the readiness issues + * - any other failure, such as a provider that cannot load: `code-facts-unavailable` + */ +export function withReadyCodeGraphIndex(root: string, read: (graph: CodeGraphIndexHandle) => T): T { + try { + return withCodeGraphReadSession(root, (opened) => { + switch (opened.state) { + case "index-missing": + throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "index-missing"); + case "index-open-failed": + throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "index-open-failed"); + case "build-in-progress": + throw indexMoving(); + case "open": { + // Readiness opens its own session inside this one. An unchanged revision after the read + // (design G4) proves that both sessions read one index state. + const facts = inspectRepositoryCodeFacts(root); + if (facts.state === "index-missing") throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "index-missing"); + if (facts.state === "unavailable") { + throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", facts.reasonCode === "index-open-failed" ? "index-open-failed" : "code-facts-unavailable"); + } + if (facts.state === "not-ready") throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-read-not-ready", { issues: facts.issues }); + const result = read(opened.session.graph); + if (!opened.session.unchangedSinceOpen()) throw indexMoving(); + return result; + } + } + }); + } catch (error) { + if (error instanceof FailureError) throw error; + // No provider or SDK text crosses this boundary. + throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable"); + } +} + +/** A provider build holds the writer lock, or the index revision moved while it was read. */ +function indexMoving(): FailureError { + return new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-read-not-ready", { + issues: [{ code: "index-incomplete", message: CODE_FACTS_ISSUE_MESSAGES["index-incomplete"] }] + }, { retryable: true }); +} diff --git a/packages/local-runtime/codegraph-adapter/src/ref-impact-facts.ts b/packages/local-runtime/codegraph-adapter/src/ref-impact-facts.ts index cb17054b..0df3a9d3 100644 --- a/packages/local-runtime/codegraph-adapter/src/ref-impact-facts.ts +++ b/packages/local-runtime/codegraph-adapter/src/ref-impact-facts.ts @@ -1,21 +1,23 @@ import { createHash } from "node:crypto"; -import { createRequire } from "node:module"; import { isRepoRelativePosixPath, REF_IMPACT_LIMITS, type NativeContextModel, type NativeContextSnapshot } from "@archcontext/contracts"; -import { nativeNodeSource, type NativeNode, type NativeModel } from "@archcontext/core/projection-engine"; +import { nativeNodeSource, type NativeNode } from "@archcontext/core/projection-engine"; import { decodeUtf8, readGitBlobs, readGitBlobTree } from "../../git-adapter/src/blob-reader"; import type { RefImpactFacts } from "../../model-store-yaml/src/ref-impact"; -import { codeGraphSnapshotQueries, withExistingCodeGraphSnapshot } from "./index"; -type Sdk = typeof import("@colbymchenry/codegraph"); -type SnapshotGraph = Pick, "getFiles" | "getNodesByName">; +import { codeGraphBundle, type CodeGraphIndexHandle } from "./read-session"; +import { withReadyCodeGraphIndex } from "./ready-index-read"; +type RefImpactGraph = Pick; const nonDeclarations = new Set(["file", "import", "export", "parameter"]); -const requireFromAdapter = createRequire(import.meta.url); -/** Both refs are compared against one provider-authenticated private snapshot; no original DB open. */ +/** + * Both refs are bound against the repository's own ready index, read in-process and read-only + * (ADR-0058). Every failure, including an index that is missing or behind the worktree, leaves the + * facts unknown. + */ export function readRefImpactFacts(root: string, base: { model: NativeContextModel; snapshot: NativeContextSnapshot }, head: { model: NativeContextModel; snapshot: NativeContextSnapshot }): { baseFacts?: RefImpactFacts; headFacts?: RefImpactFacts } { try { - return withExistingCodeGraphSnapshot(root, codeGraphSnapshotQueries([...base.model.nodes, ...head.model.nodes] as unknown as NativeModel["nodes"], "ref"), graph => { + return withReadyCodeGraphIndex(root, graph => { const bind = (input: typeof head) => input.snapshot.source === "ref" - ? bindSnapshotRefImpactFacts(root, input.model, input.snapshot, graph) : undefined; + ? bindRefImpactFacts(root, input.model, input.snapshot, graph) : undefined; return { baseFacts: bind(base), headFacts: bind(head) }; }); } catch { @@ -24,20 +26,19 @@ export function readRefImpactFacts(root: string, base: { model: NativeContextMod } /** - * Called only inside withExistingCodeGraphSnapshot's authenticated ready callback. - * Every indexed file must match an immutable + * Called only inside withReadyCodeGraphIndex's read. Every indexed file must match an immutable * blob, not just the current HEAD label. CodeGraph 1.6.2 stores SHA-256 of UTF-8 source text; * strict UTF-8 and the 1 MiB extraction ceiling exclude replacement decoding and oversize stamps. * A supported, error-free indexed file permits an authoritative exact-name no-match. * That means the selector does not resolve in CodeGraph, not that no runtime code can exist. * Missing files, unsupported languages and ambiguous declarations remain unknown. */ -function bindSnapshotRefImpactFacts( - root: string, model: NativeContextModel, ref: { commitSha: string; treeOid: string }, graph: SnapshotGraph +function bindRefImpactFacts( + root: string, model: NativeContextModel, ref: { commitSha: string; treeOid: string }, graph: RefImpactGraph ): RefImpactFacts | undefined { try { const files = graph.getFiles(); - const sdk = requireFromAdapter("@colbymchenry/codegraph") as Sdk; + const { sdk } = codeGraphBundle(); const supportedPaths = new Set(files.filter(file => sdk.isLanguageSupported(file.language)).map(file => file.path)); if (files.length > REF_IMPACT_LIMITS.codeFactFiles) return undefined; const tree = new Map(readGitBlobTree(root, ref.treeOid, { sizes: true, trees: false }).map(entry => [entry.path, entry])); diff --git a/packages/local-runtime/codegraph-adapter/src/snapshot-query.ts b/packages/local-runtime/codegraph-adapter/src/snapshot-query.ts deleted file mode 100644 index 7a03c4c6..00000000 --- a/packages/local-runtime/codegraph-adapter/src/snapshot-query.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { isAbsolute, resolve } from "node:path"; -import { FailureError } from "@archcontext/contracts"; -import { codeGraphChildEnv } from "./child-env"; -import type { Node, Edge, CodeGraph } from "@colbymchenry/codegraph"; - -type FileRecord = ReturnType[number]; - -export interface SnapshotQueryRequest { - names: readonly string[]; - sources: readonly { name: string; path: string }[]; - includeFiles: boolean; -} - -export interface SnapshotQueryReader { - getNodesByName(name: string): Node[]; - getNodesInFile(path: string): Node[]; - /** Only the complete calls/references population is requested by this reader. */ - getOutgoingEdges(id: string): Edge[]; - getNode(id: string): Node | null; - getFiles(): FileRecord[]; - getProjectRoot(): string; -} - -const MAX_REQUEST_BYTES = 1024 * 1024; -const MAX_OUTPUT_BYTES = 32 * 1024 * 1024; -const MAX_KEYS = 10_000; -const MAX_ITEMS = 100_000; - -// A literal script survives the CLI's ordinary bundle. Only this process opens SDK DB handles; -// process termination releases retained SQLite statements before the caller removes the mirror. -const QUERY_SCRIPT = String.raw` -"use strict"; -const fs = require("node:fs"); -const MAX_KEYS = 10000, MAX_ITEMS = 100000, MAX_BYTES = 32 * 1024 * 1024; -let items = 0; -function account(rows) { - items += rows.length; - if (items > MAX_ITEMS) throw new Error("query limit"); - return rows; -} -function keys(values) { - const result = [...new Set(values)]; - if (result.length > MAX_KEYS) throw new Error("query key limit"); - return result; -} -try { - const request = JSON.parse(fs.readFileSync(0, "utf8")); - const sdk = require(process.argv[1]); - const root = process.argv[2]; - let holder, graph, result; - try { - holder = sdk.CodeGraph.openSync(root); - graph = sdk.CodeGraph.openSync(root, { readOnly: true }); - const names = request.names.map(name => [name, account(graph.getNodesByName(name))]); - const byFile = new Map(); - function file(path) { - if (!byFile.has(path)) { - if (byFile.size >= MAX_KEYS) throw new Error("query key limit"); - byFile.set(path, account(graph.getNodesInFile(path))); - } - return byFile.get(path); - } - const sources = new Map(); - for (const selector of request.sources) { - for (const node of file(selector.path)) { - if (node.name === selector.name) sources.set(node.id, node); - } - } - const ids = keys([...sources.keys()]); - // Built-in correction queries the provider's own source.filePath, not the request spelling. - for (const source of sources.values()) file(source.filePath); - const outgoing = new Map(ids.map(id => [id, []])); - const edges = account(ids.length ? graph.getOutgoingEdgesFrom(ids, ["calls", "references"]) : []); - for (const edge of edges) { - if (!outgoing.has(edge.source)) throw new Error("unexpected source"); - outgoing.get(edge.source).push(edge); - } - const targets = keys(edges.map(edge => edge.target)); - const nodes = targets.length ? graph.getNodesByIds(targets) : new Map(); - const targetRows = account(targets.map(id => [id, nodes.get(id) ?? null])); - result = { - version: 1, root: graph.getProjectRoot(), names, - filesByPath: [...byFile], outgoing: [...outgoing], targets: targetRows, - files: request.includeFiles ? account(graph.getFiles()) : null - }; - } finally { - try { if (graph) graph.close(); } finally { if (holder) holder.close(); } - } - const output = JSON.stringify(result); - if (Buffer.byteLength(output) > MAX_BYTES) throw new Error("query byte limit"); - fs.writeFileSync(1, output); -} catch { - // No source text, SDK diagnostics or partial result crosses the failure boundary. - process.exitCode = 1; -} -`; - -function unavailable(): never { - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable"); -} - -function record(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -function text(value: unknown): value is string { - return typeof value === "string" && value.length > 0; -} - -function node(value: unknown): value is Node { - return record(value) - && ["id", "kind", "name", "qualifiedName", "filePath", "language"].every(key => typeof value[key] === "string") - && text(value.id) - && ["startLine", "endLine", "startColumn", "endColumn", "updatedAt"].every(key => typeof value[key] === "number" && Number.isFinite(value[key])); -} - -function edge(value: unknown): value is Edge { - return record(value) && text(value.source) && text(value.target) - && (value.kind === "calls" || value.kind === "references") - && (value.line === undefined || (typeof value.line === "number" && Number.isFinite(value.line))) - && (value.column === undefined || (typeof value.column === "number" && Number.isFinite(value.column))) - && (value.metadata === undefined || record(value.metadata)); -} - -function fileRecord(value: unknown): value is FileRecord { - return record(value) && text(value.path) && typeof value.contentHash === "string" && typeof value.language === "string" - && ["size", "modifiedAt", "indexedAt", "nodeCount"].every(key => typeof value[key] === "number" && Number.isFinite(value[key])) - && (value.errors === undefined || Array.isArray(value.errors)); -} - -function requested(table: Map, key: string): T { - if (!table.has(key)) return unavailable(); - return table.get(key)!; -} - -function sameKeys(table: Map, expected: Iterable): void { - const keys = new Set(expected); - if (keys.size !== table.size || [...keys].some(key => !table.has(key))) unavailable(); -} - -function normalizedRequest(request: SnapshotQueryRequest): SnapshotQueryRequest { - if (!record(request) || !Array.isArray(request.names) || !Array.isArray(request.sources) - || typeof request.includeFiles !== "boolean" || request.names.length > MAX_KEYS || request.sources.length > MAX_KEYS - || !request.names.every(text) - || !request.sources.every(source => record(source) && text(source.name) && text(source.path))) unavailable(); - const names = [...new Set(request.names)]; - const requestedNames = new Set(names); - const sources = [...new Map(request.sources.map(source => [JSON.stringify([source.name, source.path]), { name: source.name, path: source.path }])).values()]; - if (sources.some(source => !requestedNames.has(source.name))) unavailable(); - return { names, sources, includeFiles: request.includeFiles }; -} - -function decodeReader(output: string, root: string, request: SnapshotQueryRequest): SnapshotQueryReader { - const wire: unknown = JSON.parse(output); - if (!record(wire) || wire.version !== 1 || !text(wire.root) || !isAbsolute(wire.root) || resolve(wire.root) !== resolve(root)) unavailable(); - let items = 0; - const rows = (value: unknown, valid: (item: unknown) => item is T): T[] => { - if (!Array.isArray(value) || (items += value.length) > MAX_ITEMS || !value.every(valid)) return unavailable(); - return value; - }; - const table = (value: unknown, decode: (value: unknown) => T): Map => { - if (!Array.isArray(value) || value.length > MAX_KEYS) return unavailable(); - const result = new Map(); - for (const row of value) { - if (!Array.isArray(row) || row.length !== 2 || !text(row[0]) || result.has(row[0])) unavailable(); - result.set(row[0], decode(row[1])); - } - return result; - }; - const names = table(wire.names, value => rows(value, node)); - sameKeys(names, request.names); - for (const [name, nodes] of names) if (nodes.some(item => item.name !== name)) unavailable(); - const filesByPath = table(wire.filesByPath, value => rows(value, node)); - const sources = new Map(); - const sourcePaths = new Set(request.sources.map(source => source.path)); - for (const source of request.sources) { - for (const item of requested(filesByPath, source.path)) { - if (item.name !== source.name) continue; - if (!requested(names, source.name).some(named => named.id === item.id)) unavailable(); - sources.set(item.id, item); - sourcePaths.add(item.filePath); - } - } - sameKeys(filesByPath, sourcePaths); - const outgoing = table(wire.outgoing, value => rows(value, edge)); - sameKeys(outgoing, sources.keys()); - const targetIds = new Set(); - for (const [id, edges] of outgoing) for (const item of edges) { - if (item.source !== id) unavailable(); - targetIds.add(item.target); - } - const targets = table(wire.targets, value => { - if (++items > MAX_ITEMS || (value !== null && !node(value))) return unavailable(); - return value as Node | null; - }); - sameKeys(targets, targetIds); - for (const [id, item] of targets) if (item !== null && item.id !== id) unavailable(); - const files = request.includeFiles ? rows(wire.files, fileRecord) : null; - if (!request.includeFiles && wire.files !== null) unavailable(); - if (files && new Set(files.map(file => file.path)).size !== files.length) unavailable(); - const projectRoot = wire.root; - return { - getNodesByName: name => requested(names, name), - getNodesInFile: path => requested(filesByPath, path), - getOutgoingEdges: id => requested(outgoing, id), - getNode: id => requested(targets, id), - getFiles: () => files ?? unavailable(), - getProjectRoot: () => projectRoot - }; -} - -/** The caller resolves the pinned provider runtime and owns removal of the private snapshot. */ -export function readSnapshotQueries( - runtime: { node: string; sdk: string }, root: string, request: SnapshotQueryRequest -): SnapshotQueryReader { - try { - if (!isAbsolute(runtime.node) || !isAbsolute(runtime.sdk) || !isAbsolute(root)) unavailable(); - const normalized = normalizedRequest(request); - const input = JSON.stringify(normalized); - if (Buffer.byteLength(input) > MAX_REQUEST_BYTES) unavailable(); - const env = codeGraphChildEnv({ DO_NOT_TRACK: "1", CODEGRAPH_NO_DOWNLOAD: "1" }); - // A host's injected Node preload/options must not change the pinned query process. - delete env.NODE_OPTIONS; - const child = spawnSync(runtime.node, ["--input-type=commonjs", "-e", QUERY_SCRIPT, runtime.sdk, root], { - cwd: root, input, encoding: "utf8", env, windowsHide: true, - timeout: 30_000, killSignal: "SIGKILL", maxBuffer: MAX_OUTPUT_BYTES, - stdio: ["pipe", "pipe", "pipe"] - }); - if (child.error || child.signal || child.status !== 0 || Buffer.byteLength(child.stdout) > MAX_OUTPUT_BYTES) unavailable(); - return decodeReader(child.stdout, root, normalized); - } catch { - return unavailable(); - } -} diff --git a/packages/local-runtime/codegraph-adapter/src/snapshot.ts b/packages/local-runtime/codegraph-adapter/src/snapshot.ts deleted file mode 100644 index da0814a5..00000000 --- a/packages/local-runtime/codegraph-adapter/src/snapshot.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { FailureError } from "@archcontext/contracts"; -import { execFileSync, spawnSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readSync, realpathSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { basename, dirname, isAbsolute, join, relative } from "node:path"; -import { REQUIRED_CODEGRAPH_VERSION, codeGraphBundle, pinnedCodeGraphProvider } from "./read-session"; -import { readSnapshotQueries, type SnapshotQueryRequest, type SnapshotQueryReader } from "./snapshot-query"; -import { listGitVisibleFiles } from "@archcontext/local-runtime/git-adapter"; -import { nativeNodeSource, type NativeModel } from "@archcontext/core/projection-engine"; -import { CODEGRAPH_OUTPUT_MAX_BYTES, isExecutablePath } from "./cli-provider"; -import { inspectRepositoryCodeFacts } from "./repository-code-facts"; -import { normalizedIndexPath } from "./selector-index"; - -/** - * 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 } { - 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: join(bundle.dist, "index.js") }; -} - -function codeGraphSourceScanner(): (root: string) => string[] { - try { return pinnedCodeGraphProvider().scanSourceFiles; } - catch { throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable"); } -} - -/** 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; -export const CODEGRAPH_SNAPSHOT_MAX_ENTRIES = 100_000; - -export function codeGraphSnapshotQueries(nodes: NativeModel["nodes"], purpose: "flow" | "ref" = "flow"): SnapshotQueryRequest { - const names = new Set(); - const sources = new Map(); - for (const node of nodes) for (const entry of nativeNodeSource(node)?.entrypoints ?? []) { - for (const symbol of entry.symbols) for (const sink of symbol.sinks) { - names.add(symbol.name); names.add(sink.symbol); - const path = normalizedIndexPath(entry.path); - sources.set(`${path}\0${symbol.name}`, { path, name: symbol.name }); - } - } - return { names: [...names].sort(), sources: purpose === "flow" ? [...sources.values()] : [], includeFiles: purpose === "ref" }; -} - -/** - * Runs synchronous readers against a private, ready CodeGraph snapshot. Provider status opens - * writable and SQLite readOnly can create WAL/SHM files, so neither ever sees the original root. - * The provider-enumerated filesystem mirror preserves source discovery and configuration rather - * than approximating its source extensions, ignores or content hashes. Nothing is re-indexed. - */ -export function withExistingCodeGraphSnapshot( - root: string, - queries: SnapshotQueryRequest, - 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"]; - if (redirectedGitInputs.some(key => process.env[key]) || process.env.CODEGRAPH_DIR) { - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); - } - const indexDir = join(originalRoot, ".codegraph"); - if (!existsSync(join(indexDir, "codegraph.db"))) throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "index-missing"); - if (lstatSync(indexDir).isSymbolicLink()) throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); - const gitPath = (flag: string) => realpathSync.native(execFileSync("git", ["rev-parse", "--path-format=absolute", flag], { - cwd: originalRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 5_000 - }).trim()); - const gitDir = gitPath("--git-dir"); - const commonDir = gitPath("--git-common-dir"); - assertSnapshotGitReadSafety(originalRoot); - const temporaryParent = realpathSync.native(tmpdir()); - const temporaryRelative = relative(originalRoot, temporaryParent); - if (!temporaryRelative || (temporaryRelative !== ".." && !temporaryRelative.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) && !isAbsolute(temporaryRelative))) { - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); - } - const temporaryRoot = mkdtempSync(join(temporaryParent, "archctx-codegraph-snapshot-")); - try { - const snapshotRoot = join(temporaryRoot, "repository"); - mkdirSync(snapshotRoot); - const before = mirrorCodeGraphInputs(originalRoot, gitDir, commonDir, snapshotRoot); - if (before !== mirrorCodeGraphInputs(originalRoot, gitDir, commonDir)) { - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-changed"); - } - const scan = codeGraphSourceScanner(); - 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); - if (ready.state !== "ready") { - // 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 }); - } - // 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) { - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); - } - if (before !== mirrorCodeGraphInputs(originalRoot, gitDir, commonDir)) { - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-changed"); - } - return result; - } catch (error) { - if (error instanceof FailureError) throw error; - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable"); - } finally { - rmSync(temporaryRoot, { recursive: true, force: true }); - } -} - -function assertSnapshotGitReadSafety(root: string): void { - const options = { cwd: root, stdio: "pipe" as const, timeout: 5_000, maxBuffer: CODEGRAPH_OUTPUT_MAX_BYTES }; - const config = spawnSync("git", ["config", "--get-regexp", "^(core\\.(worktree|fsmonitor)|include\\.|include[iI]f\\.)"], { ...options, encoding: "utf8" }); - if ((config.status !== 0 && config.status !== 1) || config.stdout.trim()) { - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); - } - // Provider readiness runs git status, which may execute clean/process filters on tracked - // content. Configuration alone (e.g. a system LFS installation) does not select a filter. - // Git owns attribute evaluation, including macros, nested attributes and info/attributes. - let attributes: string[]; - let ambiguousDrivers: Set; - try { - const tracked = execFileSync("git", ["ls-files", "--cached", "-z"], options); - if (tracked.length === 0) return; - const output = execFileSync("git", ["check-attr", "-z", "--stdin", "filter"], { ...options, input: tracked }).toString("utf8"); - attributes = output.split("\0"); - if (attributes.pop() !== "" || attributes.length % 3 !== 0) throw new Error("Invalid attribute response"); - // check-attr prints literal driver names "unset"/"unspecified" identically to its - // sentinels. A configured driver with either name makes that response ambiguous. - const drivers = spawnSync("git", ["config", "--name-only", "--get-regexp", "^filter\\.(unspecified|unset)\\.(clean|process)$"], { ...options, encoding: "utf8" }); - if (drivers.status !== 0 && drivers.status !== 1) throw new Error("Cannot inspect filter drivers"); - ambiguousDrivers = new Set(drivers.stdout.trim().split(/\r?\n/).filter(Boolean).map(key => key.split(".")[1]!)); - } catch { - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable"); - } - for (let index = 0; index < attributes.length; index += 3) { - if (attributes[index + 1] !== "filter" || !["unspecified", "unset"].includes(attributes[index + 2]!) || ambiguousDrivers.has(attributes[index + 2]!)) { - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); - } - } -} - -/** - * A repository-local core.hooksPath replaces any copied, global or system value, so provider Git - * operations (an index refresh runs post-index-change) cannot execute repository or user hooks. - * A higher-precedence source such as config.worktree would beat the pin, so the value Git will - * actually use must be exactly the pinned directory. - */ -function pinSnapshotHooks(snapshotRepository: string): void { - const gitDir = join(snapshotRepository, ".git"); - const hooks = join(gitDir, "archctx-empty-hooks"); - mkdirSync(hooks); - const path = process.platform === "win32" ? hooks.replaceAll("\\", "/") : hooks; - const options = { cwd: snapshotRepository, encoding: "utf8" as const, stdio: "pipe" as const, timeout: 5_000 }; - const pinned = spawnSync("git", ["config", "--file", join(gitDir, "config"), "--replace-all", "core.hooksPath", path], options); - const effective = spawnSync("git", ["config", "--get", "core.hooksPath"], options); - if (pinned.status !== 0 || effective.status !== 0 || effective.stdout.replace(/\r?\n$/, "") !== path) { - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); - } -} - -function mirrorCodeGraphInputs(root: string, gitDir: string, commonDir: string, destination?: string): string { - const hash = createHash("sha256"); - const measured = { sourceBytes: 0, sourceEntries: 0, gitBytes: 0, gitEntries: 0, gitObjectEntries: 0, indexBytes: 0, indexEntries: 0 }; - let bytes = 0; - let entries = 0; - // After the first cap trips nothing more is read or copied; the walk only completes the counts. - let limit: "bytes" | "entries" | undefined; - const fingerprint = (source: string, target: string) => { - const stat = lstatSync(source, { bigint: true }); - if (++entries > CODEGRAPH_SNAPSHOT_MAX_ENTRIES) limit ??= "entries"; - hash.update(JSON.stringify([source, target, String(stat.ino), String(stat.size), String(stat.mtimeNs), String(stat.ctimeNs)])); - return stat; - }; - const visit = (part: "source" | "git" | "index", source: string, target: string, skip: ReadonlySet = new Set()) => { - const stat = fingerprint(source, target); - measured[`${part}Entries`]++; - if (stat.isDirectory()) { - if (destination && !limit) mkdirSync(join(destination, target), { recursive: true }); - for (const name of readdirSync(source).sort()) { - if (!skip.has(name)) visit(part, join(source, name), join(target, name)); - } - } else if (stat.isFile()) { - if (basename(target) === ".git") throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); - measured[`${part}Bytes`] += Number(stat.size); - bytes += Number(stat.size); - if (bytes > CODEGRAPH_SNAPSHOT_MAX_BYTES) limit ??= "bytes"; - if (limit) return; - // Bound the actual read as well as the prior stat: a growing file must not bypass the cap. - const data = Buffer.alloc(Number(stat.size)); - const fd = openSync(source, "r"); - try { - let offset = 0; - while (offset < data.length) { - const count = readSync(fd, data, offset, data.length - offset, offset); - if (count === 0) throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-changed"); - offset += count; - } - if (readSync(fd, Buffer.alloc(1), 0, 1, offset) !== 0) throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-changed"); - } finally { closeSync(fd); } - hash.update(data); - if (destination) { - mkdirSync(dirname(join(destination, target)), { recursive: true }); - writeFileSync(join(destination, target), data, { mode: Number(stat.mode) & 0o777 }); - } - } else { - // Following a symlink can silently omit an external source/config population or let a - // provider write outside the mirror. Such layouts require a separate snapshot contract. - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); - } - }; - // Objects are fingerprinted, never read or copied: a write to the original store changes the - // fingerprint, and the copy grows with what the read needs rather than with history. - const visitObjects = (source: string, target: string) => { - const stat = fingerprint(source, target); - measured.gitObjectEntries++; - if (stat.isDirectory()) { - for (const name of readdirSync(source).sort()) visitObjects(join(source, name), join(target, name)); - } else if (!stat.isFile()) { - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); - } - }; - // The snapshot reads the original objects read-only through a snapshot-local alternate. - // An original alternates chain, or a path Git cannot store in that file, is a separate layout. - // Repository hooks are not part of the read; the snapshot pins its own empty hooks directory. - const visitRepository = (source: string, target: string, skip: ReadonlySet) => { - const objects = join(source, "objects"); - if (existsSync(join(objects, "info/alternates"))) throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); - visit("git", source, target, new Set([...skip, "objects", "hooks", "archctx-empty-hooks"])); - visitObjects(objects, join(target, "objects")); - const original = realpathSync.native(objects); - const alternate = process.platform === "win32" ? original.replaceAll("\\", "/") : original; - if (/[\r\n]/.test(alternate)) throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); - if (destination && !limit) { - mkdirSync(join(destination, target, "objects/pack"), { recursive: true }); - mkdirSync(join(destination, target, "objects/info"), { recursive: true }); - writeFileSync(join(destination, target, "objects/info/alternates"), `${alternate}\n`); - } - }; - const sourceFiles = codeGraphSourceScanner()(root); - const files = new Set([...sourceFiles, ...listGitVisibleFiles(root)]); - const directories = new Set(["."]); - const repositories = new Set(["."]); - for (const path of files) { - if (isAbsolute(path) || path.split(/[\\/]/).includes("..")) throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); - let directory = dirname(path); - while (directory !== ".") { directories.add(directory); directory = dirname(directory); } - } - for (const directory of directories) { - for (const name of [".gitignore", ".gitattributes", "codegraph.json"]) { - const path = join(directory, name); - if (existsSync(join(root, path))) files.add(path); - } - if (directory !== "." && existsSync(join(root, directory, ".git"))) { - visitRepository(join(root, directory, ".git"), join(directory, ".git"), new Set()); - assertSnapshotGitReadSafety(join(root, directory)); - repositories.add(directory); - } - } - hash.update(JSON.stringify([...sourceFiles].sort())); - for (const file of [...files].sort()) { - if (file.split(/[\\/]/).some(part => part === ".git" || part === ".codegraph")) { - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); - } - visit("source", join(root, file), file); - } - visitRepository(commonDir, ".git", new Set(["worktrees"])); - if (gitDir !== commonDir) visit("git", gitDir, ".git", new Set(["commondir", "gitdir"])); - for (const suffix of ["", "-wal"]) { - const source = join(root, ".codegraph", `codegraph.db${suffix}`); - hash.update(JSON.stringify([suffix, existsSync(source)])); - if (existsSync(source)) visit("index", source, join(".codegraph", `codegraph.db${suffix}`)); - } - if (limit) { - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-limit-exceeded", { - limit, maxBytes: CODEGRAPH_SNAPSHOT_MAX_BYTES, maxEntries: CODEGRAPH_SNAPSHOT_MAX_ENTRIES, ...measured - }); - } - // Recheck the exact copied attribute population before any provider status operation. - if (destination) for (const repository of repositories) { - pinSnapshotHooks(join(destination, repository)); - assertSnapshotGitReadSafety(join(destination, repository)); - } - return hash.digest("hex"); -} diff --git a/packages/local-runtime/codegraph-adapter/test/capability-projection-inputs.test.ts b/packages/local-runtime/codegraph-adapter/test/capability-projection-inputs.test.ts index b1bc929c..d430565c 100644 --- a/packages/local-runtime/codegraph-adapter/test/capability-projection-inputs.test.ts +++ b/packages/local-runtime/codegraph-adapter/test/capability-projection-inputs.test.ts @@ -1,12 +1,11 @@ import { describe, expect, test } from "bun:test"; import { execFileSync } from "node:child_process"; -import { closeSync, existsSync, ftruncateSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { NativeModel } from "@archcontext/core/projection-engine"; import { digestJson } from "@archcontext/contracts"; -import { fixtureNameCounts, holdCodeGraphFixture, indexCodeGraphFixture } from "./real-index"; -import { ProjectionCodeFactsUnavailableError, RUNTIME_BUILTIN_CALL_NAMES, assertProjectionCodeFactsAvailable, codeGraphIndexAvailable, loadCapabilityCodeGraphProjectionInputs, projectionCodeFactsRequired, withExistingCodeGraphSnapshot, type CodeGraphSelectorIndex, type PreparedProjectionCodeFacts } from "../src/index"; +import { ProjectionCodeFactsUnavailableError, RUNTIME_BUILTIN_CALL_NAMES, assertProjectionCodeFactsAvailable, codeGraphIndexAvailable, loadCapabilityCodeGraphProjectionInputs, projectionCodeFactsRequired, type CodeGraphSelectorIndex, type PreparedProjectionCodeFacts } from "../src/index"; const model: NativeModel = { nodes: [ @@ -185,195 +184,6 @@ function builtinSelectorModel(sinkName: string): NativeModel { // shared by the import-graph and exact-selector assertions: spawning it per assertion made the // neighbouring adapter suite time out under parallel test execution. describe("capability documentation projection inputs from the code index", () => { - test.each(["unspecified", "unset"])("private snapshots refuse the literal filter driver %s before execution", async (driver) => { - const { root } = seedWorkspace({ withIndex: false }); - const git = (...args: string[]) => execFileSync("git", args, { cwd: root, stdio: "pipe" }); - try { - writeFileSync(join(root, ".gitignore"), ".codegraph/\n"); - git("add", "packages"); - git("-c", "user.name=Snapshot Test", "-c", "user.email=snapshot@example.test", "commit", "-qm", "fixture"); - indexCodeGraphFixture(root); - Bun.gc(true); - const marker = join(root, "filter-executed"); - const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; - const script = `require("node:fs").writeFileSync(${JSON.stringify(marker)}, "executed"); process.stdin.pipe(process.stdout);`; - git("config", `filter.${driver}.clean`, `${quote(process.execPath.replaceAll("\\", "/"))} -e ${quote(script)}`); - writeFileSync(join(root, ".gitattributes"), `*.ts filter=${driver}\n`); - let failure: unknown; - try { withExistingCodeGraphSnapshot(root, { names: ["MAIN_VERSION", "optedInLibrary", "customExtension"], sources: [], includeFiles: false }, () => true); } catch (error) { failure = error; } - expect(existsSync(marker)).toBe(false); - expect(failure).toMatchObject({ reasonCode: "code-facts-snapshot-layout-unsupported" }); - } finally { Bun.gc(true); rmSync(root, { recursive: true, force: true }); } - }, 60_000); - - test("private snapshots allow inert filter configuration and reject effective filters", async () => { - const { root } = seedWorkspace({ withIndex: false }); - writeFileSync(join(root, ".gitignore"), ".codegraph/\n"); - const git = (...args: string[]) => execFileSync("git", args, { cwd: root, stdio: "pipe" }); - try { - writeFileSync(join(root, "notes.txt"), "not a CodeGraph source\n"); - git("add", "packages", "notes.txt"); - git("-c", "user.name=Snapshot Test", "-c", "user.email=snapshot@example.test", "commit", "-qm", "fixture"); - git("config", "filter.lfs.clean", "git-lfs clean -- %f"); - git("config", "filter.lfs.process", "git-lfs filter-process"); - git("config", "diff.external.command", "exit 99"); - indexCodeGraphFixture(root); - Bun.gc(true); - const indexBefore = readFileSync(join(root, ".git/index")); - expect(withExistingCodeGraphSnapshot(root, { names: ["MAIN_VERSION", "optedInLibrary", "customExtension"], sources: [], includeFiles: false }, snapshot => snapshot.getNodesByName("MAIN_VERSION").length)).toBe(1); - expect(readFileSync(join(root, ".git/index"))).toEqual(indexBefore); - for (const pattern of ["*.ts", "notes.txt"]) { - writeFileSync(join(root, ".gitattributes"), `${pattern} filter=lfs\n`); - expect(() => withExistingCodeGraphSnapshot(root, { names: ["MAIN_VERSION", "optedInLibrary", "customExtension"], sources: [], includeFiles: false }, () => true)).toThrow("safely isolated"); - } - expect(readFileSync(join(root, ".git/index"))).toEqual(indexBefore); - } finally { Bun.gc(true); rmSync(root, { recursive: true, force: true }); } - }, 60_000); - - for (const liveWal of [false, true]) test(`private snapshot reads real facts without changing original SQLite sidecars (live WAL: ${liveWal})`, async () => { - const { root } = seedWorkspace({ withIndex: false }); - writeFileSync(join(root, ".gitignore"), ".codegraph/\nnode_modules/\n"); - mkdirSync(join(root, "node_modules")); - symlinkSync(join(root, "packages/docs"), join(root, "node_modules/docs"), process.platform === "win32" ? "junction" : "dir"); - let holder: Awaited> | undefined; - const indexState = () => readdirSync(join(root, ".codegraph")).sort().map(name => { - const path = join(root, ".codegraph", name); - return [name, statSync(path).mtimeMs, readFileSync(path).toString("base64")]; - }); - try { - indexCodeGraphFixture(root); - if (liveWal) { holder = await holdCodeGraphFixture(root); expect(holder.isRunning()).toBe(true); } - const before = indexState(); - const names = withExistingCodeGraphSnapshot(root, { names: ["MAIN_VERSION", "optedInLibrary", "customExtension"], sources: [], includeFiles: false }, graph => { - expect(graph.getProjectRoot()).not.toBe(root); - expect(graph.getNodesByName("customExtension")).toEqual([]); - expect(() => graph.getNodesByName("not-requested")).toThrow(); - return graph.getNodesByName("MAIN_VERSION").map(node => node.name); - }); - expect(names).toContain("MAIN_VERSION"); - if (holder) expect(holder.isRunning()).toBe(true); - expect(indexState()).toEqual(before); - expect(() => withExistingCodeGraphSnapshot(root, { names: ["MAIN_VERSION", "optedInLibrary", "customExtension"], sources: [], includeFiles: false }, () => { - writeFileSync(join(root, "packages/docs/src/main.ts"), "export const MAIN_VERSION = 2;\n"); - })).toThrow(); - expect(indexState()).toEqual(before); - } finally { - await holder?.close(); - Bun.gc(true); - rmSync(root, { recursive: true, force: true }); - } - }, 60_000); - - test("private snapshot reads Git objects through a read-only alternate and refuses object writes during the read", async () => { - const { root } = seedWorkspace({ withIndex: false }); - const git = (cwd: string, ...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" }).trim(); - const query = { names: ["MAIN_VERSION"], sources: [], includeFiles: false }; - try { - writeFileSync(join(root, ".gitignore"), ".codegraph/\n"); - git(root, "add", "packages", ".gitignore"); - git(root, "-c", "user.name=Snapshot Test", "-c", "user.email=snapshot@example.test", "commit", "-qm", "fixture"); - const head = git(root, "rev-parse", "HEAD"); - indexCodeGraphFixture(root); - Bun.gc(true); - expect(withExistingCodeGraphSnapshot(root, query, graph => { - const objects = join(graph.getProjectRoot(), ".git/objects"); - return [readdirSync(objects).sort(), readdirSync(join(objects, "pack")), readdirSync(join(objects, "info")), - git(graph.getProjectRoot(), "rev-parse", "HEAD"), git(graph.getProjectRoot(), "cat-file", "-t", head)]; - })).toEqual([["info", "pack"], [], ["alternates"], head, "commit"]); - let failure: unknown; - try { - withExistingCodeGraphSnapshot(root, query, () => execFileSync("git", ["hash-object", "-w", "--stdin"], { cwd: root, input: "new object\n", stdio: "pipe" })); - } catch (error) { failure = error; } - expect(failure).toMatchObject({ reasonCode: "code-facts-snapshot-changed" }); - } finally { Bun.gc(true); rmSync(root, { recursive: true, force: true }); } - }, 60_000); - - test("private snapshot runs no repository or configured hook and refuses an overriding worktree config", async () => { - const { root } = seedWorkspace({ withIndex: false }); - const outside = mkdtempSync(join(tmpdir(), "archctx-snapshot-hooks-")); - const git = (cwd: string, ...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" }).trim(); - const posix = (path: string) => path.replaceAll("\\", "/"); - try { - writeFileSync(join(root, ".gitignore"), ".codegraph/\n"); - git(root, "add", "packages", ".gitignore"); - git(root, "-c", "user.name=Snapshot Test", "-c", "user.email=snapshot@example.test", "commit", "-qm", "fixture"); - indexCodeGraphFixture(root); - Bun.gc(true); - const markers = ["repository", "configured", "planted"].map(name => join(outside, `${name}-hook-ran`)); - mkdirSync(join(outside, "hooks")); - mkdirSync(join(root, ".git/archctx-empty-hooks")); - for (const [directory, marker] of [[join(root, ".git/hooks"), markers[0]!], [join(outside, "hooks"), markers[1]!], [join(root, ".git/archctx-empty-hooks"), markers[2]!]] as const) { - writeFileSync(join(directory, "post-index-change"), `#!/bin/sh\necho ran > "${posix(marker)}"\n`, { mode: 0o755 }); - } - git(root, "config", "core.hooksPath", posix(join(outside, "hooks"))); - expect(withExistingCodeGraphSnapshot(root, { names: ["MAIN_VERSION"], sources: [], includeFiles: false }, graph => { - const snapshotGit = join(graph.getProjectRoot(), ".git"); - git(graph.getProjectRoot(), "status", "--porcelain", "-uall"); - return [existsSync(join(snapshotGit, "hooks")), readdirSync(join(snapshotGit, "archctx-empty-hooks")), git(graph.getProjectRoot(), "config", "core.hooksPath") === posix(join(snapshotGit, "archctx-empty-hooks"))]; - })).toEqual([false, [], true]); - expect(markers.map(marker => existsSync(marker))).toEqual([false, false, false]); - git(root, "config", "extensions.worktreeConfig", "true"); - git(root, "config", "--worktree", "core.hooksPath", posix(join(outside, "hooks"))); - expect(() => withExistingCodeGraphSnapshot(root, { names: ["MAIN_VERSION"], sources: [], includeFiles: false }, () => true)).toThrow(expect.objectContaining({ reasonCode: "code-facts-snapshot-layout-unsupported" })); - expect(markers.map(marker => existsSync(marker))).toEqual([false, false, false]); - } finally { Bun.gc(true); rmSync(root, { recursive: true, force: true }); rmSync(outside, { recursive: true, force: true }); } - }, 60_000); - - test("private snapshot limit stops copying and reports the measured breakdown", () => { - const { root } = seedWorkspace({ withIndex: true }); - const git = (...args: string[]) => execFileSync("git", args, { cwd: root, stdio: "pipe" }); - try { - writeFileSync(join(root, ".gitignore"), ".codegraph/\n"); - writeFileSync(join(root, ".codegraph/codegraph.db"), ""); - git("add", "packages", ".gitignore"); - git("-c", "user.name=Snapshot Test", "-c", "user.email=snapshot@example.test", "commit", "-qm", "fixture"); - const large = openSync(join(root, "large.bin"), "w"); - try { ftruncateSync(large, 513 * 1024 * 1024); } finally { closeSync(large); } - let failure: any; - try { withExistingCodeGraphSnapshot(root, { names: ["MAIN_VERSION"], sources: [], includeFiles: false }, () => true); } catch (error) { failure = error; } - const copiedBytes = (dir: string): number => readdirSync(dir).reduce((sum, name) => { - const stat = statSync(join(dir, name)); - return sum + (!stat.isDirectory() ? stat.size : dir === join(root, ".git") && ["objects", "hooks"].includes(name) ? 0 : copiedBytes(join(dir, name))); - }, 0); - expect(failure?.reasonCode).toBe("code-facts-snapshot-limit-exceeded"); - expect(failure.details).toMatchObject({ limit: "bytes", maxBytes: 512 * 1024 * 1024, gitBytes: copiedBytes(join(root, ".git")), indexBytes: 0, indexEntries: 1 }); - expect(failure.details.sourceBytes).toBeGreaterThanOrEqual(513 * 1024 * 1024); - expect(failure.details.gitObjectEntries).toBeGreaterThan(0); - } finally { rmSync(root, { recursive: true, force: true }); } - }); - - test("private source mirror preserves provider includeIgnored, include and extension overrides", async () => { - const { root } = seedWorkspace({ withIndex: false }); - writeFileSync(join(root, ".gitignore"), ".codegraph/\nexternal-projects/\nprivate-src/\n"); - mkdirSync(join(root, "external-projects/embedded"), { recursive: true }); - execFileSync("git", ["init", "-q"], { cwd: join(root, "external-projects/embedded"), stdio: "pipe" }); - writeFileSync(join(root, "external-projects/embedded/library.ts"), "export function optedInLibrary() { return 1; }\n"); - mkdirSync(join(root, "private-src")); - writeFileSync(join(root, "private-src/source.fixture"), "export function customExtension() { return 1; }\n"); - writeFileSync(join(root, "codegraph.json"), JSON.stringify({ - includeIgnored: ["external-projects/"], include: ["private-src/**"], extensions: { ".fixture": "typescript" } - })); - try { - indexCodeGraphFixture(root); - expect(fixtureNameCounts(root, ["optedInLibrary", "customExtension"])).toEqual([1, 1]); - Bun.gc(true); - expect(withExistingCodeGraphSnapshot(root, { names: ["MAIN_VERSION", "optedInLibrary", "customExtension"], sources: [], includeFiles: false }, snapshot => [ - snapshot.getNodesByName("optedInLibrary").length, - snapshot.getNodesByName("customExtension").length - ])).toEqual([1, 1]); - writeFileSync(join(root, "external-projects/embedded/new.ts"), "export const newIgnoredSource = 1;\n"); - expect(() => withExistingCodeGraphSnapshot(root, { names: ["MAIN_VERSION", "optedInLibrary", "customExtension"], sources: [], includeFiles: false }, () => true)).toThrow(); - const embedded = join(root, "external-projects/embedded"); - execFileSync("git", ["add", "library.ts"], { cwd: embedded, stdio: "pipe" }); - execFileSync("git", ["config", "filter.selected.clean", "exit 99"], { cwd: embedded, stdio: "pipe" }); - writeFileSync(join(embedded, ".gitattributes"), "*.ts filter=selected\n"); - expect(() => withExistingCodeGraphSnapshot(root, { names: ["MAIN_VERSION", "optedInLibrary", "customExtension"], sources: [], includeFiles: false }, () => true)).toThrow("safely isolated"); - } finally { - Bun.gc(true); - rmSync(root, { recursive: true, force: true }); - } - }, 60_000); - test("measures import edges and exact node-v2 selector evidence in one index pass", () => { const { root, binary, log } = seedWorkspace({ withIndex: true }); try { diff --git a/packages/local-runtime/codegraph-adapter/test/git-spy.ts b/packages/local-runtime/codegraph-adapter/test/git-spy.ts new file mode 100644 index 00000000..e2cf1d3d --- /dev/null +++ b/packages/local-runtime/codegraph-adapter/test/git-spy.ts @@ -0,0 +1,40 @@ +import { execFileSync } from "node:child_process"; +import { chmodSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; + +/** + * The git commands ADR-0058 Decision 3 allows a code-facts read: archctx's Git-visible file list and + * the provider's file enumeration. None reads content through a filter or writes the index. + */ +const CODE_FACTS_READ_GIT_VERBS: ReadonlySet = new Set(["ls-files", "rev-parse", "check-ignore"]); +const CODE_FACTS_READ_GIT_CONFIG = "config --get core.excludesFile"; + +/** The logged subcommands outside Decision 3, and outside `verbs` the command itself runs (such as `cat-file`). */ +export function gitCommandsOutsideCodeFactsRead(subcommands: readonly string[], verbs: readonly string[] = []): string[] { + return subcommands.filter((command) => command !== CODE_FACTS_READ_GIT_CONFIG + && !CODE_FACTS_READ_GIT_VERBS.has(command.split(" ")[0]!) && !verbs.includes(command.split(" ")[0]!)); +} + +/** + * A `git` first on `PATH` that logs each invocation and runs the real one. POSIX only. Pass `env` to + * a child process; `subcommands()` drops global options (`-C `, `-c `, `--no-pager`). + */ +export function gitSpy(): { env: NodeJS.ProcessEnv; subcommands(): 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); + return { + env: { ...process.env, PATH: `${dir}${delimiter}${process.env.PATH ?? ""}` }, + subcommands: () => readFileSync(log, "utf8").split("\n").filter(Boolean).map((line) => { + const words = line.split(" "); + let index = 0; + while (index < words.length && words[index]!.startsWith("-")) index += ["-C", "-c"].includes(words[index]!) ? 2 : 1; + return words.slice(index).join(" "); + }), + restore: () => rmSync(dir, { recursive: true, force: true }) + }; +} 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 9ed847bd..3de62dfb 100644 --- a/packages/local-runtime/codegraph-adapter/test/read-session.test.ts +++ b/packages/local-runtime/codegraph-adapter/test/read-session.test.ts @@ -12,8 +12,7 @@ import { inspectRepositoryCodeFacts, openCodeGraphReadSession, pinnedCodeGraphProvider, - withCodeGraphReadSession, - withExistingCodeGraphSnapshot + withCodeGraphReadSession } from "../src/index"; import { codeGraphBundle } from "../src/read-session"; import { fakeReadProvider } from "./read-provider-fixture"; @@ -289,22 +288,6 @@ 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-"))); diff --git a/packages/local-runtime/codegraph-adapter/test/real-index.ts b/packages/local-runtime/codegraph-adapter/test/real-index.ts index 5694ec0b..38c61fc9 100644 --- a/packages/local-runtime/codegraph-adapter/test/real-index.ts +++ b/packages/local-runtime/codegraph-adapter/test/real-index.ts @@ -1,5 +1,16 @@ import { execFileSync, spawn } from "node:child_process"; -import { codeGraphSnapshotRuntime, packagedCodeGraphCliInvocation } from "../src/index"; +import { join } from "node:path"; +import { packagedCodeGraphCliInvocation } from "../src/index"; +import { codeGraphBundle } from "../src/read-session"; + +/** + * The Node executable and SDK entry of the pinned platform bundle. The fixtures below run in it, so + * their SQLite handles end with the child process; production reads never use it (ADR-0058). + */ +function codeGraphNodeRuntime(): { node: string; sdk: string } { + const bundle = codeGraphBundle(); + return { node: join(bundle.platformRoot, process.platform === "win32" ? "node.exe" : "node"), sdk: join(bundle.dist, "index.js") }; +} /** Index construction owns native parser resources; subprocess exit bounds their lifetime. */ export function indexCodeGraphFixture(root: string): void { @@ -20,14 +31,14 @@ export function syncCodeGraphFixture(root: string): void { } export function incompleteCodeGraphFixture(root: string): void { - const runtime = codeGraphSnapshotRuntime(); + const runtime = codeGraphNodeRuntime(); execFileSync(runtime.node, ["-e", 'const {CodeGraph}=require(process.argv[1]); const graph=CodeGraph.initSync(process.argv[2]); graph.close();', runtime.sdk, root], { cwd: root, stdio: "pipe", timeout: 30_000, env: { ...process.env, DO_NOT_TRACK: "1" } }); } export function fixtureNameCounts(root: string, names: string[]): number[] { - const runtime = codeGraphSnapshotRuntime(); + const runtime = codeGraphNodeRuntime(); const script = 'const {CodeGraph}=require(process.argv[1]); const names=JSON.parse(require("node:fs").readFileSync(0,"utf8")); const graph=CodeGraph.openSync(process.argv[2]); try { process.stdout.write(JSON.stringify(names.map(name=>graph.getNodesByName(name).length))); } finally { graph.close(); }'; return JSON.parse(execFileSync(runtime.node, ["-e", script, runtime.sdk, root], { cwd: root, input: JSON.stringify(names), encoding: "utf8", timeout: 30_000, @@ -36,7 +47,7 @@ export function fixtureNameCounts(root: string, names: string[]): number[] { } export async function holdCodeGraphFixture(root: string): Promise<{ isRunning(): boolean; close(): Promise }> { - const runtime = codeGraphSnapshotRuntime(); + const runtime = codeGraphNodeRuntime(); const script = 'const {CodeGraph}=require(process.argv[1]); const graph=CodeGraph.openSync(process.argv[2]); graph.getFiles(); process.stdin.resume(); process.stdin.once("end",()=>graph.close()); process.stdout.write("ready\\n");'; const child = spawn(runtime.node, ["-e", script, runtime.sdk, root], { cwd: root, stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, DO_NOT_TRACK: "1" } }); const closed = new Promise(resolve => child.once("close", resolve)); 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 62e200f7..af5d260f 100644 --- a/packages/local-runtime/runtime-daemon/src/flow-selector-check.ts +++ b/packages/local-runtime/runtime-daemon/src/flow-selector-check.ts @@ -1,6 +1,6 @@ import { FailureError, okEnvelope, type Json, type JsonEnvelope } from "@archcontext/contracts"; import { architectureDocumentationSourceTreeDigest, checkArchitectureFlowSelectors, loadNativeModelFromArchContext } from "@archcontext/core/projection-engine"; -import { assertProjectionCodeFactsAvailable, codeGraphSnapshotQueries, loadCapabilityCodeGraphProjectionInputs, prepareProjectionCodeFacts, REQUIRED_CODEGRAPH_VERSION, withExistingCodeGraphSnapshot } from "@archcontext/local-runtime/codegraph-adapter"; +import { assertProjectionCodeFactsAvailable, loadCapabilityCodeGraphProjectionInputs, prepareProjectionCodeFacts, REQUIRED_CODEGRAPH_VERSION, withReadyCodeGraphIndex } from "@archcontext/local-runtime/codegraph-adapter"; import { findRepositoryRoot, listGitVisibleFiles } from "@archcontext/local-runtime/git-adapter"; import { selectedProducerFailureEnvelope } from "./producer-failure"; @@ -10,7 +10,7 @@ import { selectedProducerFailureEnvelope } from "./producer-failure"; * no daemon, no local store, no runtime state, no task session, no ChangeSet. It takes the same * CodeGraph handshake as a projection (version, status, `codegraph sync`) and reads selector * evidence through the same producer, but skips the import-graph query and every renderer. - * `noSync` requires a ready existing index; a provider Node process reads its private snapshot. + * `noSync` requires a ready existing index and reads it in-process and read-only (ADR-0058). */ export function runFlowSelectorCheckCommand(cwd: string, options: { binary?: string; noSync?: boolean } = {}): JsonEnvelope { try { @@ -19,9 +19,9 @@ export function runFlowSelectorCheckCommand(cwd: string, options: { binary?: str catch { throw new FailureError("AC_REPO_NOT_FOUND", "git-worktree-required"); } const model = loadNativeModelFromArchContext(root); if (options.noSync) { - const selectorEvidence = withExistingCodeGraphSnapshot(root, codeGraphSnapshotQueries(model.nodes), graph => loadCapabilityCodeGraphProjectionInputs(root, model, { - readOnly: true, importGraphs: false, - // The snapshot boundary owns handle lifetime, including the post-read stability check. + const selectorEvidence = withReadyCodeGraphIndex(root, graph => loadCapabilityCodeGraphProjectionInputs(root, model, { + importGraphs: false, + // The read session owns the handle, including the post-read revision check. selectorIndexFactory: () => ({ getNodesByName: name => graph.getNodesByName(name), getNodesInFile: path => graph.getNodesInFile(path), 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 58804dde..de84988b 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 @@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url"; import { failureIssues } from "@archcontext/contracts"; import { initializeArchContextModel } from "@archcontext/local-runtime/model-store-yaml"; import { runFlowSelectorCheckCommand } from "../src/flow-selector-check"; +import { gitCommandsOutsideCodeFactsRead, gitSpy } from "../../codegraph-adapter/test/git-spy"; import { incompleteCodeGraphFixture, indexCodeGraphFixture } from "../../codegraph-adapter/test/real-index"; function fixture() { @@ -35,20 +36,31 @@ function fixture() { return root; } +/** + * ADR-0058 Decision 4: a read may add SQLite's shared-memory file and an empty WAL to `.codegraph/`, + * and nothing else. Adding them changes only that directory's own stat. + */ function snapshot(root: string): unknown[] { - return readdirSync(root).sort().map(name => { + return readdirSync(root).sort().filter(name => { + const path = join(root, name); + return name !== "codegraph.db-shm" && !(name === "codegraph.db-wal" && statSync(path).size === 0); + }).map(name => { const path = join(root, name); const stat = statSync(path, { bigint: true }); + if (name === ".codegraph" && stat.isDirectory()) return [name, snapshot(path)]; return [name, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs, stat.isDirectory() ? snapshot(path) : readFileSync(path).toString("base64")]; }); } -for (const state of ["missing", "incomplete", "ready", "stale", "foreign"] as const) { +for (const state of ["missing", "incomplete", "ready", "stale", "foreign", "unopenable"] as const) { test(`no-sync preserves original checkout and index for ${state}`, async () => { const root = fixture(); let other: string | undefined; try { - if (state !== "missing") { + if (state === "unopenable") { + mkdirSync(join(root, ".codegraph")); + writeFileSync(join(root, ".codegraph/codegraph.db"), "not a sqlite database\n"); + } else if (state !== "missing") { if (state === "incomplete") incompleteCodeGraphFixture(root); else indexCodeGraphFixture(root); Bun.gc(true); @@ -66,12 +78,20 @@ for (const state of ["missing", "incomplete", "ready", "stale", "foreign"] as co expect(result.data).toMatchObject({ selectors: [{ status: "proven" }] }); // 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); + const spy = process.platform === "win32" ? undefined : gitSpy(); + try { + const output = execFileSync(process.execPath, [cli, "flows", "check", "--no-sync", "--json"], { cwd: target, encoding: "utf8", env: spy?.env ?? process.env }); + expect(JSON.parse(output)).toEqual(result); + if (spy) { + // No `status`, `diff`, `check-attr` or other `config`: the read runs no filter and refreshes no index. + expect(spy.subcommands().some(command => command.startsWith("ls-files"))).toBe(true); + expect(gitCommandsOutsideCodeFactsRead(spy.subcommands())).toEqual([]); + } + } finally { spy?.restore(); } expect(runFlowSelectorCheckCommand(target, { noSync: true })).toEqual(result); } else { expect(result).toMatchObject({ ok: false, error: { code: "AC_CODE_FACTS_UNAVAILABLE", reasonCode: - state === "missing" ? "index-missing" : state === "foreign" ? "code-facts-snapshot-layout-unsupported" : "code-facts-read-not-ready" + state === "missing" ? "index-missing" : state === "foreign" || state === "unopenable" ? "index-open-failed" : "code-facts-read-not-ready" } }); expect(failureIssues(result.error)).toEqual([]); } diff --git a/packages/surfaces/cli/test/failure-envelope.test.ts b/packages/surfaces/cli/test/failure-envelope.test.ts index 0bcacace..6149fd66 100644 --- a/packages/surfaces/cli/test/failure-envelope.test.ts +++ b/packages/surfaces/cli/test/failure-envelope.test.ts @@ -71,7 +71,7 @@ test("all registered producer reasons survive selected CLI and MCP adapters", as projectionInputField: "headSha", ownershipAction: "remove-whole-file-outputs-and-reapply", ledgerModeField: "readMode", changedFields: ["headSha", "worktreeDigest"], publicConfirmationToken: facts.confirmationToken, consentReason: "not-granted", searched: "recorded", decisionStatus: "accepted", disposition: "resolved", - commandSuggestion: null, context7Kind: "timeout", repoPath: "packages/fixture/package.json", exportsSubpath: "./sub", snapshotLimit: "bytes", + commandSuggestion: null, context7Kind: "timeout", repoPath: "packages/fixture/package.json", exportsSubpath: "./sub", modelValidationDiagnostics: [{ path: "docs/adr/ADR-0001-x.md", code: "schema-version-missing" }], stateMachineIssues: [".archcontext/model/state-machines/order.yaml: regions[0].initial: state-machine-initial-unknown"] }; diff --git a/packages/surfaces/cli/test/ref-impact.test.ts b/packages/surfaces/cli/test/ref-impact.test.ts index e035692f..3165ec4b 100644 --- a/packages/surfaces/cli/test/ref-impact.test.ts +++ b/packages/surfaces/cli/test/ref-impact.test.ts @@ -7,19 +7,24 @@ import { describe, expect, test } from "bun:test"; import { runRefImpactCommand } from "../src/ref-impact"; import { runCli } from "../src/main"; import { type RefImpactV1 } from "@archcontext/contracts"; -import { createNativeFixture, commitFixture, writeFixtureFile } from "../../../local-runtime/model-store-yaml/test/fixtures/native-context/fixture"; +import { createNativeFixture, commitFixture, fixtureGit, writeFixtureFile } from "../../../local-runtime/model-store-yaml/test/fixtures/native-context/fixture"; import { packagedCodeGraphCliInvocation } from "../../../local-runtime/codegraph-adapter/src/index"; +import { gitCommandsOutsideCodeFactsRead, gitSpy } from "../../../local-runtime/codegraph-adapter/test/git-spy"; // 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)); +/** ADR-0058 Decision 4: a read may add SQLite's shared-memory file and an empty WAL, nothing else. */ function indexBytes(root: string): Record { const files: Record = {}; const visit = (path: string) => { for (const entry of readdirSync(join(root, path), { withFileTypes: true })) { const name = `${path}/${entry.name}`; + const bytes = entry.isDirectory() ? undefined : readFileSync(join(root, name)); if (entry.isDirectory()) visit(name); - else files[name] = createHash("sha256").update(readFileSync(join(root, name))).digest("hex"); + else if (name !== ".codegraph/codegraph.db-shm" && !(name === ".codegraph/codegraph.db-wal" && bytes!.length === 0)) { + files[name] = createHash("sha256").update(bytes!).digest("hex"); + } } }; visit(".codegraph"); @@ -102,6 +107,40 @@ test("real CodeGraph distinguishes ambiguous declarations from a renamed selecto } finally { fixture.cleanup(); } }, 120_000); +test("a configured clean filter still gets evidence, and the read runs no filter, config, check-attr or status", () => { + const fixture = createNativeFixture(); + const spy = process.platform === "win32" ? undefined : gitSpy(); + try { + const marker = join(fixture.root, "runtime-state-filter-ran"); + writeFixtureFile(fixture.root, ".gitignore", ".codegraph/\nruntime-state-filter-ran\n"); + // An identity clean filter that records each run. The private snapshot refused any selected filter. + fixtureGit(fixture.root, ["config", "filter.spy.clean", `sh -c 'echo ran >> "${marker.replaceAll("\\", "/")}"; cat'`]); + writeFixtureFile(fixture.root, ".gitattributes", "*.ts filter=spy\n"); + writeFixtureFile(fixture.root, "src/done.ts", "export function finish() { return 1; }\n"); + const before = "import { finish } from './done';\nexport function run() {\n return finish();\n}\n"; + writeFixtureFile(fixture.root, "src/worker.ts", before); + const base = commitFixture(fixture.root); + writeFixtureFile(fixture.root, "src/worker.ts", before.replace(" return", " const touched = 1;\n void touched;\n return")); + const head = commitFixture(fixture.root); + const provider = packagedCodeGraphCliInvocation(); + const env = { ...(spy?.env ?? process.env), DO_NOT_TRACK: "1", CODEGRAPH_NO_DOWNLOAD: "1" }; + execFileSync(provider.command, [...provider.argsPrefix, "init", fixture.root], { cwd: fixture.root, env: { ...process.env, DO_NOT_TRACK: "1", CODEGRAPH_NO_DOWNLOAD: "1" }, stdio: "pipe" }); + expect(existsSync(marker)).toBe(true); + rmSync(marker); + const frozen = indexBytes(fixture.root); + const result = JSON.parse(execFileSync(process.execPath, [CLI_LAUNCHER, "impact", "--base", base, "--head", head, "--json"], { cwd: fixture.root, env, encoding: "utf8" })); + expect(result.data.touchedFlowSteps.status).toBe("known"); + expect(result.data.touchedFlowSteps.items).toContainEqual({ flowId: "flow.demo.primary", stepId: "request", outcomeId: null, reasonCodes: ["head-lines-changed"] }); + expect(existsSync(marker)).toBe(false); + expect(indexBytes(fixture.root)).toEqual(frozen); + if (spy) { + expect(spy.subcommands().some(command => command.startsWith("cat-file"))).toBe(true); + // Decision 3 plus the immutable ref reads of impact itself. + expect(gitCommandsOutsideCodeFactsRead(spy.subcommands(), ["ls-tree", "cat-file", "log"])).toEqual([]); + } + } finally { spy?.restore(); fixture.cleanup(); } +}, 120_000); + describe("impact CLI", () => { test.each(["\n", "\r\n"])("file and stdin preserve path whitespace with %j delimiters", newline => { const fixture = createNativeFixture(); diff --git a/scripts/codegraph-selector-node-integration.mjs b/scripts/codegraph-selector-node-integration.mjs index f7b9b48f..b9ba8240 100644 --- a/scripts/codegraph-selector-node-integration.mjs +++ b/scripts/codegraph-selector-node-integration.mjs @@ -206,17 +206,20 @@ try { assert.equal(proof.duplicate.ambiguous, true); assert.equal(proof.duplicate.matched, false); - const indexFiles = (directory = join(fixtureRoot, ".codegraph")) => readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)).map(entry => { - const path = join(directory, entry.name); - return entry.isDirectory() ? [entry.name, indexFiles(path)] : [entry.name, readFileSync(path).toString("base64"), statSync(path).mtimeMs]; - }); + // ADR-0058 Decision 4: a read may add SQLite's shared-memory file and an empty WAL, nothing else. + const indexFiles = (directory = join(fixtureRoot, ".codegraph")) => readdirSync(directory, { withFileTypes: true }) + .filter(entry => entry.name !== "codegraph.db-shm" && !(entry.name === "codegraph.db-wal" && statSync(join(directory, entry.name)).size === 0)) + .sort((a, b) => a.name.localeCompare(b.name)).map(entry => { + const path = join(directory, entry.name); + return entry.isDirectory() ? [entry.name, indexFiles(path)] : [entry.name, readFileSync(path).toString("base64"), statSync(path).mtimeMs]; + }); const { CodeGraph } = createRequire(import.meta.url)("@colbymchenry/codegraph"); for (const liveWal of [false, true]) { const holder = liveWal ? CodeGraph.openSync(fixtureRoot) : undefined; try { const before = indexFiles(); - assert.equal(adapter.withExistingCodeGraphSnapshot(fixtureRoot, { names: ["renderMain"], sources: [], includeFiles: false }, graph => graph.getNodesByName("renderMain").length), 1); - assert.deepEqual(indexFiles(), before, `private Node snapshot must preserve original DB and sidecars, liveWal=${liveWal}`); + assert.equal(adapter.withReadyCodeGraphIndex(fixtureRoot, graph => graph.getNodesByName("renderMain").length), 1); + assert.deepEqual(indexFiles(), before, `the in-process read must preserve the database and its WAL, liveWal=${liveWal}`); } finally { holder?.close(); } } // ADR-0058: the read session under Node, in this process, with no holder: the writer of the @@ -271,15 +274,15 @@ try { 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"); - assert.deepEqual(indexFiles(), beforeStale, "stale Node snapshot must not sync the original index"); + assert.throws(() => adapter.withReadyCodeGraphIndex(fixtureRoot, () => true), error => error.reasonCode === "code-facts-read-not-ready"); + assert.deepEqual(indexFiles(), beforeStale, "a stale index is refused, never synced"); console.log(JSON.stringify({ schemaVersion: "archcontext.codegraph-selector-node-integration/v1", status: "verified", nodeVersion: process.version, codeGraphVersion: adapter.REQUIRED_CODEGRAPH_VERSION, - privateSnapshots: "closed-and-live-wal-preserved; stale-refused", + readyIndexReads: "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 } })); From be07d5f2c5235f71cedffd46d3f745220b94687e Mon Sep 17 00:00:00 2001 From: Ancienttwo <215803476+Ancienttwo@users.noreply.github.com> Date: Sat, 14 Nov 2026 04:30:59 +0800 Subject: [PATCH 2/5] refactor(codegraph-adapter): keep index.ts to the snapshot re-export removal (#574 S4) flows check --no-sync imports withReadyCodeGraphIndex from its module, as model-store-yaml imports ref-impact-facts; the Node integration bundles that module beside the adapter entry. Refs #574 --- .../codegraph-adapter/src/index.ts | 1 - .../runtime-daemon/src/flow-selector-check.ts | 3 +- .../codegraph-selector-node-integration.mjs | 48 +++++++++++-------- 3 files changed, 29 insertions(+), 23 deletions(-) diff --git a/packages/local-runtime/codegraph-adapter/src/index.ts b/packages/local-runtime/codegraph-adapter/src/index.ts index b65b48a8..d05aba3f 100644 --- a/packages/local-runtime/codegraph-adapter/src/index.ts +++ b/packages/local-runtime/codegraph-adapter/src/index.ts @@ -52,7 +52,6 @@ export { type ProjectionCodeGraphStatusIssueCode, type ProjectionCodeGraphStatusIssueV1 } from "./projection-handshake"; -export { withReadyCodeGraphIndex } from "./ready-index-read"; export { CODEGRAPH_IMPORT_NODE_READ_LIMIT, repositoryImportPairs, 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 af5d260f..4ea1f286 100644 --- a/packages/local-runtime/runtime-daemon/src/flow-selector-check.ts +++ b/packages/local-runtime/runtime-daemon/src/flow-selector-check.ts @@ -1,6 +1,7 @@ import { FailureError, okEnvelope, type Json, type JsonEnvelope } from "@archcontext/contracts"; import { architectureDocumentationSourceTreeDigest, checkArchitectureFlowSelectors, loadNativeModelFromArchContext } from "@archcontext/core/projection-engine"; -import { assertProjectionCodeFactsAvailable, loadCapabilityCodeGraphProjectionInputs, prepareProjectionCodeFacts, REQUIRED_CODEGRAPH_VERSION, withReadyCodeGraphIndex } from "@archcontext/local-runtime/codegraph-adapter"; +import { assertProjectionCodeFactsAvailable, loadCapabilityCodeGraphProjectionInputs, prepareProjectionCodeFacts, REQUIRED_CODEGRAPH_VERSION } from "@archcontext/local-runtime/codegraph-adapter"; +import { withReadyCodeGraphIndex } from "../../codegraph-adapter/src/ready-index-read"; import { findRepositoryRoot, listGitVisibleFiles } from "@archcontext/local-runtime/git-adapter"; import { selectedProducerFailureEnvelope } from "./producer-failure"; diff --git a/scripts/codegraph-selector-node-integration.mjs b/scripts/codegraph-selector-node-integration.mjs index b9ba8240..9534915c 100644 --- a/scripts/codegraph-selector-node-integration.mjs +++ b/scripts/codegraph-selector-node-integration.mjs @@ -85,27 +85,33 @@ try { "" ].join("\n")); - const bundlePath = join(buildRoot, "codegraph-adapter.mjs"); - const built = spawnSync("bun", [ - "build", - "packages/local-runtime/codegraph-adapter/src/index.ts", - "--target=node", - "--format=esm", - "--external", - "@colbymchenry/codegraph", - "--external=koffi", - "--outfile", - bundlePath - ], { - cwd: repoRoot, - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"] - }); - assert.equal(built.status, 0, built.stderr || built.stdout || "failed to bundle the CodeGraph adapter"); - - assert.equal(readFileSync(bundlePath, "utf8").includes(repoRoot), false, "Node adapter bundle must not embed the build root"); - const imported = await import(pathToFileURL(bundlePath).href); - const adapter = imported.default ?? imported; + // The adapter entry, plus the ready-index read that `flows check --no-sync` and ref impact import directly. + const bundle = async (entry, name) => { + const bundlePath = join(buildRoot, name); + const built = spawnSync("bun", [ + "build", + entry, + "--target=node", + "--format=esm", + "--external", + "@colbymchenry/codegraph", + "--external=koffi", + "--outfile", + bundlePath + ], { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"] + }); + assert.equal(built.status, 0, built.stderr || built.stdout || `failed to bundle ${entry}`); + assert.equal(readFileSync(bundlePath, "utf8").includes(repoRoot), false, "Node adapter bundle must not embed the build root"); + const imported = await import(pathToFileURL(bundlePath).href); + return imported.default ?? imported; + }; + const adapter = { + ...await bundle("packages/local-runtime/codegraph-adapter/src/index.ts", "codegraph-adapter.mjs"), + ...await bundle("packages/local-runtime/codegraph-adapter/src/ready-index-read.ts", "ready-index-read.mjs") + }; const invocation = adapter.codeGraphCliInvocation("codegraph", fixtureRoot); const cliEnv = { ...process.env, From 735b9ddeff45413901040ab1050e8ec463e72214 Mon Sep 17 00:00:00 2001 From: Ancienttwo <215803476+Ancienttwo@users.noreply.github.com> Date: Sat, 14 Nov 2026 11:14:37 +0800 Subject: [PATCH 3/5] fix(cli): describe the ADR-0058 read-only session in flows help (#574 S4) --- packages/surfaces/cli/src/main.ts | 4 ++-- packages/surfaces/cli/test/ref-impact.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/surfaces/cli/src/main.ts b/packages/surfaces/cli/src/main.ts index 36b4719c..fae27fce 100755 --- a/packages/surfaces/cli/src/main.ts +++ b/packages/surfaces/cli/src/main.ts @@ -947,8 +947,8 @@ function cliHelpEnvelope(command: string, args: string[]): JsonEnvelope { command: "archctx flows check [--no-sync] [--json]", description: "Check every declared flow selector against the CodeGraph index without rendering a projection.", statuses: "Each selector reports proven, no-direct-call, only-references, false-builtin-call, ambiguous-symbol, evidence-missing or evidence-truncated. Only proven binds a flow step.", - writes: "Default: runs codegraph sync and writes the .codegraph index. With --no-sync: no checkout or index writes; disposable private snapshots are created outside the checkout and removed after reading. Neither mode starts a daemon or task session.", - noSync: "Requires a complete existing index matching the source population. Missing, stale, partial, unsupported-layout or over-limit snapshots fail closed with AC_CODE_FACTS_UNAVAILABLE." + writes: "Default: runs codegraph sync and writes the .codegraph index. With --no-sync: no checkout or index writes; the existing index is read in-process through one read-only session (ADR-0058). Neither mode starts a daemon or task session.", + noSync: "Requires a complete existing index matching the source population. A missing, unreadable, stale or partial index, or one a build is still writing, fails closed with AC_CODE_FACTS_UNAVAILABLE." }); case "state-machines": return okEnvelope("state-machines", { diff --git a/packages/surfaces/cli/test/ref-impact.test.ts b/packages/surfaces/cli/test/ref-impact.test.ts index 3165ec4b..e9daa743 100644 --- a/packages/surfaces/cli/test/ref-impact.test.ts +++ b/packages/surfaces/cli/test/ref-impact.test.ts @@ -107,7 +107,7 @@ test("real CodeGraph distinguishes ambiguous declarations from a renamed selecto } finally { fixture.cleanup(); } }, 120_000); -test("a configured clean filter still gets evidence, and the read runs no filter, config, check-attr or status", () => { +test("a configured clean filter still gets evidence, and the read runs no filter, check-attr, status or config beyond core.excludesFile", () => { const fixture = createNativeFixture(); const spy = process.platform === "win32" ? undefined : gitSpy(); try { From 26e96f49bf8c6737af6790b9d3f4cafe0b9990c1 Mon Sep 17 00:00:00 2001 From: Ancienttwo <215803476+Ancienttwo@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:26:47 +0800 Subject: [PATCH 4/5] fix(codegraph-adapter): evaluate readiness on the read's own session; restore provider-configuration coverage (#574 S4) --- docs/runbooks/ref-impact.md | 9 +- .../codegraph-adapter/src/ready-index-read.ts | 42 ++++----- .../src/repository-code-facts.ts | 70 ++++++++------ .../test/read-session.test.ts | 94 ++++++++++++++++++- 4 files changed, 161 insertions(+), 54 deletions(-) diff --git a/docs/runbooks/ref-impact.md b/docs/runbooks/ref-impact.md index a47d349c..7a9d67ce 100644 --- a/docs/runbooks/ref-impact.md +++ b/docs/runbooks/ref-impact.md @@ -41,13 +41,16 @@ include, includeIgnored and extension settings. Impact summarizes every code-fac refusal as `ref-code-facts-unavailable`: a missing index, an index that cannot be opened, an index behind the worktree and an index that moved during the read. `flows check --no-sync` reads the same way and exposes the specific reason: -`index-missing`, `index-open-failed` or `code-facts-read-not-ready`. +`index-missing`, `index-open-failed`, `code-facts-read-not-ready` or +`code-facts-unavailable` (the CodeGraph provider could not load). The read runs only the git commands ADR-0058 allows: `ls-files`, `rev-parse`, `check-ignore` and `config --get core.excludesFile`, plus the `rev-parse`, `ls-tree`, `cat-file` and `log` reads of the immutable refs. It never runs `git -status` or `git diff`, so configured clean/process filters, `core.fsmonitor` and -index hooks are not executed, and a repository that uses them still gets evidence. +status` or `git diff` and never writes the Git index, so configured clean/process +filters and index-writing hooks do not run, and a repository that uses them +still gets evidence. A configured `core.fsmonitor` hook still runs: `ls-files` and +`check-ignore` invoke it, as any git read of that repository does. SQLite may leave the `codegraph.db-wal` and `codegraph.db-shm` sidecar files in `.codegraph/`; nothing else is written. diff --git a/packages/local-runtime/codegraph-adapter/src/ready-index-read.ts b/packages/local-runtime/codegraph-adapter/src/ready-index-read.ts index 6fd7a400..2aab9a44 100644 --- a/packages/local-runtime/codegraph-adapter/src/ready-index-read.ts +++ b/packages/local-runtime/codegraph-adapter/src/ready-index-read.ts @@ -1,21 +1,22 @@ -import { CODE_FACTS_ISSUE_MESSAGES, FailureError } from "@archcontext/contracts"; -import { withCodeGraphReadSession, type CodeGraphIndexHandle } from "./read-session"; -import { inspectRepositoryCodeFacts } from "./repository-code-facts"; +import { FailureError } from "@archcontext/contracts"; +import { pinnedCodeGraphProvider, withCodeGraphReadSession, type CodeGraphIndexHandle } from "./read-session"; +import { movingCodeFacts, openSessionCodeFacts, type RepositoryCodeFactsInspectionOptions, type RepositoryCodeFactsV1 } from "./repository-code-facts"; /** * Runs `read` against the repository's own index through one read-only session (ADR-0058), and * only while that index attests to the worktree: it is compatible and CodeGraph's change test finds - * no pending source change. Nothing syncs, copies or writes the index; `flows check --no-sync` and - * ref impact read through here. + * no pending source change. Readiness and `read` use the same handle. Nothing syncs, copies or + * writes the index; `flows check --no-sync` and ref impact read through here. * * - no `.codegraph/codegraph.db`: `AC_CODE_FACTS_UNAVAILABLE` / `index-missing` * - a linked `.codegraph`, or an index that cannot be opened and read: `index-open-failed` * - an incompatible or stale index, a live build, or an index that moved during the read: - * `code-facts-read-not-ready` with the readiness issues + * `code-facts-read-not-ready` with the readiness issues; retryable for a live build or a move * - any other failure, such as a provider that cannot load: `code-facts-unavailable` */ -export function withReadyCodeGraphIndex(root: string, read: (graph: CodeGraphIndexHandle) => T): T { +export function withReadyCodeGraphIndex(root: string, read: (graph: CodeGraphIndexHandle) => T, options: RepositoryCodeFactsInspectionOptions = {}): T { try { + const provider = options.provider ?? pinnedCodeGraphProvider(); return withCodeGraphReadSession(root, (opened) => { switch (opened.state) { case "index-missing": @@ -23,22 +24,19 @@ export function withReadyCodeGraphIndex(root: string, read: (graph: CodeGraph case "index-open-failed": throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "index-open-failed"); case "build-in-progress": - throw indexMoving(); + throw notReady(movingCodeFacts(provider.version)); case "open": { - // Readiness opens its own session inside this one. An unchanged revision after the read - // (design G4) proves that both sessions read one index state. - const facts = inspectRepositoryCodeFacts(root); - if (facts.state === "index-missing") throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "index-missing"); - if (facts.state === "unavailable") { - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", facts.reasonCode === "index-open-failed" ? "index-open-failed" : "code-facts-unavailable"); - } - if (facts.state === "not-ready") throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-read-not-ready", { issues: facts.issues }); + let readiness: { facts: RepositoryCodeFactsV1; retryable: boolean }; + try { readiness = openSessionCodeFacts(opened.session, "freshness"); } + catch { throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "index-open-failed"); } + if (readiness.facts.state !== "ready") throw notReady(readiness); const result = read(opened.session.graph); - if (!opened.session.unchangedSinceOpen()) throw indexMoving(); + // An unchanged revision after the read (design G4) proves readiness and the read saw one index state. + if (!opened.session.unchangedSinceOpen()) throw notReady(movingCodeFacts(provider.version)); return result; } } - }); + }, { provider }); } catch (error) { if (error instanceof FailureError) throw error; // No provider or SDK text crosses this boundary. @@ -46,9 +44,7 @@ export function withReadyCodeGraphIndex(root: string, read: (graph: CodeGraph } } -/** A provider build holds the writer lock, or the index revision moved while it was read. */ -function indexMoving(): FailureError { - return new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-read-not-ready", { - issues: [{ code: "index-incomplete", message: CODE_FACTS_ISSUE_MESSAGES["index-incomplete"] }] - }, { retryable: true }); +function notReady({ facts, retryable }: { facts: RepositoryCodeFactsV1; retryable: boolean }): FailureError { + const issues = facts.state === "not-ready" ? facts.issues : []; + return new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-read-not-ready", { issues }, retryable ? { retryable: true } : {}); } diff --git a/packages/local-runtime/codegraph-adapter/src/repository-code-facts.ts b/packages/local-runtime/codegraph-adapter/src/repository-code-facts.ts index 35573f37..586d966e 100644 --- a/packages/local-runtime/codegraph-adapter/src/repository-code-facts.ts +++ b/packages/local-runtime/codegraph-adapter/src/repository-code-facts.ts @@ -6,7 +6,8 @@ import { CodeGraphProviderUnavailableError, pinnedCodeGraphProvider, withCodeGraphReadSession, - type CodeGraphReadProvider + type CodeGraphReadProvider, + type CodeGraphReadSession } from "./read-session"; import { ProjectionCodeFactsUnavailableError, @@ -157,7 +158,6 @@ function inspectRepositoryCodeFactsTier(root: string, tier: "compatibility" | "f return { facts: unavailableCodeFacts("codegraph-unavailable", error instanceof CodeGraphProviderUnavailableError ? error.message : "CodeGraph provider could not be loaded"), retryable: false }; } const versionIssues = versionIssuesOf(codeGraphVersion); - const moving = { facts: buildInProgressCodeFacts(codeGraphVersion, versionIssues), retryable: versionIssues.length === 0 }; try { return withCodeGraphReadSession(root, (opened): { facts: RepositoryCodeFactsV1; retryable: boolean } => { switch (opened.state) { @@ -166,31 +166,9 @@ function inspectRepositoryCodeFactsTier(root: string, tier: "compatibility" | "f case "index-open-failed": return { facts: unavailableCodeFacts("index-open-failed", INDEX_OPEN_FAILED_MESSAGE), retryable: false }; case "build-in-progress": - return moving; - case "open": { - const compatibility = opened.session.compatibility(); - const pendingChanges = tier === "freshness" ? opened.session.freshness() : undefined; - // Facts read across a build or a sync are not one index state (design G4). - if (!opened.session.unchangedSinceOpen()) return moving; - const status: CodeGraphProjectionStatusV1 = { - initialized: true, - version: compatibility.version, - projectPath: compatibility.projectPath, - lastIndexed: compatibility.lastIndexed, - ...(pendingChanges === undefined ? {} : { pendingChanges }), - // `openSync(root)` never walks up to another worktree's index (ADR-0058). - worktreeMismatch: null, - index: { - ...(compatibility.builtWithVersion === null ? {} : { builtWithVersion: compatibility.builtWithVersion }), - ...(compatibility.builtWithExtractionVersion === null ? {} : { builtWithExtractionVersion: compatibility.builtWithExtractionVersion }), - currentExtractionVersion: compatibility.currentExtractionVersion, - reindexRecommended: compatibility.reindexRecommended, - ...(compatibility.state === null ? {} : { state: compatibility.state }), - pendingRefs: compatibility.pendingRefs - } - }; - return { facts: repositoryCodeFactsFromStatus(root, codeGraphVersion, status, tier === "freshness"), retryable: false }; - } + return movingCodeFacts(codeGraphVersion); + case "open": + return openSessionCodeFacts(opened.session, tier); } }, { provider }); } catch { @@ -199,6 +177,44 @@ function inspectRepositoryCodeFactsTier(root: string, tier: "compatibility" | "f } } +/** + * The `ready` or `not-ready` facts of an index already open in `session`, read on that handle. A + * provider build that holds the writer lock, or a revision that moved during the reads, gives the + * build-in-progress answer; `retryable` follows `inspectRepositoryCodeFactsTier`. Throws when a + * read of the handle fails. + */ +export function openSessionCodeFacts(session: CodeGraphReadSession, tier: "compatibility" | "freshness"): { facts: RepositoryCodeFactsV1; retryable: boolean } { + const codeGraphVersion = session.version(); + const compatibility = session.compatibility(); + const pendingChanges = tier === "freshness" ? session.freshness() : undefined; + // Facts read across a build or a sync are not one index state (design G4). + if (!session.unchangedSinceOpen()) return movingCodeFacts(codeGraphVersion); + const status: CodeGraphProjectionStatusV1 = { + initialized: true, + version: compatibility.version, + projectPath: compatibility.projectPath, + lastIndexed: compatibility.lastIndexed, + ...(pendingChanges === undefined ? {} : { pendingChanges }), + // `openSync(root)` never walks up to another worktree's index (ADR-0058). + worktreeMismatch: null, + index: { + ...(compatibility.builtWithVersion === null ? {} : { builtWithVersion: compatibility.builtWithVersion }), + ...(compatibility.builtWithExtractionVersion === null ? {} : { builtWithExtractionVersion: compatibility.builtWithExtractionVersion }), + currentExtractionVersion: compatibility.currentExtractionVersion, + reindexRecommended: compatibility.reindexRecommended, + ...(compatibility.state === null ? {} : { state: compatibility.state }), + pendingRefs: compatibility.pendingRefs + } + }; + return { facts: repositoryCodeFactsFromStatus(session.root, codeGraphVersion, status, tier === "freshness"), retryable: false }; +} + +/** The build-in-progress answer; a retry cannot fix a provider of the wrong version. */ +export function movingCodeFacts(codeGraphVersion: string): { facts: RepositoryCodeFactsV1; retryable: boolean } { + const versionIssues = versionIssuesOf(codeGraphVersion); + return { facts: buildInProgressCodeFacts(codeGraphVersion, versionIssues), retryable: versionIssues.length === 0 }; +} + /** * Read-only usability gate (ADR-0058): compatibility facts only. Pending edits affect freshness, not * index compatibility, so the gate reads no source file. 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 3de62dfb..33c1c060 100644 --- a/packages/local-runtime/codegraph-adapter/test/read-session.test.ts +++ b/packages/local-runtime/codegraph-adapter/test/read-session.test.ts @@ -12,9 +12,11 @@ import { inspectRepositoryCodeFacts, openCodeGraphReadSession, pinnedCodeGraphProvider, - withCodeGraphReadSession + withCodeGraphReadSession, + type CodeGraphReadProvider } from "../src/index"; import { codeGraphBundle } from "../src/read-session"; +import { withReadyCodeGraphIndex } from "../src/ready-index-read"; import { fakeReadProvider } from "./read-provider-fixture"; import { indexCodeGraphFixture } from "./real-index"; @@ -288,6 +290,96 @@ describe("read session over a real index", () => { }); }); +describe("withReadyCodeGraphIndex: readiness and the read on one session", () => { + test("a ready read opens one handle, queries it, and closes it", () => { + withRoot((root) => { + const provider = fakeReadProvider(root); + let atRead: number[] = []; + expect(withReadyCodeGraphIndex(root, (graph) => { + atRead = [provider.calls.open, provider.calls.close]; + return graph.getProjectRoot(); + }, { provider })).toBe(root); + expect(atRead).toEqual([1, 0]); + expect([provider.calls.open, provider.calls.close]).toEqual([1, 1]); + }); + }); + + const notReady = (retryable: boolean) => ({ code: "AC_CODE_FACTS_UNAVAILABLE", reasonCode: "code-facts-read-not-ready", retryable }); + // A revision read after readiness that differs from the one at open, as a sync committing mid-query. + const movesDuringQuery = (root: string) => { + const base = fakeReadProvider(root); + let queried = false; + const provider: CodeGraphReadProvider = { + get version() { return base.version; }, + get extractionVersion() { return base.extractionVersion; }, + openReadOnly: (openedRoot) => { + const graph = base.openReadOnly(openedRoot); + const revision = graph.getIndexRevision.bind(graph); + return Object.assign(graph, { getIndexRevision: () => ({ ...revision(), fileCount: queried ? 99 : 1 }) }); + }, + scanSourceFiles: (scanRoot) => base.scanSourceFiles(scanRoot), + changeHash: (path) => base.changeHash(path) + }; + return { provider, calls: base.calls, during: () => { queried = true; } }; + }; + const lockDuringQuery = (root: string) => { + const provider = fakeReadProvider(root); + return { provider, calls: provider.calls, during: () => writeFileSync(join(root, ".codegraph", "codegraph.lock"), String(process.pid)) }; + }; + const refusing = (facts: Parameters[1], before?: (root: string) => void) => (root: string) => { + const provider = fakeReadProvider(root, facts); + before?.(root); + return { provider, calls: provider.calls, during: () => {} }; + }; + const failingRead = (root: string) => { + const provider = fakeReadProvider(root); + return { provider, calls: provider.calls, during: () => { throw new Error("private raw provider error /outside/repository"); } }; + }; + + test.each([ + ["pending source changes", refusing({ pending: { added: 1, modified: 0, removed: 0 } }), notReady(false), false, 1], + ["a revision that moves before the query", refusing({ revisionMoves: true }), notReady(true), false, 1], + ["a moving index of the wrong provider version", refusing({ revisionMoves: true, version: "0.0.1" }), notReady(false), false, 1], + ["a live build at open", refusing({}, root => writeFileSync(join(root, ".codegraph", "codegraph.lock"), String(process.pid))), notReady(true), false, 0], + ["an index whose readiness reads fail", refusing({ readFailsAfterOpen: true }), { reasonCode: "index-open-failed" }, false, 1], + ["a revision that moves during the query", movesDuringQuery, notReady(true), true, 1], + ["a writer lock that appears during the query", lockDuringQuery, notReady(true), true, 1], + ["a query that throws a non-FailureError", failingRead, { code: "AC_CODE_FACTS_UNAVAILABLE", reasonCode: "code-facts-unavailable" }, true, 1] + ] as const)("%s is refused, and the handle is closed", (_name, setup, error, queried, opens) => { + withRoot((root) => { + const { provider, calls, during } = setup(root); + let ran = false; + expect(() => withReadyCodeGraphIndex(root, () => { ran = true; during(); return true; }, { provider })).toThrow(expect.objectContaining(error)); + expect(ran).toBe(queried); + expect([calls.open, calls.close]).toEqual([opens, opens]); + }); + }); +}); + +test("a ready read sees includeIgnored, include and extension sources; a new source under includeIgnored makes it not ready", () => { + withRoot((root) => { + execFileSync("git", ["init", "-q"], { cwd: root }); + writeFileSync(join(root, ".gitignore"), ".codegraph/\nexternal-projects/\nprivate-src/\n"); + mkdirSync(join(root, "src")); + writeFileSync(join(root, "src/main.ts"), "export const MAIN_VERSION = 1;\n"); + mkdirSync(join(root, "external-projects/embedded"), { recursive: true }); + execFileSync("git", ["init", "-q"], { cwd: join(root, "external-projects/embedded") }); + writeFileSync(join(root, "external-projects/embedded/library.ts"), "export function optedInLibrary() { return 1; }\n"); + mkdirSync(join(root, "private-src")); + writeFileSync(join(root, "private-src/source.fixture"), "export function customExtension() { return 1; }\n"); + writeFileSync(join(root, "codegraph.json"), JSON.stringify({ + includeIgnored: ["external-projects/"], include: ["private-src/**"], extensions: { ".fixture": "typescript" } + })); + indexCodeGraphFixture(root); + const names = ["MAIN_VERSION", "optedInLibrary", "customExtension"]; + expect(withReadyCodeGraphIndex(root, graph => names.map(name => graph.getNodesByName(name).length))).toEqual([1, 1, 1]); + writeFileSync(join(root, "external-projects/embedded/new.ts"), "export const newIgnoredSource = 1;\n"); + expect(() => withReadyCodeGraphIndex(root, () => true)).toThrow(expect.objectContaining({ + reasonCode: "code-facts-read-not-ready", details: { issues: [expect.objectContaining({ code: "pending-source-changes" })] } + })); + }); +}, 120_000); + /** 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 586d0dec5947b414c04e6cf3e4f6f09b575274bf Mon Sep 17 00:00:00 2001 From: Ancienttwo <215803476+Ancienttwo@users.noreply.github.com> Date: Sat, 14 Nov 2026 12:54:45 +0800 Subject: [PATCH 5/5] refactor(codegraph-adapter): one shared git spy; drop fixtures only the deleted snapshot tests used (#574 S4) --- .../test/read-session.test.ts | 33 +++++--------- .../codegraph-adapter/test/real-index.ts | 44 +------------------ 2 files changed, 11 insertions(+), 66 deletions(-) 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 33c1c060..712e9fa2 100644 --- a/packages/local-runtime/codegraph-adapter/test/read-session.test.ts +++ b/packages/local-runtime/codegraph-adapter/test/read-session.test.ts @@ -4,7 +4,7 @@ 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 { join } from "node:path"; import { CODEGRAPH_PROVIDER_INTERNAL_EXPORTS, REQUIRED_CODEGRAPH_VERSION, @@ -18,6 +18,7 @@ import { import { codeGraphBundle } from "../src/read-session"; import { withReadyCodeGraphIndex } from "../src/ready-index-read"; import { fakeReadProvider } from "./read-provider-fixture"; +import { gitSpy } from "./git-spy"; import { indexCodeGraphFixture } from "./real-index"; function withRoot(run: (root: string) => T): T { @@ -243,14 +244,20 @@ describe("read session over a real index", () => { const before = { gitIndex: statSync(gitIndex).mtimeMs, database: digest(database) }; const spy = process.platform === "win32" ? undefined : gitSpy(); let commands: string[] = []; + // The read is in-process, so the spy goes on this process's PATH for its duration. + const path = process.env.PATH; + if (spy) process.env.PATH = spy.env.PATH; 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(); } + commands = spy?.subcommands() ?? []; + } finally { + process.env.PATH = path; + 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); @@ -379,23 +386,3 @@ test("a ready read sees includeIgnored, include and extension sources; a new sou })); }); }, 120_000); - -/** A `git` first on PATH that logs each command and runs the real one. POSIX only. */ -function gitSpy(): { commands(): string[]; restore(): void } { - const dir = realpathSync.native(mkdtempSync(join(tmpdir(), "archctx-git-spy-"))); - const log = join(dir, "git.log"); - const real = execFileSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).trim(); - writeFileSync(log, ""); - writeFileSync(join(dir, "git"), `#!/bin/sh\nprintf '%s\\n' "$*" >> '${log}'\nexec '${real}' "$@"\n`); - chmodSync(join(dir, "git"), 0o755); - const previous = process.env.PATH; - process.env.PATH = `${dir}${delimiter}${previous ?? ""}`; - return { - // `-C ` is a location, not the command. - commands: () => readFileSync(log, "utf8").split("\n").filter(Boolean).map(line => line.replace(/^-C \S+ /, "")), - restore: () => { - process.env.PATH = previous; - rmSync(dir, { recursive: true, force: true }); - } - }; -} diff --git a/packages/local-runtime/codegraph-adapter/test/real-index.ts b/packages/local-runtime/codegraph-adapter/test/real-index.ts index 38c61fc9..4f5fa367 100644 --- a/packages/local-runtime/codegraph-adapter/test/real-index.ts +++ b/packages/local-runtime/codegraph-adapter/test/real-index.ts @@ -1,4 +1,4 @@ -import { execFileSync, spawn } from "node:child_process"; +import { execFileSync } from "node:child_process"; import { join } from "node:path"; import { packagedCodeGraphCliInvocation } from "../src/index"; import { codeGraphBundle } from "../src/read-session"; @@ -36,45 +36,3 @@ export function incompleteCodeGraphFixture(root: string): void { cwd: root, stdio: "pipe", timeout: 30_000, env: { ...process.env, DO_NOT_TRACK: "1" } }); } - -export function fixtureNameCounts(root: string, names: string[]): number[] { - const runtime = codeGraphNodeRuntime(); - const script = 'const {CodeGraph}=require(process.argv[1]); const names=JSON.parse(require("node:fs").readFileSync(0,"utf8")); const graph=CodeGraph.openSync(process.argv[2]); try { process.stdout.write(JSON.stringify(names.map(name=>graph.getNodesByName(name).length))); } finally { graph.close(); }'; - return JSON.parse(execFileSync(runtime.node, ["-e", script, runtime.sdk, root], { - cwd: root, input: JSON.stringify(names), encoding: "utf8", timeout: 30_000, - env: { ...process.env, DO_NOT_TRACK: "1" }, stdio: ["pipe", "pipe", "pipe"] - })); -} - -export async function holdCodeGraphFixture(root: string): Promise<{ isRunning(): boolean; close(): Promise }> { - const runtime = codeGraphNodeRuntime(); - const script = 'const {CodeGraph}=require(process.argv[1]); const graph=CodeGraph.openSync(process.argv[2]); graph.getFiles(); process.stdin.resume(); process.stdin.once("end",()=>graph.close()); process.stdout.write("ready\\n");'; - const child = spawn(runtime.node, ["-e", script, runtime.sdk, root], { cwd: root, stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, DO_NOT_TRACK: "1" } }); - const closed = new Promise(resolve => child.once("close", resolve)); - let ready = false; - let output = ""; - let timer: ReturnType | undefined; - try { - await new Promise((resolve, reject) => { - timer = setTimeout(() => reject(new Error("CodeGraph fixture holder did not become ready")), 15_000); - child.once("error", reject); - child.once("close", code => { if (!ready) reject(new Error(`CodeGraph fixture holder exited: ${code}`)); }); - child.stdout.on("data", chunk => { - output += String(chunk); - if (output === "ready\n") { ready = true; resolve(); } - else if (output.length > 1024) reject(new Error("Unexpected fixture holder output")); - }); - }); - } catch (error) { - child.kill("SIGKILL"); await closed; throw error; - } finally { if (timer) clearTimeout(timer); } - return { - isRunning: () => child.exitCode === null && child.signalCode === null, - close: async () => { - child.stdin.end(); - const deadline = setTimeout(() => child.kill("SIGKILL"), 10_000); - try { if (await closed !== 0) throw new Error("CodeGraph fixture holder did not close cleanly"); } - finally { clearTimeout(deadline); } - } - }; -}