diff --git a/docs/spec.md b/docs/spec.md index 69033f0e..d7ef0c10 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -224,11 +224,13 @@ shared `evidenceSampleLimit` and the candidates cut by `maxRecommendationsPerRun a scan-candidate decision and a scan-candidate `recommendations show` refuse an unrecordable run with `AC_REFACTOR_RUN_TOO_LARGE` (not retryable; `error.details` carries the measured size and the limit), and the scan omits `recordCommand` for such a run. A stale `--expected-worktree-digest` on a decision returns `AC_REFACTOR_STALE`. -The scan reads all CodeGraph import nodes in one query sized from the index count. The request -field `importNodeLimit` (CLI `--import-node-limit`, default 50000) caps that read. When the index +The scan reads all CodeGraph import nodes through the in-process read-only index handle (ADR-0058). +The request field `importNodeLimit` (CLI `--import-node-limit`, default 64000) caps the nodes kept; +above the cap the kept nodes are the first ones in (file path, specifier, line) order. When the index holds more import nodes than the cap, `codeFacts` reports `coverage: partial`, `truncated: false`, `importNodeCount`, `edgeLimit` and reason code `code-facts-import-limit`. `code-facts-truncated` -means the read returned fewer nodes than the index reported, or no index answered. +means the read returned a different number of nodes than the index reported, the index changed +during the read, or no index answered. The scan measures the `HEAD` tree only. `untrackedExcludedCount` counts the worktree files that Git does not ignore and that `HEAD` does not carry (untracked and newly staged files). A commit that adds them changes the numbers for the same model (capability `refactor-scan-untracked-count-v1`). diff --git a/packages/local-runtime/codegraph-adapter/src/import-population.ts b/packages/local-runtime/codegraph-adapter/src/import-population.ts index c43b71e3..ccb7336e 100644 --- a/packages/local-runtime/codegraph-adapter/src/import-population.ts +++ b/packages/local-runtime/codegraph-adapter/src/import-population.ts @@ -1,9 +1,8 @@ import { posix } from "node:path"; import type { CapabilitySourceFootprint, CapabilityImportEdge, CapabilityImportGraph } from "@archcontext/core/projection-engine"; import type { NormalizedEdge } from "@archcontext/contracts"; -import { CODEGRAPH_OUTPUT_MAX_BYTES, CODEGRAPH_QUERY_TIMEOUT_MS, codeGraphCliInvocation, runCodeGraphCli, type CodeGraphCliNode } from "./cli-provider"; -import { readProjectionCodeGraphStatus, type CodeGraphProjectionStatusV1 } from "./projection-handshake"; -import { codeGraphIndexAvailable } from "./repository-code-facts"; +import type { CodeGraphCliNode } from "./cli-provider"; +import { withCodeGraphReadSession, type CodeGraphReadProvider, type CodeGraphReadSession } from "./read-session"; const SOURCE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".json"]; @@ -72,13 +71,13 @@ export function uniqueStrings(items: string[]): string[] { } /** - * Default cap on the import nodes `repositoryImportPairs` and the docs projection read. The read - * is sized from the index's own import count, so this cap only bounds memory and time. One import - * node costs about 0.7 KiB of CLI output, so the default is about 35 MiB. + * Default cap on the import nodes `repositoryImportPairs` and the docs projection keep. The read + * loads the index's whole import population in process (`getNodesByKind("import")` has no limit), + * so the cap bounds the coverage a consumer is given, not the memory of the read. 64 000 is about + * 10x repo-harness's 6 420 import nodes; at 64 000 nodes the read measured 0.6 s and 340 MiB peak + * RSS under Node, within 7 % of a 50 000 cap on the same index (#585). */ -export const CODEGRAPH_IMPORT_NODE_READ_LIMIT = 50_000; -/** Output allowance per import node, so a large read is not cut by the CLI output buffer. */ -const CODEGRAPH_IMPORT_NODE_OUTPUT_BYTES = 4 * 1024; +export const CODEGRAPH_IMPORT_NODE_READ_LIMIT = 64_000; export type ProjectionImportPopulationV1 = Omit; @@ -96,25 +95,22 @@ export interface RepositoryImportPairsV1 { pairs: RepositoryImportPairV1[]; /** * True when the pairs are not the whole population for a reason other than the limit: no - * index answered, or the query returned a different number of import nodes than the index - * reported (the index changed during the read). + * index answered, or the index changed during the read (its import count or its revision moved). */ truncated: boolean; - /** `ready` only when an index exists and reports no pending source changes. */ + /** `ready` only when an index answered, reports no pending source changes and did not move during the read. */ availability: "ready" | "unavailable"; /** The worktree the index attests to, or `null` when it attests to nothing usable. */ indexedWorktreeDigest: string | null; - /** - * Import nodes in the index, from `codegraph status` (`nodesByKind.import`). `null` when no - * index answered, or when the status gave no count and the read stopped at the limit. - */ + /** Import nodes in the index (`getStats().nodesByKind.import`). `null` when no index answered. */ importNodeCount: number | null; /** The read limit this call applied. */ importNodeLimit: number; /** - * True when the index holds more import nodes than `importNodeLimit`. The pairs then come from - * the first `importNodeLimit` nodes. This is a configured limit, not an incomplete index, so - * `truncated` stays false. + * True when the index holds more import nodes than `importNodeLimit`, by its count or by the + * nodes the read returned. The pairs then come from the first `importNodeLimit` nodes in + * (filePath, name, startLine) order. The limit is configured, not an incomplete index, so on its + * own it leaves `truncated` false. */ importNodeLimitExceeded: boolean; } @@ -131,51 +127,64 @@ export interface RepositoryImportPairsV1 { * pairs as `importPopulation`, and keeps its resolved-only graph edge set, which the capability * projection fixtures pin byte for byte. * - * Freshness is producer-owned evidence, never a caller assertion: the returned - * `indexedWorktreeDigest` binds the edges to `worktreeDigest` only when the index reports itself - * clean, so a consumer cannot claim complete coverage over an index that never saw this tree. + * One read-only session answers everything (ADR-0058): no CodeGraph command, no `git status` or + * `git diff`. Freshness is producer-owned evidence, never a caller assertion: the returned + * `indexedWorktreeDigest` binds the edges to `worktreeDigest` only when CodeGraph's own change + * test finds no pending source change and the index did not move during the read, so a consumer + * cannot claim complete coverage over an index that never saw this tree. An index that is + * missing, mid-build or cannot be opened is `unavailable`. * - * `limit` is a safety cap, not a page size. The read asks the index for its import count first - * and then reads that whole population in one query, so a complete index gives complete pairs up - * to `limit` import nodes. Above it, `importNodeLimitExceeded` is true and `truncated` stays false. + * `limit` is a safety cap, not a page size. Above it, `importNodeLimitExceeded` is true and + * `truncated` stays false. */ export function repositoryImportPairs( root: string, - binary: string, limit: number, worktreeDigest: string, /** The Git-visible files (`listGitVisibleFiles`) that `worktreeDigest` was computed over. */ - sourceFiles: readonly string[] + sourceFiles: readonly string[], + options: { provider?: CodeGraphReadProvider } = {} ): RepositoryImportPairsV1 { - if (!Number.isInteger(limit) || limit < 1) throw new Error(`import node limit must be a positive integer, got ${limit}`); - if (!codeGraphIndexAvailable(root)) { + assertImportNodeLimit(limit); + return withCodeGraphReadSession(root, (opened): RepositoryImportPairsV1 => { + if (opened.state !== "open") { + return { + pairs: [], + truncated: true, + availability: "unavailable", + indexedWorktreeDigest: null, + importNodeCount: null, + importNodeLimit: limit, + importNodeLimitExceeded: false + }; + } + const { session } = opened; + const nodes = codeGraphImportPopulation(session, limit); + const pending = session.freshness(); + // Facts read across a sync are not one index state (design G4). + const unchanged = session.unchangedSinceOpen(); + const clean = unchanged && pending.added === 0 && pending.modified === 0 && pending.removed === 0; return { - pairs: [], - truncated: true, - availability: "unavailable", - indexedWorktreeDigest: null, - importNodeCount: null, + pairs: importPairsOf(new Set(sourceFiles), nodes.imports), + truncated: nodes.truncated || !unchanged, + availability: clean ? "ready" : "unavailable", + indexedWorktreeDigest: clean ? worktreeDigest : null, + importNodeCount: nodes.count, importNodeLimit: limit, - importNodeLimitExceeded: false + importNodeLimitExceeded: nodes.limitExceeded }; - } - const status = readProjectionCodeGraphStatus(codeGraphCliInvocation(binary, root, ""), root, CODEGRAPH_QUERY_TIMEOUT_MS); - const nodes = codeGraphImportPopulation(root, binary, limit, statusImportNodeCount(status)); - const pending = status.pendingChanges; - const clean = status.initialized && pending !== undefined && pending.added === 0 && pending.modified === 0 && pending.removed === 0; - return { - pairs: importPairsOf(new Set(sourceFiles), nodes.imports), - truncated: nodes.truncated, - availability: clean ? "ready" : "unavailable", - indexedWorktreeDigest: clean ? worktreeDigest : null, - importNodeCount: nodes.count, - importNodeLimit: limit, - importNodeLimitExceeded: nodes.limitExceeded - }; + }, options); } +function assertImportNodeLimit(limit: number): void { + if (!Number.isInteger(limit) || limit < 1) throw new Error(`import node limit must be a positive integer, got ${limit}`); +} + +/** The fields of an import node this producer reads; the SDK node and the CLI node both carry them. */ +type ImportNode = Pick; + /** Import nodes as repo-relative pairs, unresolved specifiers kept as `to: null`, sorted by file then specifier. */ -function importPairsOf(gitVisibleFiles: ReadonlySet, imports: CodeGraphCliNode[]): RepositoryImportPairV1[] { +function importPairsOf(gitVisibleFiles: ReadonlySet, imports: readonly ImportNode[]): RepositoryImportPairV1[] { const pairs = new Map(); for (const node of imports) { if (node.kind !== "import") continue; @@ -192,64 +201,51 @@ function importPairsOf(gitVisibleFiles: ReadonlySet, imports: CodeGraphC } /** - * The import population the index reports, or `null` when the status gives no `import` count. - * A missing `import` kind is unknown, not zero: a CodeGraph that names the kind differently must - * fall back to the limit-sized read, not to a one-node read that is always short. - */ -export function statusImportNodeCount(status: CodeGraphProjectionStatusV1): number | null { - if (!status.nodesByKind || typeof status.nodesByKind !== "object") return null; - const count = status.nodesByKind.import; - return Number.isInteger(count) && count! >= 0 ? count! : null; -} - -/** - * Reads the whole import population in one query. `codegraph query` has no offset, so the query - * limit is the count the index reported, plus one: a reply longer or shorter than that count - * means the index changed during the read, which is real truncation. Without a reported count, - * the read asks for `limit + 1` and reports the reply size as the count. + * Reads the whole import population from the session's handle. The count comes first + * (`getStats`), then the nodes; each is one statement, so a node list longer or shorter than the + * count means the index changed between them, which is real truncation. The kept prefix is always + * at most `limit` nodes in (filePath, name, startLine) order, whatever the count said, so a + * population that grew after the count cannot escape the cap and the cut does not depend on the + * order the index stores them in. The limit counts as exceeded when either observation is above + * it. `getStats` counts kinds with a `GROUP BY`, so an absent `import` key is zero import nodes. */ function codeGraphImportPopulation( - root: string, - binary: string, - limit: number, - reportedCount: number | null, - envOverrides?: Record -): { imports: CodeGraphCliNode[]; count: number | null; truncated: boolean; limitExceeded: boolean } { - const queryLimit = Math.min(reportedCount ?? limit, limit) + 1; - const output = runCodeGraphCli( - binary, - root, - ["query", "-p", root, "-j", "-l", String(queryLimit), "-k", "import", "import"], - Math.max(CODEGRAPH_OUTPUT_MAX_BYTES, queryLimit * CODEGRAPH_IMPORT_NODE_OUTPUT_BYTES), - envOverrides - ); - const imports = (JSON.parse(output) as { node: CodeGraphCliNode }[]).map(({ node }) => node); - const limitExceeded = (reportedCount ?? imports.length) > limit; - if (limitExceeded) { - // The cap explains a reply of exactly `limit` nodes. A shorter reply means the index shrank - // during the read, which is real truncation on top of the limit. - return { imports: imports.slice(0, limit), count: reportedCount, truncated: imports.length < limit, limitExceeded }; - } + session: CodeGraphReadSession, + limit: number +): { imports: ImportNode[]; count: number; truncated: boolean; limitExceeded: boolean } { + const count = session.graph.getStats().nodesByKind.import ?? 0; + const imports: ImportNode[] = session.graph.getNodesByKind("import").sort(compareImportNodes); return { - imports, - count: reportedCount ?? imports.length, - truncated: reportedCount !== null && imports.length !== reportedCount, - limitExceeded + imports: imports.slice(0, limit), + count, + truncated: imports.length !== count, + limitExceeded: Math.max(count, imports.length) > limit }; } +function compareImportNodes(left: ImportNode, right: ImportNode): number { + return compareStrings(left.filePath, right.filePath) || compareStrings(left.name, right.name) || (left.startLine ?? 0) - (right.startLine ?? 0); +} + +/** Code-unit order, independent of the process locale. */ +function compareStrings(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} +/** + * Per-capability import graphs over the session's import population. An index that moved since + * the session opened (revision or writer lock) marks the population and every graph truncated. + */ export function capabilityImportGraphs( - root: string, - binary: string, + session: CodeGraphReadSession, gitVisibleFiles: ReadonlySet, footprints: readonly CapabilitySourceFootprint[], - limit: number, - reportedCount: number | null, - envOverrides?: Record + limit: number ): { importGraphs: CapabilityImportGraph[]; importPopulation: ProjectionImportPopulationV1 } { - if (!Number.isInteger(limit) || limit < 1) throw new Error(`import node limit must be a positive integer, got ${limit}`); - const nodes = codeGraphImportPopulation(root, binary, limit, reportedCount, envOverrides); + assertImportNodeLimit(limit); + const nodes = codeGraphImportPopulation(session, limit); + // As in `repositoryImportPairs`: facts read across a sync are not one index state (design G4). + const truncated = nodes.truncated || !session.unchangedSinceOpen(); const population = importPairsOf(gitVisibleFiles, nodes.imports); // The graphs keep their resolved-only edge set, which the capability projection fixtures pin. const resolved = population.flatMap((pair) => pair.to === null ? [] : [{ from: pair.from, to: pair.to }]); @@ -264,7 +260,7 @@ export function capabilityImportGraphs( nodeId: footprint.nodeId, files: footprint.files, edges: [...edges.values()].sort((left, right) => left.from.localeCompare(right.from) || left.to.localeCompare(right.to)), - truncated: nodes.truncated, + truncated, ...(nodes.limitExceeded ? { importNodeLimitExceeded: true as const } : {}) }; }); @@ -272,7 +268,7 @@ export function capabilityImportGraphs( importGraphs, importPopulation: { pairs: population, - truncated: nodes.truncated, + truncated, importNodeCount: nodes.count, importNodeLimit: limit, importNodeLimitExceeded: nodes.limitExceeded diff --git a/packages/local-runtime/codegraph-adapter/src/index.ts b/packages/local-runtime/codegraph-adapter/src/index.ts index 47145aaf..869c7255 100644 --- a/packages/local-runtime/codegraph-adapter/src/index.ts +++ b/packages/local-runtime/codegraph-adapter/src/index.ts @@ -27,6 +27,7 @@ export { codeGraphCliInvocation, disableCodeGraphTelemetryByDefault, packagedCodeGraphCliInvocation, + packagedCodeGraphRuntimeDigest, type CodeGraphCliInvocation, type CodeGraphProvider } from "./cli-provider"; diff --git a/packages/local-runtime/codegraph-adapter/src/projection-handshake.ts b/packages/local-runtime/codegraph-adapter/src/projection-handshake.ts index 1e03cc03..5c542ba8 100644 --- a/packages/local-runtime/codegraph-adapter/src/projection-handshake.ts +++ b/packages/local-runtime/codegraph-adapter/src/projection-handshake.ts @@ -27,7 +27,6 @@ import { packagedCodeGraphRuntimeDigest, type CodeGraphCliInvocation } from "./cli-provider"; -import { statusImportNodeCount } from "./import-population"; import { assertRepositoryCodeFactsReady, codeGraphIndexAvailable } from "./repository-code-facts"; import { loadCapabilityCodeGraphProjectionInputs, type CapabilityCodeGraphProjectionInputs } from "./selector-index"; @@ -264,9 +263,7 @@ export function prepareProjectionCodeFacts( const inputs = loadCapabilityCodeGraphProjectionInputs(root, model, { ...(options.sourceFiles ? { sourceFiles: options.sourceFiles } : {}), ...(options.footprints ? { footprints: options.footprints } : {}), - binary: binaryPath, importNodeLimit: options.importNodeLimit, - importNodeCount: statusImportNodeCount(postStatus), ...(options.importGraphs === false ? { importGraphs: false } : {}) }); const finalStatus = readProjectionCodeGraphStatus(invocation, root, statusTimeout); diff --git a/packages/local-runtime/codegraph-adapter/src/selector-index.ts b/packages/local-runtime/codegraph-adapter/src/selector-index.ts index 4fba008e..53959210 100644 --- a/packages/local-runtime/codegraph-adapter/src/selector-index.ts +++ b/packages/local-runtime/codegraph-adapter/src/selector-index.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import type { Edge as CodeGraphEdge, Node as CodeGraphNode } from "@colbymchenry/codegraph"; -import { codeGraphBundle, type CodeGraphSdk } from "./read-session"; +import { codeGraphBundle, withCodeGraphReadSession, type CodeGraphReadProvider, type CodeGraphSdk } from "./read-session"; import { listGitVisibleFiles } from "@archcontext/local-runtime/git-adapter"; import { loadCapabilitySourceFootprints, @@ -10,7 +10,7 @@ import { type CapabilityImportGraph, type NativeModel } from "@archcontext/core/projection-engine"; -import { DEFAULT_CODEGRAPH_BINARY, disableCodeGraphTelemetryByDefault } from "./cli-provider"; +import { disableCodeGraphTelemetryByDefault } from "./cli-provider"; import { CODEGRAPH_IMPORT_NODE_READ_LIMIT, capabilityImportGraphs, resolveImportTarget, type ProjectionImportPopulationV1 } from "./import-population"; import { codeGraphIndexAvailable } from "./repository-code-facts"; @@ -41,13 +41,6 @@ export interface CodeGraphSelectorIndex { close(): void; } -/** - * The packaged CodeGraph npm shim downloads its platform bundle from GitHub Releases when the - * optional platform package is absent. `CODEGRAPH_NO_DOWNLOAD` (read by the shim's selfHealBundle) - * turns that fetch off, so the snapshot's CLI probe stays local whatever the egress mode is. - */ -const CODEGRAPH_NO_DOWNLOAD_ENV: Record = { CODEGRAPH_NO_DOWNLOAD: "1" }; - export interface CodeGraphSymbolDeclaration { name: string; qualifiedName: string; @@ -212,16 +205,12 @@ export function loadCapabilityCodeGraphProjectionInputs( sourceFiles?: readonly string[]; /** Footprints resolved over the Git-visible files; resolved from `root` when omitted. */ footprints?: readonly CapabilitySourceFootprint[]; - binary?: string; - /** Safety cap on the import nodes read; `CODEGRAPH_IMPORT_NODE_READ_LIMIT` when omitted. */ + /** The provider the import read opens the index with; the pinned one unless a test injects one. */ + provider?: CodeGraphReadProvider; + /** Safety cap on the import nodes kept; `CODEGRAPH_IMPORT_NODE_READ_LIMIT` when omitted. */ importNodeLimit?: number; - /** - * Import nodes in the index, from the `codegraph status` the caller asserted. The read is sized - * from it. `null` or omitted is an unknown count: the read then asks for the limit. - */ - importNodeCount?: number | null; selectorIndexFactory?: (root: string, options: { readOnly: boolean }) => CodeGraphSelectorIndex; - /** Read consumers open selectors read-only and prohibit the import-query shim from downloading. */ + /** Read consumers open selectors read-only. */ readOnly?: boolean; /** `false` skips the import-graph query; `flows check` reads selector evidence only. */ importGraphs?: boolean; @@ -242,11 +231,13 @@ export function loadCapabilityCodeGraphProjectionInputs( if (!codeGraphIndexAvailable(root)) return { importGraphs: [], selectorEvidence: [] }; disableCodeGraphTelemetryByDefault(); - const binary = options.binary ?? DEFAULT_CODEGRAPH_BINARY; const importTargets = new Set(sourceFiles()); const imports = footprints.length > 0 - ? capabilityImportGraphs(root, binary, importTargets, footprints, options.importNodeLimit ?? CODEGRAPH_IMPORT_NODE_READ_LIMIT, options.importNodeCount ?? null, - options.readOnly ? CODEGRAPH_NO_DOWNLOAD_ENV : undefined) + ? withCodeGraphReadSession(root, (opened) => { + // The read-only session (ADR-0058). An index it cannot open fails the read, never an empty graph. + if (opened.state !== "open") throw new Error(`CodeGraph index could not be read: ${opened.state}`); + return capabilityImportGraphs(opened.session, importTargets, footprints, options.importNodeLimit ?? CODEGRAPH_IMPORT_NODE_READ_LIMIT); + }, options.provider ? { provider: options.provider } : {}) : undefined; const importGraphs = imports?.importGraphs ?? []; const selectorIndex = entrypointNodes.length > 0 diff --git a/packages/local-runtime/codegraph-adapter/test/capability-import-graph-limit.test.ts b/packages/local-runtime/codegraph-adapter/test/capability-import-graph-limit.test.ts index a28a2ba6..9fe9ff9a 100644 --- a/packages/local-runtime/codegraph-adapter/test/capability-import-graph-limit.test.ts +++ b/packages/local-runtime/codegraph-adapter/test/capability-import-graph-limit.test.ts @@ -59,11 +59,16 @@ describe("docs projection import graphs over an index with more than 5000 import expect(graph.edges).toHaveLength(EDGE_COUNT); }, 120_000); - test("a limit below the import count is reported as the limit, not as truncation", () => { + test("a limit below the import count is reported as the limit, not as truncation, and keeps the sorted prefix", () => { const prepared = prepareProjectionCodeFacts(root, model, { sourceTreeDigest: SOURCE_TREE_DIGEST, importNodeLimit: 1000 }); const graph = prepared.importGraphs[0]; expect(graph.importNodeLimitExceeded).toBe(true); expect(graph.truncated).toBe(false); expect(graph.edges.length).toBeLessThan(EDGE_COUNT); + // 1000 nodes in (filePath, name, startLine) order are the whole of the first ten files by + // path, and each file imports every other file. + const prefixFiles = Array.from({ length: FILE_COUNT }, (_, file) => `src/file-${file}.ts`).sort().slice(0, 1000 / IMPORTS_PER_FILE); + expect([...new Set(graph.edges.map((edge) => edge.from))].sort()).toEqual(prefixFiles); + expect(graph.edges).toHaveLength(prefixFiles.length * (FILE_COUNT - 1)); }, 120_000); }); 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..baf8c33d 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 @@ -6,6 +6,7 @@ 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 { fakeReadProvider, type FakeReadProvider } from "./read-provider-fixture"; import { ProjectionCodeFactsUnavailableError, RUNTIME_BUILTIN_CALL_NAMES, assertProjectionCodeFactsAvailable, codeGraphIndexAvailable, loadCapabilityCodeGraphProjectionInputs, projectionCodeFactsRequired, withExistingCodeGraphSnapshot, type CodeGraphSelectorIndex, type PreparedProjectionCodeFacts } from "../src/index"; const model: NativeModel = { @@ -32,56 +33,49 @@ const model: NativeModel = { relations: [] }; -/** - * Fake CodeGraph CLI. Answers exactly the three shapes the capability projection loader asks for - * and fails loudly on anything else, so an unexpected query surfaces as a test failure rather - * than as a silently empty measurement. - */ -function fakeCli(logPath: string): string { - return ` -import { appendFileSync } from "node:fs"; -const argv = process.argv.slice(2); -appendFileSync(${JSON.stringify(logPath)}, JSON.stringify(argv) + "\\n"); -const importNodes = [ +const IMPORT_NODES = [ { name: "./render", filePath: "packages/docs/src/main.ts" }, { name: "../../shared/util", filePath: "packages/docs/src/main.ts" }, { name: "./helper", filePath: "packages/docs/src/render.ts" }, { name: "@archcontext/contracts", filePath: "packages/docs/src/render.ts" }, { name: "./missing-target", filePath: "packages/docs/src/render.ts" }, { name: "./main", filePath: "packages/other/src/index.ts" } -]; -if (argv[0] === "query") { - process.stdout.write(JSON.stringify(importNodes.map((node) => ({ - node: { id: "import:" + node.filePath + ":" + node.name, kind: "import", name: node.name, filePath: node.filePath } - })))); -} else if (argv.includes("--symbols-only")) { - process.stdout.write([ - "**packages/docs/src/main.ts** — 5 symbols", - "", - "**Symbols**", - "- \\\`MAIN_VERSION\\\` (constant) = \\"1\\" — :3", - "- \\\`renderMain\\\` (function) (input: string): string — :9", - "- \\\`DocsRunner\\\` (class) — :20", - "- \\\`run\\\` (method) (): void — :24", - "- \\\`helperLocal\\\` (function) (): void — :40", - "" - ].join("\\n")); -} else if (argv[0] === "node") { - const symbol = argv[3]; - const trail = { - renderMain: "**Calls →** renderBlock (packages/docs/src/render.ts:12), digestJson (packages/shared/util.ts:88), +3 more", - run: "**Calls →** renderMain (packages/docs/src/main.ts:9)", - helperLocal: "" - }[symbol] ?? ""; - process.stdout.write(["**" + symbol + "** (function)", "", trail, ""].join("\\n")); -} else { - process.stderr.write("unexpected codegraph invocation: " + JSON.stringify(argv)); - process.exit(1); -} -`; +].map((node) => ({ id: `import:${node.filePath}:${node.name}`, kind: "import", ...node })); + +/** + * An injected read-only provider whose handle answers the import read: `getStats` reports `count` + * import nodes (the six nodes when omitted) and `getNodesByKind("import")` returns `returned` nodes + * (the six, then unresolved extras from `main.ts`), or throws `failure`. `revisionMoves` moves the + * index revision after the open; `writerStartsDuringRead` takes the provider writer lock inside + * the node read. `importReads` counts the node reads. + */ +function importIndex( + root: string, + options: { count?: number; returned?: number; failure?: string; revisionMoves?: boolean; writerStartsDuringRead?: boolean } = {} +): FakeReadProvider & { importReads(): number } { + const base = fakeReadProvider(root, { revisionMoves: options.revisionMoves ?? false }); + const returned = options.returned ?? IMPORT_NODES.length; + const extras = Array.from({ length: Math.max(0, returned - IMPORT_NODES.length) }, (_, i) => + ({ id: `import:packages/docs/src/main.ts:./extra-${i}`, kind: "import", name: `./extra-${i}`, filePath: "packages/docs/src/main.ts" })); + const nodes = [...IMPORT_NODES, ...extras].slice(0, returned); + let reads = 0; + return { + ...base, + calls: base.calls, + importReads: () => reads, + openReadOnly: (openedRoot) => Object.assign(base.openReadOnly(openedRoot), { + getStats: () => ({ nodesByKind: { import: options.count ?? IMPORT_NODES.length } }), + getNodesByKind: (kind: string) => { + reads++; + if (options.failure) throw new Error(options.failure); + if (options.writerStartsDuringRead) writeFileSync(join(root, ".codegraph", "codegraph.lock"), String(process.pid)); + return kind === "import" ? nodes : []; + } + }) + }; } -function seedWorkspace(options: { withIndex: boolean }): { root: string; log: string; binary: string } { +function seedWorkspace(options: { withIndex: boolean }): { root: string } { const root = mkdtempSync(join(tmpdir(), "archctx-capability-codegraph-")); if (options.withIndex) mkdirSync(join(root, ".codegraph")); mkdirSync(join(root, "packages/docs/src"), { recursive: true }); @@ -95,15 +89,11 @@ function seedWorkspace(options: { withIndex: boolean }): { root: string; log: st writeFileSync(join(root, "packages/shared/util.ts"), "export const util = 1;\n"); writeFileSync(join(root, "packages/other/src/index.ts"), "// outside the footprint\n"); writeFileSync(join(root, "packages/other/src/main.ts"), "// resolvable, but outside the footprint\n"); - const log = join(root, "invocations.log"); - writeFileSync(log, ""); - const binary = join(root, "fake-codegraph.js"); - writeFileSync(binary, fakeCli(log)); // Footprints are measured over Git-visible files; untracked files count, so no commit is needed. execFileSync("git", ["init", "-q"], { cwd: root, stdio: ["ignore", "pipe", "pipe"] }); // Tests that commit later must not start detached auto-maintenance that writes into .git while it is copied or listed. for (const [key, value] of [["gc.auto", "0"], ["maintenance.auto", "false"]]) execFileSync("git", ["config", key, value!], { cwd: root, stdio: ["ignore", "pipe", "pipe"] }); - return { root, log, binary }; + return { root }; } function selectorIndex(options: { exactTargets?: number; unrelatedCalls?: number } = {}): CodeGraphSelectorIndex { @@ -375,9 +365,10 @@ describe("capability documentation projection inputs from the code index", () => }, 60_000); test("measures import edges and exact node-v2 selector evidence in one index pass", () => { - const { root, binary, log } = seedWorkspace({ withIndex: true }); + const { root } = seedWorkspace({ withIndex: true }); + const provider = importIndex(root); try { - const inputs = loadCapabilityCodeGraphProjectionInputs(root, model, { binary, selectorIndexFactory: () => selectorIndex({ unrelatedCalls: 50 }) }); + const inputs = loadCapabilityCodeGraphProjectionInputs(root, model, { provider, selectorIndexFactory: () => selectorIndex({ unrelatedCalls: 50 }) }); expect(inputs.importGraphs).toHaveLength(1); const graph = inputs.importGraphs[0]; @@ -417,21 +408,20 @@ describe("capability documentation projection inputs from the code index", () => } ]); - // Import diagnostics still use the CLI. Exact selectors use the uncapped structured SDK. - const invocations = readFileSync(log, "utf8").trim().split("\n").map((line) => JSON.parse(line) as string[]); - expect(invocations).toHaveLength(1); - expect(invocations[0].slice(0, 2)).toEqual(["query", "-p"]); - expect(invocations.flat()).not.toContain("--symbols-only"); + // Imports come from one read-only session and one node read; selectors from the structured index. + expect(provider.calls).toMatchObject({ open: 1, close: 1 }); + expect(provider.importReads()).toBe(1); } finally { rmSync(root, { recursive: true, force: true }); } }); test("selector proof is stable under unrelated fanout and fails closed for missing or ambiguous identities", () => { - const { root, binary } = seedWorkspace({ withIndex: true }); + const { root } = seedWorkspace({ withIndex: true }); + const provider = importIndex(root); try { const evidence = (index: CodeGraphSelectorIndex) => loadCapabilityCodeGraphProjectionInputs(root, model, { - binary, + provider, selectorIndexFactory: () => index }).selectorEvidence[0]; const unique = evidence(selectorIndex({ unrelatedCalls: 5 })); @@ -463,9 +453,8 @@ describe("capability documentation projection inputs from the code index", () => writeFileSync(join(root, ".gitignore"), `.codegraph/\n${ignoredFile}\n`); writeFileSync(join(root, ignoredFile), "// generated\n"); } - // No footprint is declared, so only the structured selector index is read: no CLI call. + // No footprint is declared, so only the structured selector index is read: no import read. const inputs = loadCapabilityCodeGraphProjectionInputs(root, builtinSelectorModel(sinkName), { - binary: join(root, "does-not-exist.js"), selectorIndexFactory: () => index }); expect(inputs.selectorEvidence).toHaveLength(1); @@ -552,18 +541,14 @@ describe("capability documentation projection inputs from the code index", () => }); }); - // The fake index always replies with its 6 import nodes, whatever `-l` asks for. const footprintOnly = { nodes: [{ ...model.nodes[0], source: { include: ["packages/docs/**"] } }], relations: [] }; - const importQueryLimits = (log: string) => readFileSync(log, "utf8").trim().split("\n") - .map((line) => JSON.parse(line) as string[]) - .filter((argv) => argv[0] === "query") - .map((argv) => argv[argv.indexOf("-l") + 1]); test("reads the whole import population when the limit equals the import count", () => { - const { root, binary, log } = seedWorkspace({ withIndex: true }); + const { root } = seedWorkspace({ withIndex: true }); + const provider = importIndex(root); try { - const inputs = loadCapabilityCodeGraphProjectionInputs(root, footprintOnly, { binary, importNodeLimit: 6, importNodeCount: 6 }); - expect(importQueryLimits(log)).toEqual(["7"]); + const inputs = loadCapabilityCodeGraphProjectionInputs(root, footprintOnly, { provider, importNodeLimit: 6 }); + expect(provider.importReads()).toBe(1); expect(inputs.importGraphs[0].truncated).toBe(false); expect(inputs.importGraphs[0].importNodeLimitExceeded).toBeUndefined(); expect(inputs.importGraphs[0].edges).toHaveLength(3); @@ -573,11 +558,10 @@ describe("capability documentation projection inputs from the code index", () => } }); - test("reports a reply shorter than the index import count as truncated", () => { - const { root, binary, log } = seedWorkspace({ withIndex: true }); + test("reports a read shorter than the index import count as truncated", () => { + const { root } = seedWorkspace({ withIndex: true }); try { - const inputs = loadCapabilityCodeGraphProjectionInputs(root, footprintOnly, { binary, importNodeCount: 8 }); - expect(importQueryLimits(log)).toEqual(["9"]); + const inputs = loadCapabilityCodeGraphProjectionInputs(root, footprintOnly, { provider: importIndex(root, { count: 8 }) }); expect(inputs.importGraphs[0].truncated).toBe(true); expect(inputs.importGraphs[0].importNodeLimitExceeded).toBeUndefined(); } finally { @@ -586,23 +570,49 @@ describe("capability documentation projection inputs from the code index", () => }); test("reports a limit below the import count apart from truncation", () => { - for (const importNodeCount of [6, null]) { - const { root, binary, log } = seedWorkspace({ withIndex: true }); - try { - const inputs = loadCapabilityCodeGraphProjectionInputs(root, footprintOnly, { binary, importNodeLimit: 5, importNodeCount }); - expect(importQueryLimits(log)).toEqual(["6"]); - expect(inputs.importGraphs[0].importNodeLimitExceeded).toBe(true); - expect(inputs.importGraphs[0].truncated).toBe(false); - } finally { - rmSync(root, { recursive: true, force: true }); + const { root } = seedWorkspace({ withIndex: true }); + try { + const inputs = loadCapabilityCodeGraphProjectionInputs(root, footprintOnly, { provider: importIndex(root), importNodeLimit: 5 }); + expect(inputs.importGraphs[0].importNodeLimitExceeded).toBe(true); + expect(inputs.importGraphs[0].truncated).toBe(false); + expect(inputs.importPopulation).toMatchObject({ importNodeCount: 6, importNodeLimit: 5, importNodeLimitExceeded: true, truncated: false }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + // An index that moves during the read, or a population that disagrees with its count, is + // truncated in the population and in every graph; the kept prefix never exceeds the limit. + for (const row of [ + { name: "same-count revision movement", index: { revisionMoves: true }, limit: 100, exceeded: false }, + { name: "a writer starting during the read below the cap", index: { writerStartsDuringRead: true }, limit: 7, exceeded: false }, + { name: "a writer starting during the read at the cap", index: { writerStartsDuringRead: true }, limit: 6, exceeded: false }, + { name: "a writer starting during the read above the cap", index: { writerStartsDuringRead: true }, limit: 5, exceeded: true }, + { name: "count growth across the cap", index: { count: 6, returned: 8 }, limit: 6, exceeded: true }, + { name: "growth while above the cap", index: { count: 7, returned: 8 }, limit: 5, exceeded: true }, + { name: "shrink while above the cap", index: { count: 8, returned: 7 }, limit: 5, exceeded: true } + ]) test(`marks the import read truncated on ${row.name}`, () => { + const { root } = seedWorkspace({ withIndex: true }); + try { + const capabilities = { nodes: [footprintOnly.nodes[0], { ...model.nodes[1], source: { include: ["packages/other/**"] } }], relations: [] }; + const inputs = loadCapabilityCodeGraphProjectionInputs(root, capabilities, { provider: importIndex(root, row.index), importNodeLimit: row.limit }); + const returned = row.index.returned ?? IMPORT_NODES.length; + expect(inputs.importPopulation).toMatchObject({ truncated: true, importNodeLimit: row.limit, importNodeLimitExceeded: row.exceeded }); + expect(inputs.importPopulation!.pairs).toHaveLength(Math.min(returned, row.limit)); + expect(inputs.importGraphs).toHaveLength(2); + for (const graph of inputs.importGraphs) { + expect(graph.truncated).toBe(true); + expect(graph.importNodeLimitExceeded).toBe(row.exceeded ? true : undefined); } + } finally { + rmSync(root, { recursive: true, force: true }); } }); test("refuses an import node limit that is not a positive integer", () => { - const { root, binary } = seedWorkspace({ withIndex: true }); + const { root } = seedWorkspace({ withIndex: true }); try { - expect(() => loadCapabilityCodeGraphProjectionInputs(root, footprintOnly, { binary, importNodeLimit: 0 })) + expect(() => loadCapabilityCodeGraphProjectionInputs(root, footprintOnly, { provider: importIndex(root), importNodeLimit: 0 })) .toThrow("import node limit must be a positive integer, got 0"); } finally { rmSync(root, { recursive: true, force: true }); @@ -610,10 +620,10 @@ describe("capability documentation projection inputs from the code index", () => }); test("returns empty measurements when the workspace carries no index", () => { - const { root, binary } = seedWorkspace({ withIndex: false }); + const { root } = seedWorkspace({ withIndex: false }); try { expect(codeGraphIndexAvailable(root)).toBe(false); - expect(loadCapabilityCodeGraphProjectionInputs(root, model, { binary })).toEqual({ + expect(loadCapabilityCodeGraphProjectionInputs(root, model)).toEqual({ importGraphs: [], selectorEvidence: [] }); @@ -622,28 +632,32 @@ describe("capability documentation projection inputs from the code index", () => } }); - test("never shells out for a model that declares no footprint and no entrypoints", () => { - const { root, log } = seedWorkspace({ withIndex: true }); + test("never opens the index for a model that declares no footprint and no entrypoints", () => { + const { root } = seedWorkspace({ withIndex: true }); + const provider = importIndex(root); try { const inputs = loadCapabilityCodeGraphProjectionInputs( root, { nodes: [{ id: "module.undeclared", kind: "module", name: "Undeclared" }], relations: [] }, - { binary: join(root, "does-not-exist.js") } + { provider } ); expect(inputs).toEqual({ importGraphs: [], selectorEvidence: [] }); - expect(statSync(log).size).toBe(0); + expect(provider.calls.open).toBe(0); } finally { rmSync(root, { recursive: true, force: true }); } }); - test("fails loudly when the index CLI errors instead of returning an empty measurement", () => { + test("fails loudly when the index read errors or the index cannot be opened, instead of returning an empty measurement", () => { const { root } = seedWorkspace({ withIndex: true }); - const broken = join(root, "broken-codegraph.js"); - writeFileSync(broken, "process.stderr.write('index corrupted'); process.exit(2);\n"); try { - expect(() => loadCapabilityCodeGraphProjectionInputs(root, model, { binary: broken })) - .toThrow("CodeGraph CLI failed: index corrupted"); + const failing = importIndex(root, { failure: "index corrupted" }); + expect(() => loadCapabilityCodeGraphProjectionInputs(root, model, { provider: failing })).toThrow("index corrupted"); + // The session is closed even when the read throws. + expect(failing.calls).toMatchObject({ open: 1, close: 1 }); + // A `.codegraph` without a database is no index the session can open. + rmSync(join(root, ".codegraph", "codegraph.db")); + expect(() => loadCapabilityCodeGraphProjectionInputs(root, model, { provider: failing })).toThrow("CodeGraph index could not be read: index-missing"); } finally { rmSync(root, { recursive: true, force: true }); } diff --git a/packages/local-runtime/codegraph-adapter/test/module-import-pairs.test.ts b/packages/local-runtime/codegraph-adapter/test/module-import-pairs.test.ts index 21bde357..8fa32b20 100644 --- a/packages/local-runtime/codegraph-adapter/test/module-import-pairs.test.ts +++ b/packages/local-runtime/codegraph-adapter/test/module-import-pairs.test.ts @@ -1,73 +1,65 @@ import { execFileSync } from "node:child_process"; -import { describe, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, rmdirSync, writeFileSync } from "node:fs"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { delimiter, join } from "node:path"; import { listGitVisibleFiles } from "@archcontext/local-runtime/git-adapter"; -import { CODEGRAPH_IMPORT_NODE_READ_LIMIT, repositoryImportPairs } from "../src/index"; +import { CODEGRAPH_IMPORT_NODE_READ_LIMIT, repositoryImportPairs, type CodeGraphReadProvider } from "../src/index"; +import { fakeReadProvider, type FakeIndexFacts } from "./read-provider-fixture"; +import { indexCodeGraphFixture } from "./real-index"; const WORKTREE_DIGEST = `sha256:${"a".repeat(64)}`; -interface FakeIndexOptions { - /** Extra import nodes beyond the seven named ones, to exceed one fixed-size read. */ +interface FakeImportIndex { + /** Extra import nodes beyond the seven named ones. */ generated?: number; - /** Report `nodesByKind.import` in the status, as CodeGraph 1.6.2 does. */ - reportCount?: boolean; - /** Return this many fewer nodes than the status reports, as an index that changed mid-read. */ + /** Report a `startLine` on the nodes that name one. */ + withLines?: boolean; + /** Return this many fewer nodes than the count reports, as an index that changed mid-read. */ queryShortBy?: number; - /** The `nodesByKind` key the status uses for imports; another name models a renamed kind. */ - countKey?: string; + /** Report this many fewer nodes in the count than the read returns, as an index that grew mid-read. */ + countShortBy?: number; + /** Readiness facts of the injected index (pending changes, a moving revision). */ + facts?: FakeIndexFacts; } /** - * Fake index CLI. It answers the two query shapes this producer issues and fails loudly on - * anything else, so an unexpected invocation surfaces as a failure rather than as an empty answer. + * An injected read-only provider whose handle answers `getStats` and `getNodesByKind("import")`. + * The nodes come back in reverse of their (filePath, name, startLine) order, so a prefix that does + * not sort them is visible. */ -function fakeCli(logPath: string, pending: number, withLines = false, index: FakeIndexOptions = {}): string { - return ` -import { appendFileSync } from "node:fs"; -const argv = process.argv.slice(2); -appendFileSync(${JSON.stringify(logPath)}, JSON.stringify(argv) + "\\n"); -const withLines = ${withLines}; -const importNodes = [ - { name: "./render", filePath: "packages/docs/src/main.ts", startLine: 4 }, - { name: "./render", filePath: "packages/docs/src/main.ts", startLine: 2 }, - { name: "@archcontext/contracts", filePath: "packages/docs/src/main.ts" }, - { name: "node:fs", filePath: "packages/docs/src/main.ts" }, - { name: "./missing-target", filePath: "packages/docs/src/render.ts" }, - { name: "../../shared/util", filePath: "packages/docs/src/render.ts" }, - { name: "./helper", filePath: "packages/other/src/index.ts" } -]; -for (let i = 0; i < ${index.generated ?? 0}; i += 1) { - importNodes.push({ name: "./generated-" + i, filePath: "packages/generated/src/file-" + (i % 100) + ".ts" }); -} -if (argv[0] === "query") { - const limit = Number(argv[argv.indexOf("-l") + 1]); - const available = importNodes.length - ${index.queryShortBy ?? 0}; - process.stdout.write(JSON.stringify(importNodes.slice(0, Math.min(limit, available)).map((node) => ({ - node: { id: "import:" + node.filePath + ":" + node.name, kind: "import", name: node.name, filePath: node.filePath, ...(withLines && node.startLine ? { startLine: node.startLine } : {}) } - })))); -} else if (argv[0] === "status") { - process.stdout.write(JSON.stringify({ - initialized: true, - version: "1.6.2", - projectPath: process.cwd(), - lastIndexed: "2026-09-03T00:00:00Z", - ...(${index.reportCount ?? false} ? { nodesByKind: { file: 5, [${JSON.stringify(index.countKey ?? "import")}]: importNodes.length } } : {}), - pendingChanges: { added: ${pending}, modified: 0, removed: 0 } +function importProvider(root: string, index: FakeImportIndex = {}): CodeGraphReadProvider { + const base = fakeReadProvider(root, index.facts ?? {}); + const named = [ + { name: "./render", filePath: "packages/docs/src/main.ts", startLine: 4 }, + { name: "./render", filePath: "packages/docs/src/main.ts", startLine: 2 }, + { name: "@archcontext/contracts", filePath: "packages/docs/src/main.ts" }, + { name: "node:fs", filePath: "packages/docs/src/main.ts" }, + { name: "./missing-target", filePath: "packages/docs/src/render.ts" }, + { name: "../../shared/util", filePath: "packages/docs/src/render.ts" }, + { name: "./helper", filePath: "packages/other/src/index.ts" } + ]; + const generated = Array.from({ length: index.generated ?? 0 }, (_, i) => ({ name: `./generated-${i}`, filePath: `packages/generated/src/file-${i % 100}.ts` })); + const nodes = [...named, ...generated].map((node) => ({ + id: `import:${node.filePath}:${node.name}`, + kind: "import", + name: node.name, + filePath: node.filePath, + ...(index.withLines && "startLine" in node ? { startLine: node.startLine } : {}) })); -} else { - process.stderr.write("unexpected codegraph invocation: " + JSON.stringify(argv)); - process.exit(1); -} -`; + return { + ...base, + openReadOnly: (openedRoot) => Object.assign(base.openReadOnly(openedRoot), { + getStats: () => ({ nodesByKind: { file: 5, import: nodes.length - (index.countShortBy ?? 0) } }), + getNodesByKind: (kind: string) => kind === "import" ? nodes.slice(0, nodes.length - (index.queryShortBy ?? 0)).reverse() : [] + }) + }; } -function seedWorkspace(options: { withIndex?: boolean; pending?: number; withLines?: boolean } & FakeIndexOptions = {}): { root: string; binary: string; log: string } { +function seedWorkspace(index: FakeImportIndex = {}): { root: string; provider: CodeGraphReadProvider } { const root = mkdtempSync(join(tmpdir(), "archctx-module-import-pairs-")); // Import targets resolve against the Git-visible files, so the fixture is a checkout. execFileSync("git", ["init", "-q"], { cwd: root, stdio: ["ignore", "pipe", "pipe"] }); - if (options.withIndex !== false) mkdirSync(join(root, ".codegraph")); mkdirSync(join(root, "packages/docs/src"), { recursive: true }); mkdirSync(join(root, "packages/shared"), { recursive: true }); mkdirSync(join(root, "packages/other/src"), { recursive: true }); @@ -76,18 +68,18 @@ function seedWorkspace(options: { withIndex?: boolean; pending?: number; withLin writeFileSync(join(root, "packages/shared/util.ts"), "export const util = 1;\n"); writeFileSync(join(root, "packages/other/src/helper.ts"), "export const helper = 1;\n"); writeFileSync(join(root, "packages/other/src/index.ts"), "export const index = 1;\n"); - const log = join(root, "invocations.log"); - writeFileSync(log, ""); - const binary = join(root, "fake-codegraph.js"); - writeFileSync(binary, fakeCli(log, options.pending ?? 0, options.withLines ?? false, options)); - return { root, binary, log }; + return { root, provider: importProvider(root, index) }; +} + +function pairsOf(root: string, provider: CodeGraphReadProvider, limit = 100) { + return repositoryImportPairs(root, limit, WORKTREE_DIGEST, listGitVisibleFiles(root), { provider }); } describe("repository-wide import pairs", () => { test("locates a repeated specifier at its first reported import line, and omits an unreported one", () => { - const { root, binary } = seedWorkspace({ withLines: true }); + const { root, provider } = seedWorkspace({ withLines: true }); try { - const result = repositoryImportPairs(root, binary, 100, WORKTREE_DIGEST, listGitVisibleFiles(root)); + const result = pairsOf(root, provider); expect(result.pairs.find((pair) => pair.specifier === "./render")).toEqual({ from: "packages/docs/src/main.ts", specifier: "./render", @@ -101,9 +93,9 @@ describe("repository-wide import pairs", () => { }); test("keeps every unresolved specifier as its own record with the specifier retained", () => { - const { root, binary, log } = seedWorkspace(); + const { root, provider } = seedWorkspace(); try { - const result = repositoryImportPairs(root, binary, 100, WORKTREE_DIGEST, listGitVisibleFiles(root)); + const result = pairsOf(root, provider); expect(result.truncated).toBe(false); // Bare and dangling specifiers stay as `to: null` because a module snapshot has to report @@ -119,19 +111,15 @@ describe("repository-wide import pairs", () => { { from: "packages/other/src/index.ts", specifier: "./helper", to: "packages/other/src/helper.ts" } ]); expect(result.pairs.filter((pair) => pair.from === "packages/docs/src/main.ts" && pair.to === null)).toHaveLength(2); - - const invocations = readFileSync(log, "utf8").trim().split("\n").map((line) => JSON.parse(line) as string[]); - // The read is sized from the index's own import count, so the status is read first. - expect(invocations.map((argv) => argv[0])).toEqual(["status", "query"]); } finally { rmSync(root, { recursive: true, force: true }); } }); - test("binds the edges to the measured worktree only when the index reports itself clean", () => { + test("binds the edges to the measured worktree only when the index has no pending change", () => { const clean = seedWorkspace(); try { - expect(repositoryImportPairs(clean.root, clean.binary, 100, WORKTREE_DIGEST, listGitVisibleFiles(clean.root))).toMatchObject({ + expect(pairsOf(clean.root, clean.provider)).toMatchObject({ availability: "ready", indexedWorktreeDigest: WORKTREE_DIGEST }); @@ -140,9 +128,9 @@ describe("repository-wide import pairs", () => { } // An index with pending source changes describes some other tree; it attests to nothing. - const stale = seedWorkspace({ pending: 3 }); + const stale = seedWorkspace({ facts: { pending: { added: 3, modified: 0, removed: 0 } } }); try { - expect(repositoryImportPairs(stale.root, stale.binary, 100, WORKTREE_DIGEST, listGitVisibleFiles(stale.root))).toMatchObject({ + expect(pairsOf(stale.root, stale.provider)).toMatchObject({ availability: "unavailable", indexedWorktreeDigest: null }); @@ -151,11 +139,25 @@ describe("repository-wide import pairs", () => { } }); + test("an index that moves during the read attests to nothing and is truncated", () => { + const { root, provider } = seedWorkspace({ facts: { revisionMoves: true } }); + try { + expect(pairsOf(root, provider)).toMatchObject({ + truncated: true, + availability: "unavailable", + indexedWorktreeDigest: null, + importNodeCount: 7 + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + test("no index at all is an explicit unavailable state, not an empty measurement", () => { - const { root, binary } = seedWorkspace(); + const { root, provider } = seedWorkspace(); try { - rmdirSync(join(root, ".codegraph")); - expect(repositoryImportPairs(root, binary, 100, WORKTREE_DIGEST, listGitVisibleFiles(root))).toEqual({ + rmSync(join(root, ".codegraph"), { recursive: true, force: true }); + expect(pairsOf(root, provider)).toEqual({ pairs: [], truncated: true, availability: "unavailable", @@ -169,50 +171,45 @@ describe("repository-wide import pairs", () => { } }); - test("an index larger than the limit is reported as limit exceeded with its count, not as a complete edge set", () => { - for (const reportCount of [true, false]) { - const { root, binary } = seedWorkspace({ reportCount }); - try { - const result = repositoryImportPairs(root, binary, 3, WORKTREE_DIGEST, listGitVisibleFiles(root)); - expect(result.importNodeLimitExceeded).toBe(true); - // The index is complete; only the configured limit cut the read. - expect(result.truncated).toBe(false); - expect(result.importNodeLimit).toBe(3); - // Without a status count the reply only proves "more than the limit". - expect(result.importNodeCount).toBe(reportCount ? 7 : null); - expect(result.pairs).toEqual([ - { from: "packages/docs/src/main.ts", specifier: "./render", to: "packages/docs/src/render.ts" }, - { from: "packages/docs/src/main.ts", specifier: "@archcontext/contracts", to: null } - ]); - } finally { - rmSync(root, { recursive: true, force: true }); - } + test("an index larger than the limit keeps the (filePath, name, startLine) prefix and reports the limit, not truncation", () => { + const { root, provider } = seedWorkspace(); + try { + const result = pairsOf(root, provider, 3); + expect(result.importNodeLimitExceeded).toBe(true); + // The index is complete; only the configured limit cut the read. + expect(result.truncated).toBe(false); + expect(result.importNodeLimit).toBe(3); + expect(result.importNodeCount).toBe(7); + // The index returns the nodes reversed; the prefix is still the first three in sort order. + expect(result.pairs).toEqual([ + { from: "packages/docs/src/main.ts", specifier: "./render", to: "packages/docs/src/render.ts" }, + { from: "packages/docs/src/main.ts", specifier: "@archcontext/contracts", to: null } + ]); + } finally { + rmSync(root, { recursive: true, force: true }); } }); test("a complete index larger than one fixed-size read gives every pair, not a truncated prefix", () => { // 5007 import nodes: more than the former fixed 5000-node read. - const { root, binary, log } = seedWorkspace({ reportCount: true, generated: 5000 }); + const { root, provider } = seedWorkspace({ generated: 5000 }); try { - const result = repositoryImportPairs(root, binary, CODEGRAPH_IMPORT_NODE_READ_LIMIT, WORKTREE_DIGEST, listGitVisibleFiles(root)); + const result = pairsOf(root, provider, CODEGRAPH_IMPORT_NODE_READ_LIMIT); expect(result.truncated).toBe(false); expect(result.importNodeLimitExceeded).toBe(false); expect(result.importNodeCount).toBe(5007); expect(result.pairs).toHaveLength(5006); expect(result.pairs.filter((pair) => pair.specifier.startsWith("./generated-"))).toHaveLength(5000); expect(result.availability).toBe("ready"); - // One query sized from the reported count, plus one node to detect an index that grew. - const query = readFileSync(log, "utf8").trim().split("\n").map((line) => JSON.parse(line) as string[]).find((argv) => argv[0] === "query")!; - expect(query[query.indexOf("-l") + 1]).toBe("5008"); } finally { rmSync(root, { recursive: true, force: true }); } }); test("a read shorter than the reported population is real truncation, not a limit", () => { - const { root, binary } = seedWorkspace({ reportCount: true, queryShortBy: 2 }); + const { root, provider } = seedWorkspace({ queryShortBy: 2 }); try { - const result = repositoryImportPairs(root, binary, 100, WORKTREE_DIGEST, listGitVisibleFiles(root)); + const result = pairsOf(root, provider); expect(result.truncated).toBe(true); expect(result.importNodeLimitExceeded).toBe(false); expect(result.importNodeCount).toBe(7); @@ -222,10 +219,10 @@ describe("repository-wide import pairs", () => { }); test("an index that shrinks below the limit during a limit-exceeded read is also truncated", () => { - // The status reports 7 import nodes, the cap is 3, and the reply has only 2 nodes. - const { root, binary } = seedWorkspace({ reportCount: true, queryShortBy: 5 }); + // The count reports 7 import nodes, the cap is 3, and the read returns only 2 nodes. + const { root, provider } = seedWorkspace({ queryShortBy: 5 }); try { - const result = repositoryImportPairs(root, binary, 3, WORKTREE_DIGEST, listGitVisibleFiles(root)); + const result = pairsOf(root, provider, 3); expect(result.importNodeLimitExceeded).toBe(true); expect(result.truncated).toBe(true); expect(result.importNodeCount).toBe(7); @@ -234,38 +231,116 @@ describe("repository-wide import pairs", () => { } }); - test("a status without an import count falls back to the limit-sized read, not to a zero count", () => { - // A CodeGraph that names the kind differently must not turn every scan into a one-node read. - const { root, binary, log } = seedWorkspace({ reportCount: true, countKey: "imports" }); + // The kept prefix is the first `limit` sorted nodes whatever the count said, and a count that + // disagrees with the read stays truncated. Seven nodes are returned unless the read is short. + for (const row of [ + { name: "grows from below the cap across it", index: { countShortBy: 5 }, limit: 3, count: 2 }, + { name: "grows from the cap across it", index: { countShortBy: 4 }, limit: 3, count: 3 }, + { name: "grows while above the cap", index: { countShortBy: 2 }, limit: 3, count: 5 }, + { name: "shrinks while above the cap", index: { queryShortBy: 2 }, limit: 3, count: 7 } + ]) test(`an index that ${row.name} during the read keeps a capped prefix and is truncated`, () => { + const { root, provider } = seedWorkspace(row.index); try { - const result = repositoryImportPairs(root, binary, 100, WORKTREE_DIGEST, listGitVisibleFiles(root)); - expect(result.truncated).toBe(false); - expect(result.importNodeLimitExceeded).toBe(false); - expect(result.importNodeCount).toBe(7); - expect(result.pairs).toHaveLength(6); - const query = readFileSync(log, "utf8").trim().split("\n").map((line) => JSON.parse(line) as string[]).find((argv) => argv[0] === "query")!; - expect(query[query.indexOf("-l") + 1]).toBe("101"); + const result = pairsOf(root, provider, row.limit); + expect(result).toMatchObject({ truncated: true, importNodeCount: row.count, importNodeLimit: row.limit, importNodeLimitExceeded: true }); + expect(result.pairs).toEqual([ + { from: "packages/docs/src/main.ts", specifier: "./render", to: "packages/docs/src/render.ts" }, + { from: "packages/docs/src/main.ts", specifier: "@archcontext/contracts", to: null } + ]); } finally { rmSync(root, { recursive: true, force: true }); } }); test("rejects a limit that is not a positive integer", () => { - const { root, binary } = seedWorkspace(); + const { root, provider } = seedWorkspace(); try { - expect(() => repositoryImportPairs(root, binary, 0, WORKTREE_DIGEST, listGitVisibleFiles(root))).toThrow("positive integer"); + expect(() => pairsOf(root, provider, 0)).toThrow("positive integer"); } finally { rmSync(root, { recursive: true, force: true }); } }); test("two runs over the same workspace return the same ordered pairs", () => { - const { root, binary } = seedWorkspace(); + const { root, provider } = seedWorkspace(); try { - expect(JSON.stringify(repositoryImportPairs(root, binary, 100, WORKTREE_DIGEST, listGitVisibleFiles(root)))) - .toBe(JSON.stringify(repositoryImportPairs(root, binary, 100, WORKTREE_DIGEST, listGitVisibleFiles(root)))); + expect(JSON.stringify(pairsOf(root, provider))).toBe(JSON.stringify(pairsOf(root, provider))); } finally { rmSync(root, { recursive: true, force: true }); } }); }); + +describe("repository-wide import pairs from a real index", () => { + let root = ""; + beforeAll(() => { + root = realpathSync.native(mkdtempSync(join(tmpdir(), "archctx-module-import-pairs-real-"))); + execFileSync("git", ["init", "-q"], { cwd: root }); + writeFileSync(join(root, ".gitignore"), ".codegraph/\n"); + mkdirSync(join(root, "src/c"), { recursive: true }); + writeFileSync(join(root, "src/a.ts"), 'import { b } from "./b";\nimport { join } from "node:path";\nimport { gone } from "./gone";\nexport const a = b + join("x") + gone;\n'); + writeFileSync(join(root, "src/b.ts"), "export const b = 1;\n"); + writeFileSync(join(root, "src/c/index.ts"), "export const c = 1;\n"); + writeFileSync(join(root, "src/d.ts"), 'import { c } from "./c";\nimport { z } from "@scope/pkg";\nexport const d = c + z;\n'); + execFileSync("git", ["add", "-A"], { cwd: root }); + execFileSync("git", ["-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "-c", `core.hooksPath=${join(root, ".git/no-hooks")}`, "commit", "-qm", "fixture"], { cwd: root }); + indexCodeGraphFixture(root); + }, 120_000); + afterAll(() => { if (root) rmSync(root, { recursive: true, force: true }); }); + + test("gives the pinned pairs, resolved and unresolved, and runs no git status or diff and no CodeGraph command", () => { + const sourceFiles = listGitVisibleFiles(root); + const spy = process.platform === "win32" ? undefined : commandSpy(); + let result: ReturnType; + let commands: string[] = []; + try { + result = repositoryImportPairs(root, CODEGRAPH_IMPORT_NODE_READ_LIMIT, WORKTREE_DIGEST, sourceFiles); + commands = spy?.commands() ?? []; + } finally { spy?.restore(); } + expect(result).toEqual({ + pairs: [ + { from: "src/a.ts", specifier: "./b", to: "src/b.ts", line: 1 }, + { from: "src/a.ts", specifier: "./gone", to: null, line: 3 }, + { from: "src/a.ts", specifier: "node:path", to: null, line: 2 }, + { from: "src/d.ts", specifier: "./c", to: "src/c/index.ts", line: 1 }, + { from: "src/d.ts", specifier: "@scope/pkg", to: null, line: 2 } + ], + truncated: false, + availability: "ready", + indexedWorktreeDigest: WORKTREE_DIGEST, + importNodeCount: 5, + importNodeLimit: CODEGRAPH_IMPORT_NODE_READ_LIMIT, + importNodeLimitExceeded: false + }); + if (spy) { + // The spy saw the provider enumeration, so an absent status, diff or CodeGraph run is a real absence. + expect(commands.some((command) => command.startsWith("git ls-files"))).toBe(true); + expect(commands.filter((command) => /^git (status|diff)\b/.test(command) || command.startsWith("codegraph"))).toEqual([]); + } + }); +}); + +/** + * A `git` first on PATH that logs each command and runs the real one, and a `codegraph` that logs + * and fails, so any CodeGraph command a read starts is both visible and refused. POSIX only. + */ +function commandSpy(): { commands(): string[]; restore(): void } { + const dir = realpathSync.native(mkdtempSync(join(tmpdir(), "archctx-command-spy-"))); + const log = join(dir, "commands.log"); + const real = execFileSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).trim(); + writeFileSync(log, ""); + writeFileSync(join(dir, "git"), `#!/bin/sh\nprintf 'git %s\\n' "$*" >> '${log}'\nexec '${real}' "$@"\n`); + writeFileSync(join(dir, "codegraph"), `#!/bin/sh\nprintf 'codegraph %s\\n' "$*" >> '${log}'\nexit 1\n`); + chmodSync(join(dir, "git"), 0o755); + chmodSync(join(dir, "codegraph"), 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(/^git -C \S+ /, "git ")), + restore: () => { + process.env.PATH = previous; + rmSync(dir, { recursive: true, force: true }); + } + }; +} diff --git a/packages/local-runtime/codegraph-adapter/test/refactor-baseline.test.ts b/packages/local-runtime/codegraph-adapter/test/refactor-baseline.test.ts index 7db2d7b4..90784d54 100644 --- a/packages/local-runtime/codegraph-adapter/test/refactor-baseline.test.ts +++ b/packages/local-runtime/codegraph-adapter/test/refactor-baseline.test.ts @@ -5,9 +5,9 @@ * order and its refusal classes, the outbound-only footprint filter in `capabilityImportGraphs`, * and the fact that one global `truncated` flag is copied into every capability graph. None of * them is exported, so all three are frozen through the two public entrypoints that reach them — - * `loadCapabilityCodeGraphProjectionInputs` and `CodeGraphCliProvider.buildContext` — the first - * through the written-to-disk fake CLI pattern this package already uses, the second through an - * injected read-only provider answering the same nodes (#574 S5). No `export` was added. + * `loadCapabilityCodeGraphProjectionInputs` and `CodeGraphCliProvider.buildContext`, each through + * an injected read-only provider answering the same import nodes (#574 S2, S5). No `export` was + * added. * * Machine-dependent values are dropped by an explicit allow-list, never by a wildcard: the * handshake fixture freezes the field list and the identity values and reduces every digest to @@ -142,15 +142,28 @@ function seedWorkspace(): { root: string; binary: string } { return { root, binary }; } +/** The read-only index of the import graphs (ADR-0058): `getStats` and `getNodesByKind` over `importNodes`. */ +function importIndex(root: string): CodeGraphReadProvider { + const base = fakeReadProvider(root); + const nodes = importNodes.map((node) => ({ id: `import:${node.filePath}:${node.name}`, kind: "import", ...node })); + return { + ...base, + openReadOnly: (openedRoot) => Object.assign(base.openReadOnly(openedRoot), { + getStats: () => ({ nodesByKind: { import: nodes.length } }), + getNodesByKind: (kind: string) => kind === "import" ? nodes : [] + }) + }; +} + const graphFixtures = loadFixtures<{ importNodeLimit?: number }>("capability-import-graphs"); describe("RF0 baseline: capability import graphs", () => { for (const fixture of graphFixtures) { test(`${fixture.id} — ${fixture.description}`, () => { - const { root, binary } = seedWorkspace(); + const { root } = seedWorkspace(); try { const inputs = loadCapabilityCodeGraphProjectionInputs(root, model, { - binary, + provider: importIndex(root), ...(fixture.input.importNodeLimit === undefined ? {} : { importNodeLimit: fixture.input.importNodeLimit }) }); expectFrozen({ importGraphs: inputs.importGraphs, selectorEvidence: inputs.selectorEvidence } as unknown as Json, fixture); diff --git a/packages/local-runtime/runtime-daemon/src/architecture-debt-code-facts.ts b/packages/local-runtime/runtime-daemon/src/architecture-debt-code-facts.ts index e7f6619f..0dac82aa 100644 --- a/packages/local-runtime/runtime-daemon/src/architecture-debt-code-facts.ts +++ b/packages/local-runtime/runtime-daemon/src/architecture-debt-code-facts.ts @@ -2,15 +2,14 @@ import { computeWorktreeDigest } from "@archcontext/core/architecture-domain"; import { checkArchitectureFlowSelectors, loadCapabilitySourceFootprints, type NativeModel } from "@archcontext/core/projection-engine"; import type { ArchitectureDebtEvidence } from "@archcontext/core/review-engine"; import { - inspectRepositoryCodeFacts, loadCapabilityCodeGraphProjectionInputs, - type CodeGraphReadProvider, type RepositoryCodeFactsV1 + inspectRepositoryCodeFacts, loadCapabilityCodeGraphProjectionInputs, type RepositoryCodeFactsV1 } from "@archcontext/local-runtime/codegraph-adapter"; import { listGitVisibleFiles } from "@archcontext/local-runtime/git-adapter"; import { projectionArchitectureDebt } from "./projection-architecture-debt"; type CodeFactsDebtEvidence = Pick; type ReadOptions = Pick[2]>, - "binary" | "selectorIndexFactory" | "importNodeLimit"> & { provider?: CodeGraphReadProvider }; + "provider" | "selectorIndexFactory" | "importNodeLimit">; /** * Full dependency/flow evidence for the completion consumer. Deliberately separate from the @@ -18,7 +17,7 @@ type ReadOptions = Pick; try { inputs = loadCapabilityCodeGraphProjectionInputs(root, model, { - ...readOptions, readOnly: true, sourceFiles, + ...options, readOnly: true, sourceFiles, footprints: loadCapabilitySourceFootprints(root, model, sourceFiles) }); } catch { diff --git a/packages/local-runtime/runtime-daemon/src/capability-dependencies.ts b/packages/local-runtime/runtime-daemon/src/capability-dependencies.ts index c48a6806..1f14a0e9 100644 --- a/packages/local-runtime/runtime-daemon/src/capability-dependencies.ts +++ b/packages/local-runtime/runtime-daemon/src/capability-dependencies.ts @@ -8,9 +8,6 @@ import { CODEGRAPH_IMPORT_NODE_READ_LIMIT, repositoryImportPairs } from "@archco import { readHeadSha, readTrackedTreeEntries, listGitVisibleFiles } from "@archcontext/local-runtime/git-adapter"; import { declaredWorkspacePackages } from "./refactor-scan"; -/** The CodeGraph CLI name the adapter resolves package-locally when PATH has no answer. */ -const CODEGRAPH_BINARY = "codegraph"; - export interface CapabilityDependenciesRequestV1 { from?: string; into?: string; @@ -128,7 +125,7 @@ function loadModel(root: string): NativeModel { /** A failed index read is the `unavailable` answer, never a guessed edge set. */ function readImports(root: string, limit: number, worktreeDigest: string, sourceFiles: readonly string[]): ReturnType { try { - return repositoryImportPairs(root, CODEGRAPH_BINARY, limit, worktreeDigest, sourceFiles); + return repositoryImportPairs(root, limit, worktreeDigest, sourceFiles); } catch { return { pairs: [], diff --git a/packages/local-runtime/runtime-daemon/src/refactor-scan.ts b/packages/local-runtime/runtime-daemon/src/refactor-scan.ts index 13046ab4..0cfdb9c3 100644 --- a/packages/local-runtime/runtime-daemon/src/refactor-scan.ts +++ b/packages/local-runtime/runtime-daemon/src/refactor-scan.ts @@ -1,6 +1,5 @@ import { FailureError, knownFailure, unclassifiedFailure } from "@archcontext/contracts"; import { execFileSync } from "node:child_process"; -import { createHash } from "node:crypto"; import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { @@ -35,8 +34,9 @@ import type { RecommendationSuppression, RefactorCandidateBudget } from "@archco import { assessRefactor } from "@archcontext/core/refactor-assessment"; import { CODEGRAPH_IMPORT_NODE_READ_LIMIT, - codeGraphCliInvocation, codeGraphIndexAvailable, + packagedCodeGraphRuntimeDigest, + pinnedCodeGraphProvider, repositoryImportPairs } from "@archcontext/local-runtime/codegraph-adapter"; import { normalizePackageExports, readHeadCommitterDate, readTrackedSourceFiles, readTrackedTreeEntries, readWorkspacePackages } from "@archcontext/local-runtime/git-adapter"; @@ -44,10 +44,6 @@ import { listModelFiles } from "@archcontext/local-runtime/model-store-yaml"; import type { RefactorDecisionSetV1 } from "./refactor-decisions"; import { planRefactorRunWithRecording, previousRecommendationsFromAuthority, type RefactorRunRecordingV1 } from "./refactor-recording"; -/** The CodeGraph CLI name the adapter resolves package-locally when PATH has no answer. */ -const CODEGRAPH_BINARY = "codegraph"; -const CODEGRAPH_VERSION_TIMEOUT_MS = 15_000; - /** The default request: measure the whole repository and classify nothing beyond it. */ export const REPOSITORY_REFACTOR_REQUEST: RefactorRequestV1 = { schemaVersion: REFACTOR_REQUEST_SCHEMA_VERSION, @@ -298,7 +294,7 @@ export function evaluateWorktreeDependencyConstraints(input: { const edgeLimit = input.edgeLimit ?? CODEGRAPH_IMPORT_NODE_READ_LIMIT; let imports: ReturnType; try { - imports = repositoryImportPairs(input.root, CODEGRAPH_BINARY, edgeLimit, input.worktreeDigest, input.sourceFiles); + imports = repositoryImportPairs(input.root, edgeLimit, input.worktreeDigest, input.sourceFiles); } catch { imports = { pairs: [], @@ -508,8 +504,8 @@ interface RefactorScanCodeFactsV1 { */ function readCodeFacts(root: string, worktreeDigest: string, sourceFiles: readonly string[], edgeLimit: number): RefactorScanCodeFactsV1 { const indexed = codeGraphIndexAvailable(root); - const imports = repositoryImportPairs(root, CODEGRAPH_BINARY, edgeLimit, worktreeDigest, sourceFiles); - const attestation = indexed ? attestCodeGraphBinary(root) : { version: "", binaryDigest: "" }; + const imports = repositoryImportPairs(root, edgeLimit, worktreeDigest, sourceFiles); + const attestation = indexed ? attestCodeGraphRuntime() : { version: "", binaryDigest: "" }; return { pairs: imports.pairs, truncated: imports.truncated, @@ -521,18 +517,11 @@ function readCodeFacts(root: string, worktreeDigest: string, sourceFiles: readon }; } -/** The actual binary that answered, not the pinned constant: a pin is a requirement, not evidence. */ -function attestCodeGraphBinary(root: string): { version: string; binaryDigest: string } { - const invocation = codeGraphCliInvocation(CODEGRAPH_BINARY, root); - const version = execFileSync(invocation.command, [...invocation.argsPrefix, "--version"], { - cwd: root, - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - timeout: CODEGRAPH_VERSION_TIMEOUT_MS - }).trim(); - const binaryPath = invocation.argsPrefix[0] ?? invocation.command; - return { - version, - binaryDigest: `sha256:${createHash("sha256").update(readFileSync(binaryPath)).digest("hex")}` - }; +/** + * The provider that answered, not the pinned constant: a pin is a requirement, not evidence. The + * version is the manifest of the platform bundle this process loaded and read the index with + * (ADR-0058); the digest covers the packaged runtime, the same one the projection handshake attests. + */ +function attestCodeGraphRuntime(): { version: string; binaryDigest: string } { + return { version: pinnedCodeGraphProvider().version, binaryDigest: packagedCodeGraphRuntimeDigest() }; } diff --git a/packages/local-runtime/runtime-daemon/test/architecture-debt-code-facts.test.ts b/packages/local-runtime/runtime-daemon/test/architecture-debt-code-facts.test.ts index a00eac09..204cb15a 100644 --- a/packages/local-runtime/runtime-daemon/test/architecture-debt-code-facts.test.ts +++ b/packages/local-runtime/runtime-daemon/test/architecture-debt-code-facts.test.ts @@ -7,7 +7,7 @@ import { join } from "node:path"; import { architectureDebtReportIssues } from "@archcontext/contracts"; import type { NativeModel } from "@archcontext/core/projection-engine"; import { evaluateArchitectureDebt } from "@archcontext/core/review-engine"; -import { loadCapabilityCodeGraphProjectionInputs, REQUIRED_CODEGRAPH_VERSION, type CodeGraphSelectorIndex } from "@archcontext/local-runtime/codegraph-adapter"; +import { loadCapabilityCodeGraphProjectionInputs, REQUIRED_CODEGRAPH_VERSION, type CodeGraphReadProvider, type CodeGraphSelectorIndex } from "@archcontext/local-runtime/codegraph-adapter"; import { readArchitectureDebtCodeFacts } from "../src/architecture-debt-code-facts"; import { fakeReadProvider } from "../../codegraph-adapter/test/read-provider-fixture"; @@ -35,28 +35,26 @@ function fixture(options: { missing?: boolean; stale?: boolean; queryFails?: boo mkdirSync(join(root, "src")); writeFileSync(join(root, "src/a.ts"), 'import { sink } from "./b";\nexport function entry() { return sink(); }\n'); writeFileSync(join(root, "src/b.ts"), "export function sink() { return 1; }\n"); - const log = join(base, "calls.log"); - writeFileSync(log, ""); const stale = join(base, "stale"); if (options.stale) writeFileSync(stale, "stale"); - // Readiness reads the index through the read-only provider (ADR-0058); `stale` is its change test. - const provider = fakeReadProvider(root, () => ({ pending: { added: 0, modified: existsSync(stale) ? 1 : 0, removed: 0 } })); + // Readiness and the import population both read the index through the read-only provider + // (ADR-0058); `stale` is its change test. Nothing starts CodeGraph. + const readiness = fakeReadProvider(root, () => ({ pending: { added: 0, modified: existsSync(stale) ? 1 : 0, removed: 0 } })); + const importReads = { count: 0 }; + const provider: CodeGraphReadProvider = { + ...readiness, + openReadOnly: (openedRoot) => Object.assign(readiness.openReadOnly(openedRoot), { + getStats: () => ({ nodesByKind: { import: 1 } }), + getNodesByKind: (kind: string) => { + importReads.count++; + if (options.queryFails) throw new Error("private raw producer error"); + if (options.queryMakesStale) writeFileSync(stale, "stale"); + return kind === "import" ? [{ id: "import:a", kind: "import", name: "./b", filePath: "src/a.ts" }] : []; + } + }) + }; if (options.missing) rmSync(join(root, ".codegraph"), { recursive: true, force: true }); - // The import population is still a CLI query until S2 of #574; nothing else starts CodeGraph. - const binary = join(base, "codegraph.cjs"); - writeFileSync(binary, ` -const { appendFileSync, writeFileSync } = require("node:fs"); -const args = process.argv.slice(2); -appendFileSync(${JSON.stringify(log)}, args[0] + "=" + process.env.CODEGRAPH_NO_DOWNLOAD + "\\n"); -if (process.env.CODEGRAPH_NO_DOWNLOAD !== "1") throw new Error("download guard missing"); -if (args[0] === "query") { - if (${Boolean(options.queryFails)}) throw new Error("private raw producer error"); - if (${Boolean(options.queryMakesStale)}) writeFileSync(${JSON.stringify(stale)}, "stale"); - console.log(JSON.stringify([{ node: { id: "import:a", kind: "import", name: "./b", filePath: "src/a.ts" } }])); process.exit(0); -} -throw new Error("unexpected command " + args[0]); -`); - return { root, binary, provider, calls: () => readFileSync(log, "utf8") }; + return { root, provider, opens: readiness.calls, importReads: () => importReads.count }; } function selectorIndex(): CodeGraphSelectorIndex { @@ -73,14 +71,12 @@ function selectorIndex(): CodeGraphSelectorIndex { }; } -test("full debt reads S2 readiness, guards every shim spawn and closes the readonly selector", () => { - const { root, binary, provider, calls } = fixture(); +test("full debt reads readiness and imports read-only, starts no process and closes the readonly selector", () => { + const { root, provider, opens, importReads } = fixture(); const index = selectorIndex(); const close = spyOn(index, "close"); - const old = process.env.CODEGRAPH_NO_DOWNLOAD; - process.env.CODEGRAPH_NO_DOWNLOAD = "0"; try { - const evidence = readArchitectureDebtCodeFacts(root, model, { binary, provider, selectorIndexFactory: (path, options) => { + const evidence = readArchitectureDebtCodeFacts(root, model, { provider, selectorIndexFactory: (path, options) => { expect(path).toBe(root); expect(options).toEqual({ readOnly: true }); return index; @@ -90,24 +86,22 @@ test("full debt reads S2 readiness, guards every shim spawn and closes the reado ] }); expect(evidence.flows?.selectors[0]?.status).toBe("proven"); expect(close).toHaveBeenCalledTimes(1); - // Readiness opened the index read-only twice, around the reads, and started no process. - expect(provider.calls).toMatchObject({ open: 2, close: 2 }); - expect(calls()).toBe("query=1\n"); - expect(process.env.CODEGRAPH_NO_DOWNLOAD).toBe("0"); + // Readiness opened the index read-only twice, around the reads, and the import read once. + expect(opens).toMatchObject({ open: 3, close: 3 }); + expect(importReads()).toBe(1); } finally { close.mockRestore(); - if (old === undefined) delete process.env.CODEGRAPH_NO_DOWNLOAD; else process.env.CODEGRAPH_NO_DOWNLOAD = old; } }); -for (const [name, options, reason, expectedCalls] of [ - ["missing index", { missing: true }, "index-missing", ""], - ["stale index", { stale: true }, "pending-source-changes", ""], - ["query refusal", { queryFails: true }, "codegraph-read-failed", "query=1\n"], - ["index becomes stale during query", { queryMakesStale: true }, "pending-source-changes", "query=1\n"] +for (const [name, options, reason, expectedImportReads] of [ + ["missing index", { missing: true }, "index-missing", 0], + ["stale index", { stale: true }, "pending-source-changes", 0], + ["query refusal", { queryFails: true }, "codegraph-read-failed", 1], + ["index becomes stale during query", { queryMakesStale: true }, "pending-source-changes", 1] ] as const) test(`${name} is unknown coverage without invented dependency/flow debt`, () => { - const { root, binary, provider, calls } = fixture(options); - const evidence = readArchitectureDebtCodeFacts(root, model, { binary, provider, selectorIndexFactory: () => { + const { root, provider, importReads } = fixture(options); + const evidence = readArchitectureDebtCodeFacts(root, model, { provider, selectorIndexFactory: () => { if (options.missing || options.stale || options.queryFails) throw new Error("selector must not open"); return selectorIndex(); } }); @@ -116,13 +110,13 @@ for (const [name, options, reason, expectedCalls] of [ expect(report.coverage).toBe("unknown"); expect(report.items).toEqual([]); expect(architectureDebtReportIssues(report)).toEqual([]); - expect(calls()).toBe(expectedCalls); + expect(importReads()).toBe(expectedImportReads); }); test("read-only selector refusal is reported without a writable retry", () => { - const { root, binary, provider } = fixture(); + const { root, provider } = fixture(); let opens = 0; - const result = readArchitectureDebtCodeFacts(root, model, { binary, provider, selectorIndexFactory: (_path, options) => { + const result = readArchitectureDebtCodeFacts(root, model, { provider, selectorIndexFactory: (_path, options) => { opens++; expect(options.readOnly).toBe(true); throw new Error("readonly open refused"); @@ -132,11 +126,11 @@ test("read-only selector refusal is reported without a writable retry", () => { }); test("source edits during selector reads invalidate otherwise ready facts and still close the index", () => { - const { root, binary, provider } = fixture(); + const { root, provider } = fixture(); const index = selectorIndex(); const close = spyOn(index, "close"); try { - const result = readArchitectureDebtCodeFacts(root, model, { binary, provider, selectorIndexFactory: () => { + const result = readArchitectureDebtCodeFacts(root, model, { provider, selectorIndexFactory: () => { writeFileSync(join(root, "src/b.ts"), "export function sink() { return 2; }\n"); return index; } }); diff --git a/packages/surfaces/cli/src/main.ts b/packages/surfaces/cli/src/main.ts index 36b4719c..c2aa56bf 100755 --- a/packages/surfaces/cli/src/main.ts +++ b/packages/surfaces/cli/src/main.ts @@ -4011,7 +4011,7 @@ const CLI_DEPS_HELP = { description: "Read-only capability x capability import matrix. The command reads the model files, the HEAD tree and the CodeGraph index. It writes nothing and needs no daemon.", rule: "Each file gets its owners from the parent-chain rule refactor scan uses. Each owner rolls up to the nearest kind: capability node on its parent chain. An edge counts in data.matrix only when both files roll up to exactly one capability. Other edges count in data.imports.unattributed by cause.", filters: "--from keeps edges whose importing file the node or a descendant owns. --into keeps edges whose imported file the node or a descendant owns. An unknown node id is AC_SCHEMA_INVALID.", - imports: "data.imports reports coverage, reasonCodes, truncated, importNodeCount, importNodeLimit and importNodeLimitExceeded from the CodeGraph import read. A missing or stale index gives coverage unknown and an empty matrix. --import-node-limit raises the read cap (default 50000).", + imports: "data.imports reports coverage, reasonCodes, truncated, importNodeCount, importNodeLimit and importNodeLimitExceeded from the CodeGraph import read. A missing or stale index gives coverage unknown and an empty matrix. --import-node-limit sets the read cap (default 64000).", calls: "Call counts are not available in v1. data.calls.coverage is unknown with reason call-edges-not-exposed, and every cell has calls: null.", edges: "data.edges lists the attributed file-level edges behind the matrix, sorted by path, up to --edge-limit (default 200). data.edges.count is the full total." }; diff --git a/packages/surfaces/cli/test/constraints.test.ts b/packages/surfaces/cli/test/constraints.test.ts index e02f4053..7e01374b 100644 --- a/packages/surfaces/cli/test/constraints.test.ts +++ b/packages/surfaces/cli/test/constraints.test.ts @@ -8,7 +8,8 @@ import { stableYaml } from "@archcontext/contracts"; import { codeGraphCliInvocation } from "@archcontext/local-runtime/codegraph-adapter"; import { initializeArchContextModel } from "@archcontext/local-runtime/model-store-yaml"; -const cli = resolve(import.meta.dir, "../src/main.ts"); +// The development entry: under Bun on darwin it loads the SQLite that opens the index read-only (ADR-0058). +const cli = resolve(import.meta.dir, "../bin/archctx"); const root = mkdtempSync(join(tmpdir(), "archctx-constraints-cli-")); const state = mkdtempSync(join(tmpdir(), "archctx-constraints-state-")); const constraintPath = ".archcontext/model/constraints/constraint.layers.yaml"; diff --git a/packages/surfaces/cli/test/deps.test.ts b/packages/surfaces/cli/test/deps.test.ts index 2d01f17f..dd234361 100644 --- a/packages/surfaces/cli/test/deps.test.ts +++ b/packages/surfaces/cli/test/deps.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { execFileSync } from "node:child_process"; -import { type RmDirOptions, mkdirSync, mkdtempSync, readdirSync, rmSync as nodeRmSync, writeFileSync } from "node:fs"; +import { type RmDirOptions, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync as nodeRmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { stableYaml, type CapabilityDependenciesV1, type JsonEnvelope } from "@archcontext/contracts"; @@ -183,6 +183,23 @@ describe("archctx deps (#301)", () => { } }); + test("refuses with AC_CODE_FACTS_UNAVAILABLE when the index lags the worktree, instead of a stale matrix", async () => { + const edited = join(root, "src/billing/invoice.ts"); + const original = readFileSync(edited); + try { + writeFileSync(edited, "export const invoice = 2;\n", "utf8"); + const refused = await runCli("deps", ["--json"], root); + expect(refused.ok).toBe(false); + expect((refused as { error: { code: string; reasonCode?: string } }).error).toMatchObject({ + code: "AC_CODE_FACTS_UNAVAILABLE", + reasonCode: "code-facts-unavailable" + }); + } finally { + writeFileSync(edited, original); + } + expect((await runCli("deps", ["--json"], root)).ok).toBe(true); + }, 120_000); + test("is read-only: no runtime state is created", async () => { await runCli("deps", ["--json"], root); expect(readdirSync(stateRoot)).toEqual([]); diff --git a/scripts/architecture-projection-axr3-semantic-pilot-readback.ts b/scripts/architecture-projection-axr3-semantic-pilot-readback.ts index 7a50e2e2..4aa81498 100644 --- a/scripts/architecture-projection-axr3-semantic-pilot-readback.ts +++ b/scripts/architecture-projection-axr3-semantic-pilot-readback.ts @@ -32,10 +32,7 @@ if (!actualRevision.startsWith(declared.revision)) { } const model = loadNativeModelFromArchContext(fixtureRoot); -const measured = loadCapabilityCodeGraphProjectionInputs(repoHarnessRoot, model, { - binary: args.codegraph ?? "codegraph", - importNodeLimit: 1 -}).selectorEvidence; +const measured = loadCapabilityCodeGraphProjectionInputs(repoHarnessRoot, model, { importNodeLimit: 1 }).selectorEvidence; if (JSON.stringify(sortEvidence(measured)) !== JSON.stringify(sortEvidence(declared.evidence))) { throw new Error("pinned semantic-pilot evidence does not match the current exact CodeGraph readback"); }