diff --git a/packages/local-runtime/codegraph-adapter/src/cli-provider.ts b/packages/local-runtime/codegraph-adapter/src/cli-provider.ts index 2cf7ae8a..bf674cbc 100644 --- a/packages/local-runtime/codegraph-adapter/src/cli-provider.ts +++ b/packages/local-runtime/codegraph-adapter/src/cli-provider.ts @@ -3,11 +3,12 @@ import { execFileSync } from "node:child_process"; import { accessSync, closeSync, constants as fsConstants, existsSync, openSync, readFileSync, readSync, realpathSync, statSync } from "node:fs"; import { createHash } from "node:crypto"; import { createRequire } from "node:module"; -import { basename, delimiter, dirname, isAbsolute, join, resolve } from "node:path"; +import { delimiter, dirname, isAbsolute, join, resolve } from "node:path"; +import type { Edge as CodeGraphEdge, Node as CodeGraphNode, NodeKind as CodeGraphNodeKind } from "@colbymchenry/codegraph"; import { codeGraphChildEnv } from "./child-env"; -import { REQUIRED_CODEGRAPH_PACKAGE, REQUIRED_CODEGRAPH_VERSION, type CodeGraphReadProvider } from "./read-session"; +import { REQUIRED_CODEGRAPH_PACKAGE, REQUIRED_CODEGRAPH_VERSION, openCodeGraphReadSession, type CodeGraphIndexHandle, type CodeGraphReadProvider, type CodeGraphReadSession, type CodeGraphReadSessionOpenV1 } from "./read-session"; import { listGitVisibleFiles } from "@archcontext/local-runtime/git-adapter"; -import { digestJson, type Json, type NormalizedCodeContext, type NormalizedEdge, type NormalizedImpact, type NormalizedSymbol, type SymbolQuery } from "@archcontext/contracts"; +import { CODE_FACTS_ISSUE_MESSAGES, FailureError, digestJson, type Json, type NormalizedCodeContext, type NormalizedEdge, type NormalizedImpact, type NormalizedSymbol, type SymbolQuery } from "@archcontext/contracts"; import { importEdgesFromQueryNodes, importQuery, uniqueStrings } from "./import-population"; import { assertRepositoryCodeFactsReady } from "./repository-code-facts"; @@ -38,23 +39,46 @@ export interface CodeGraphProvider { getImpactRadius(symbolId: string, depth: number): Promise; } +/** + * Builds the index with the CodeGraph CLI. `sync` is the only command it starts (ADR-0058 Decision + * 2), under `codeGraphChildEnv`; no read goes through it. + */ +class CodeGraphCliIndexBuilder { + constructor(private readonly binary: string) {} + + sync(workspaceRoot: string): void { + const invocation = this.binary === DEFAULT_CODEGRAPH_BINARY ? packagedCodeGraphCliInvocation() : codeGraphCliInvocation(this.binary, workspaceRoot); + runCodeGraphInvocation(invocation, workspaceRoot, ["sync", workspaceRoot], CODEGRAPH_OUTPUT_MAX_BYTES); + } +} + +/** Edge kinds that make a caller or a callee: a call, or a construction, which calls the constructor. */ +const CALL_EDGE_KINDS: CodeGraphEdge["kind"][] = ["calls", "instantiates"]; + +/** + * The CodeGraph provider: a CLI builder for the index and SDK reads through one read-only session + * per read (ADR-0058). A read starts no CodeGraph command and never opens the index writable. + */ export class CodeGraphCliProvider implements CodeGraphProvider { version: string = REQUIRED_CODEGRAPH_VERSION; capabilities = ["index", "context", "impact"]; private workspaceRoot: string; + private readonly builder: CodeGraphCliIndexBuilder; /** * `binary` builds the index (`sync`); reads never start it. `readProvider` is the read-only - * provider readiness reads through, the pinned one unless a test injects one. + * provider every read and readiness check opens the index through, the pinned one unless a test + * injects one. */ - constructor(workspaceRoot = process.cwd(), private readonly binary = DEFAULT_CODEGRAPH_BINARY, private readonly readProvider?: CodeGraphReadProvider) { + constructor(workspaceRoot = process.cwd(), binary = DEFAULT_CODEGRAPH_BINARY, private readonly readProvider?: CodeGraphReadProvider) { this.workspaceRoot = workspaceRoot; + this.builder = new CodeGraphCliIndexBuilder(binary); } async indexAll(workspaceRoot: string): Promise { this.workspaceRoot = workspaceRoot; this.assertCanSync(workspaceRoot); - this.run(["sync", workspaceRoot]); + this.builder.sync(workspaceRoot); this.assertReady(workspaceRoot); } @@ -66,85 +90,127 @@ export class CodeGraphCliProvider implements CodeGraphProvider { this.assertReady(workspaceRoot); } + /** + * C6 (#574): the file set comes from `findRelevantContext(query)`, the query nodes from + * `searchNodes`, both read in one session. Symbols are the query nodes, then the file nodes of the + * relevant files in relevance order, capped at `maxSymbols`. + */ async buildContext(task: string, options: { maxSymbols: number; includeSource: boolean; changedPaths?: string[] }): Promise { - const query = scopedTaskQuery(task, options.changedPaths ?? []); - const output = this.run(["explore", "-p", this.workspaceRoot, "--max-files", String(options.maxSymbols), query]); - const queryNodes = this.queryNodes(query, Math.max(options.maxSymbols * 4, 12)); - const importNodes = this.importNodesForChangedPaths(options.changedPaths ?? []); + const changedPaths = options.changedPaths ?? []; + const query = scopedTaskQuery(task, changedPaths); + const scopedImportQuery = importQuery(changedPaths); + const read = await this.read(async (graph) => { + const relevantFiles = relevantFilesOf(await graph.findRelevantContext(query)).slice(0, options.maxSymbols); + return { + relevantFiles, + fileNodes: fileNodesOf(graph, relevantFiles), + queryNodes: rankedSearch(graph, query, Math.max(options.maxSymbols * 4, 12)), + importNodes: scopedImportQuery ? rankedSearch(graph, scopedImportQuery, Math.max(changedPaths.length * 8, 12), ["import"]) : [] + }; + }); const symbols = uniqueSymbols([ - ...queryNodes.filter((node) => node.kind !== "import").map(normalizeCliNode), - ...parseExploreSymbols(output, options.maxSymbols) - ]).slice(0, options.maxSymbols); - const fallback = symbols.length > 0 ? symbols : await this.findSymbols({ query, limit: options.maxSymbols }); - const edges = uniqueEdges(importEdgesFromQueryNodes(new Set(listGitVisibleFiles(this.workspaceRoot)), importNodes.length > 0 ? importNodes : queryNodes)); + ...read.queryNodes.filter((node) => node.kind !== "import"), + ...read.fileNodes + ].map(normalizeNode)).slice(0, options.maxSymbols); + const edges = uniqueEdges(importEdgesFromQueryNodes(new Set(listGitVisibleFiles(this.workspaceRoot)), read.importNodes.length > 0 ? read.importNodes : read.queryNodes)); return { task, - symbols: fallback, + symbols, edges, - evidence: fallback.map((symbol, index) => ({ + evidence: symbols.map((symbol, index) => ({ id: `evidence_${index + 1}`, selector: { path: symbol.path, symbolId: symbol.id, startLine: symbol.range?.startLine, endLine: symbol.range?.endLine }, - summary: `Observed by codegraph explore for task: ${task}`, + summary: `Observed by CodeGraph for task: ${task}`, confidence: "observed", snapshot: { repositoryId: "codegraph", headSha: "codegraph", - worktreeDigest: digestJson({ task, path: symbol.path, output } as unknown as Json) + worktreeDigest: digestJson({ task, path: symbol.path, relevantFiles: read.relevantFiles } as unknown as Json) } })), - digest: digestJson({ task, symbols: fallback, edges, includeSource: options.includeSource, outputDigest: digestJson(output as unknown as Json) } as unknown as Json) + digest: digestJson({ task, symbols, edges, includeSource: options.includeSource, relevantFiles: read.relevantFiles } as unknown as Json) }; } + /** `searchNodes`, restricted to `query.kinds` when given, ranked deterministically. */ async findSymbols(query: SymbolQuery): Promise { - return this.queryNodes(query.query, query.limit ?? 10, query.kinds).map(normalizeCliNode); + const nodes = await this.read((graph) => rankedSearch(graph, query.query, query.limit ?? 10, query.kinds)); + return nodes.map(normalizeNode); } - private queryNodes(query: string, limit: number, kinds: string[] = []): CodeGraphCliNode[] { - const args = ["query", "-p", this.workspaceRoot, "-j", "-l", String(limit)]; - for (const kind of kinds) args.push("-k", kind); - args.push(query); - const output = this.run(args); - const parsed = JSON.parse(output) as { node: CodeGraphCliNode }[]; - return parsed.map(({ node }) => node); + /** + * `symbolId` names a node id, else exact qualified names or names; every definition it names takes + * part, so an ambiguous name gives the union over its definitions and an unknown one an empty + * result. `affectedPaths` are the files of CodeGraph's impact radius at `depth`; callers and + * callees are the direct `calls` and `instantiates` edges of the definitions. A type reference is + * no call, so it reaches only `affectedPaths` (#574 design amendment). + */ + async getImpactRadius(symbolId: string, depth: number): Promise { + return this.read((graph) => impactOf(graph, symbolId, depth)); } - private importNodesForChangedPaths(changedPaths: string[]): CodeGraphCliNode[] { - const query = importQuery(changedPaths); - if (!query) return []; - return this.queryNodes(query, Math.max(changedPaths.length * 8, 12), ["import"]); + /** + * Runs `read` against one read-only session that is closed when it settles. Every failure leaves + * as a `FailureError` without provider text, and the read is never retried, never with a writable + * open: + * - the pinned provider cannot load: `code-facts-unavailable`, as the readiness gate maps it; + * - the session does not open: its state's refusal; + * - the index moved during the read, whether the read then threw or not: retryable not-ready, + * because facts read across a build or a sync are not one index state (design G4); + * - any other throw inside the read, including a write the read-only handle refuses: + * `index-open-failed`. + * A failing `close()` never replaces the read's own error; after a good read it is `index-open-failed`. + */ + private async read(read: (graph: CodeGraphIndexHandle) => T | Promise): Promise { + let opened: CodeGraphReadSessionOpenV1; + try { + opened = openCodeGraphReadSession(this.workspaceRoot, this.readProvider ? { provider: this.readProvider } : {}); + } catch { + throw codeGraphUnavailable(); + } + if (opened.state !== "open") throw readRefusal(opened.state); + const { session } = opened; + let outcome: { value: T } | { error: FailureError }; + try { + const value = await read(session.graph); + outcome = session.unchangedSinceOpen() ? { value } : { error: readRefusal("build-in-progress") }; + } catch { + outcome = { error: readRefusal(indexMoved(session) ? "build-in-progress" : "index-open-failed") }; + } + try { + session.close(); + } catch { + if ("value" in outcome) outcome = { error: readRefusal("index-open-failed") }; + } + if ("error" in outcome) throw outcome.error; + return outcome.value; } +} - async getImpactRadius(symbolId: string, depth: number): Promise { - const output = this.run(["impact", "-p", this.workspaceRoot, "-j", "-d", String(depth), symbolId]); - const parsed = JSON.parse(output) as { affected?: CodeGraphCliNode[] }; - const affected = (parsed.affected ?? []).map(normalizeCliNode); - const trail = parseNodeTrail(this.run(["node", symbolId, "-p", this.workspaceRoot]), symbolId); - return { - symbolId, - callers: trail.callers, - callees: trail.callees, - affectedPaths: affected.map((symbol) => symbol.path) - }; +/** True only when the session can still tell that the index moved; a failing check proves nothing. */ +function indexMoved(session: CodeGraphReadSession): boolean { + try { + return !session.unchangedSinceOpen(); + } catch { + return false; } +} - private run(args: string[]): string { - disableCodeGraphTelemetryByDefault(); - const invocation = this.binary === DEFAULT_CODEGRAPH_BINARY ? packagedCodeGraphCliInvocation() : codeGraphCliInvocation(this.binary, this.workspaceRoot); - try { - return execFileSync(invocation.command, [...invocation.argsPrefix, ...args], { - cwd: this.workspaceRoot, - env: codeGraphChildEnv(), - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - maxBuffer: CODEGRAPH_OUTPUT_MAX_BYTES, - timeout: CODEGRAPH_QUERY_TIMEOUT_MS - }); - } catch (error) { - const stderr = error && typeof error === "object" && "stderr" in error ? String((error as { stderr?: unknown }).stderr ?? "") : ""; - const message = stderr.trim() || (error instanceof Error ? error.message : String(error)); - throw new Error(`CodeGraph CLI failed: ${message}`); - } +/** The pinned provider could not load: the readiness gate's `codegraph-unavailable` refusal. */ +function codeGraphUnavailable(): FailureError { + return new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable", {}, { action: "inspect-diagnostics", retryable: false }); +} + +function readRefusal(state: Exclude): FailureError { + switch (state) { + case "index-missing": + return new FailureError("AC_CODE_FACTS_UNAVAILABLE", "index-missing"); + case "index-open-failed": + return new FailureError("AC_CODE_FACTS_UNAVAILABLE", "index-open-failed"); + case "build-in-progress": + return new FailureError("AC_CODE_FACTS_NOT_READY", "code-facts-not-ready", { + issues: [{ code: "index-incomplete", message: CODE_FACTS_ISSUE_MESSAGES["index-incomplete"] }] + }, { action: "codegraph-index", retryable: true }); } } @@ -286,17 +352,13 @@ export interface CodeGraphCliNode { endLine?: number; } -function syntheticSymbolId(name: string, path: string): string { - return `codegraph.${digestJson({ name, path } as unknown as Json).slice(7, 19)}`; -} - -function normalizeCliNode(node: CodeGraphCliNode): NormalizedSymbol { +function normalizeNode(node: CodeGraphNode): NormalizedSymbol { return { - id: node.id ?? syntheticSymbolId(node.name, node.filePath), + id: node.id, name: node.name, kind: node.kind, path: node.filePath, - range: node.startLine ? { startLine: node.startLine, endLine: node.endLine ?? node.startLine } : undefined + range: { startLine: node.startLine, endLine: node.endLine } }; } @@ -304,35 +366,62 @@ function scopedTaskQuery(task: string, changedPaths: string[]): string { return uniqueStrings([task, ...changedPaths]).join(" "); } -// The "Calls →" trail mixes real call sites with type references (parameter/return -// annotations), so every entry is a best-effort "calls" edge, not a verified runtime call. -function parseNodeTrail(output: string, symbolId: string): { callers: NormalizedEdge[]; callees: NormalizedEdge[] } { - const callees = trailEntries(output, "**Calls →**").map((entry) => ({ - source: symbolId, - target: syntheticSymbolId(entry.name, entry.path), - kind: "calls" as const, - confidence: "high" as const - })); - const callers = trailEntries(output, "**Called by ←**").map((entry) => ({ - source: syntheticSymbolId(entry.name, entry.path), - target: symbolId, - kind: "calls" as const, - confidence: "high" as const - })); - return { callers: uniqueEdges(callers), callees: uniqueEdges(callees) }; +function compareNodes(a: CodeGraphNode, b: CodeGraphNode): number { + return a.filePath.localeCompare(b.filePath) || a.startLine - b.startLine || a.id.localeCompare(b.id); } -function trailEntries(output: string, marker: string): { name: string; path: string; line: number }[] { - const line = output.split("\n").find((candidate) => candidate.includes(marker)); - if (!line) return []; - const rest = line.slice(line.indexOf(marker) + marker.length); - return rest.split(",") - .map((entry) => entry.trim()) - .flatMap((entry) => { - const match = entry.match(/^(.+)\s\(([^()]+):(\d+)\)$/); - if (!match) return []; - return [{ name: match[1].trim(), path: match[2].trim(), line: Number(match[3]) }]; - }); +/** + * `searchNodes` in a total order: hand-written before generated files, as the CodeGraph CLI ranks + * (`generatedFilePredicate`), then by score, then by location, so equal scores never reorder. + */ +function rankedSearch(graph: CodeGraphIndexHandle, query: string, limit: number, kinds: string[] = []): CodeGraphNode[] { + const results = graph.searchNodes(query, { limit, ...(kinds.length > 0 ? { kinds: kinds as CodeGraphNodeKind[] } : {}) }); + const generated = graph.generatedFilePredicate(results.map((result) => result.node.filePath)); + return results + .map((result) => ({ result, generated: generated(result.node.filePath) ? 1 : 0 })) + .sort((a, b) => a.generated - b.generated || b.result.score - a.result.score || compareNodes(a.result.node, b.result.node)) + .map(({ result }) => result.node); +} + +/** The files of a relevant-context subgraph: its entry points' files first, then in node order. */ +function relevantFilesOf(subgraph: { nodes: Map; roots: string[] }): string[] { + const roots = subgraph.roots.flatMap((id) => subgraph.nodes.get(id) ?? []); + return uniqueStrings([...roots, ...subgraph.nodes.values()].map((node) => node.filePath)); +} + +/** The index's file node of each path, in the order of `paths`. */ +function fileNodesOf(graph: CodeGraphIndexHandle, paths: string[]): CodeGraphNode[] { + if (paths.length === 0) return []; + const byPath = new Map(graph.getNodesInFiles(paths).filter((node) => node.kind === "file").map((node) => [node.filePath, node] as const)); + return paths.flatMap((path) => byPath.get(path) ?? []); +} + +/** The node with id `symbol`, else every node whose exact qualified name or name is `symbol`. */ +function definitionsOf(graph: CodeGraphIndexHandle, symbol: string): CodeGraphNode[] { + const node = graph.getNode(symbol); + if (node) return [node]; + const definitions = new Map(); + for (const definition of [...graph.getNodesByQualifiedName(symbol), ...graph.getNodesByName(symbol)]) definitions.set(definition.id, definition); + return [...definitions.values()]; +} + +function impactOf(graph: CodeGraphIndexHandle, symbolId: string, depth: number): NormalizedImpact { + const definitions = definitionsOf(graph, symbolId); + if (definitions.length === 0) return { symbolId, callers: [], callees: [], affectedPaths: [] }; + const ids = definitions.map((definition) => definition.id); + const affected = new Set(); + for (const id of ids) for (const node of graph.getImpactRadius(id, depth).nodes.values()) affected.add(node.filePath); + return { + symbolId, + callers: callEdges(graph.getIncomingEdgesTo(ids, CALL_EDGE_KINDS)), + callees: callEdges(graph.getOutgoingEdgesFrom(ids, CALL_EDGE_KINDS)), + affectedPaths: [...affected].sort() + }; +} + +function callEdges(edges: CodeGraphEdge[]): NormalizedEdge[] { + return uniqueEdges(edges.map((edge) => ({ source: edge.source, target: edge.target, kind: "calls" as const, confidence: "high" as const }))) + .sort((a, b) => a.source.localeCompare(b.source) || a.target.localeCompare(b.target)); } function uniqueSymbols(symbols: NormalizedSymbol[]): NormalizedSymbol[] { @@ -364,8 +453,11 @@ export function digestFile(path: string): string { } export function runCodeGraphCli(binary: string, workspaceRoot: string, args: string[], maxBuffer = CODEGRAPH_OUTPUT_MAX_BYTES, envOverrides?: Record): string { + return runCodeGraphInvocation(codeGraphCliInvocation(binary, workspaceRoot), workspaceRoot, args, maxBuffer, envOverrides); +} + +function runCodeGraphInvocation(invocation: CodeGraphCliInvocation, workspaceRoot: string, args: string[], maxBuffer: number, envOverrides?: Record): string { disableCodeGraphTelemetryByDefault(); - const invocation = codeGraphCliInvocation(binary, workspaceRoot); try { return execFileSync(invocation.command, [...invocation.argsPrefix, ...args], { cwd: workspaceRoot, @@ -381,17 +473,3 @@ export function runCodeGraphCli(binary: string, workspaceRoot: string, args: str throw new Error(`CodeGraph CLI failed: ${message}`); } } - -function parseExploreSymbols(output: string, maxSymbols: number): NormalizedSymbol[] { - return [...output.matchAll(/^####\s+(.+?)\s+—/gm)] - .slice(0, maxSymbols) - .map((match, index) => { - const path = match[1].trim(); - return { - id: `codegraph.explore.${index + 1}.${digestJson(path as unknown as Json).slice(7, 15)}`, - name: basename(path), - kind: "file", - path - }; - }); -} diff --git a/packages/local-runtime/codegraph-adapter/test/codegraph-adapter.test.ts b/packages/local-runtime/codegraph-adapter/test/codegraph-adapter.test.ts index 2710f88b..972b5d94 100644 --- a/packages/local-runtime/codegraph-adapter/test/codegraph-adapter.test.ts +++ b/packages/local-runtime/codegraph-adapter/test/codegraph-adapter.test.ts @@ -1,17 +1,26 @@ import { execFileSync } from "node:child_process"; -import { describe, expect, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test"; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { digestJson, type Json } from "@archcontext/contracts"; -import { CODEGRAPH_TELEMETRY_ENV, CodeGraphAdapter, CodeGraphCliProvider, MultiRepoCodeGraphAdapter, REQUIRED_CODEGRAPH_VERSION, codeGraphCliInvocation, disableCodeGraphTelemetryByDefault, openCodeGraphSymbolReader, packagedCodeGraphCliInvocation, prepareProjectionCodeFacts } from "../src/index"; -import { fakeReadProvider } from "./read-provider-fixture"; +import { dirname, join } from "node:path"; +import { FailureError, digestJson, type Json } from "@archcontext/contracts"; +import { CODEGRAPH_TELEMETRY_ENV, CodeGraphAdapter, CodeGraphCliProvider, MultiRepoCodeGraphAdapter, REQUIRED_CODEGRAPH_VERSION, codeGraphCliInvocation, disableCodeGraphTelemetryByDefault, openCodeGraphSymbolReader, packagedCodeGraphCliInvocation, pinnedCodeGraphProvider, prepareProjectionCodeFacts, type CodeGraphReadProvider } from "../src/index"; +import * as readSessionModule from "../src/read-session"; +import { CodeGraphProviderUnavailableError, codeGraphBundle } from "../src/read-session"; +import { fakeReadProvider, type FakeIndexFacts, type FakeReadProvider } from "./read-provider-fixture"; import { MockCodeGraphProvider } from "./factories"; +import { indexCodeGraphFixture } from "./real-index"; -// Mirrors the synthetic id CodeGraphCliProvider assigns to a CLI node that has no hash id -// of its own (e.g. entries parsed from the `codegraph node` text trail). -function expectedSyntheticId(name: string, path: string): string { - return `codegraph.${digestJson({ name, path } as unknown as Json).slice(7, 19)}`; +/** `fakeReadProvider`, with the SDK reads of `graph` on every handle it opens. */ +function sdkReadProvider(root: string, graph: Record, facts: FakeIndexFacts = {}): FakeReadProvider { + const base = fakeReadProvider(root, facts); + return { ...base, openReadOnly: (openedRoot) => Object.assign(base.openReadOnly(openedRoot), graph) }; +} + +async function readFailure(read: Promise): Promise { + const failure = await read.then(() => undefined, (error: unknown) => error); + expect(failure).toBeInstanceOf(FailureError); + return failure as FailureError; } function readyStatusCommands(root: string): string { @@ -301,45 +310,29 @@ writeFileSync(${JSON.stringify(logPath)}, JSON.stringify({ writeFileSync(join(root, "src", "domain", "order-service.ts"), "export const orderService = true;\n", "utf8"); writeFileSync(join(root, "src", "web", "page.ts"), "import { orderService } from \"../domain/order-service\";\nexport const page = orderService;\n", "utf8"); writeFileSync(shimPath, ` -import { writeFileSync } from "node:fs"; const args = process.argv.slice(2); ${readyStatusCommands(root)} if (args[0] === "sync") process.exit(0); -if (args[0] === "explore") { - console.log("## Exploration: fake\\n\\n#### src/web/page.ts — page.ts(file)\\n"); - process.exit(0); -} -if (args[0] === "query") { - writeFileSync(${JSON.stringify(logPath)}, JSON.stringify({ argv: args })); - console.log(JSON.stringify([ - { node: { - id: "import:order-service", - kind: "import", - name: "../domain/order-service", - qualifiedName: "../domain/order-service", - filePath: "src/web/page.ts", - language: "typescript", - startLine: 1, - endLine: 1 - } }, - { node: { - id: "file:src/web/page.ts", - kind: "file", - name: "page.ts", - qualifiedName: "src/web/page.ts", - filePath: "src/web/page.ts", - language: "typescript", - startLine: 1, - endLine: 2 - } } - ])); - process.exit(0); -} console.error("unexpected fake codegraph args", args); process.exit(2); `); + const searches: { query: string; options: unknown }[] = []; + const nodes = [ + { id: "import:order-service", kind: "import", name: "../domain/order-service", qualifiedName: "../domain/order-service", filePath: "src/web/page.ts", language: "typescript", startLine: 1, endLine: 1 }, + { id: "file:src/web/page.ts", kind: "file", name: "page.ts", qualifiedName: "src/web/page.ts", filePath: "src/web/page.ts", language: "typescript", startLine: 1, endLine: 2 } + ]; + const provider = sdkReadProvider(root, { + findRelevantContext: async () => ({ nodes: new Map([[nodes[1]!.id, nodes[1]]]), edges: [], roots: [nodes[1]!.id] }), + getNodesInFiles: () => [nodes[1]], + generatedFilePredicate: () => () => false, + searchNodes: (query: string, options: unknown) => { + searches.push({ query, options }); + writeFileSync(logPath, JSON.stringify(searches)); + return nodes.map((node) => ({ node, score: 1 })); + } + }); - const adapter = new CodeGraphAdapter(new CodeGraphCliProvider(root, shimPath, fakeReadProvider(root))); + const adapter = new CodeGraphAdapter(new CodeGraphCliProvider(root, shimPath, provider)); await adapter.sync({ workspace: { root, repositoryId: "repo.test", headSha: "abc" }, changedPaths: [" src\\web\\page.ts", "../escape.ts", "/tmp/escape.ts"] @@ -358,12 +351,12 @@ process.exit(2); confidence: "high" }]); expect(context.digest).toMatch(/^sha256:/); - const log = JSON.parse(readFileSync(logPath, "utf8")) as { argv: string[] }; - expect(log.argv).toEqual(expect.arrayContaining(["-k", "import"])); - expect(log.argv.at(-1)).toContain("src/web/page.ts"); - expect(log.argv.at(-1)).toContain("import"); - expect(log.argv.at(-1)).not.toContain("respect dependency layer import"); - expect(log.argv.at(-1)).not.toContain("escape.ts"); + const log = JSON.parse(readFileSync(logPath, "utf8")) as { query: string; options: { kinds?: string[] } }[]; + expect(log.at(-1)!.options.kinds).toEqual(["import"]); + expect(log.at(-1)!.query).toContain("src/web/page.ts"); + expect(log.at(-1)!.query).toContain("import"); + expect(log.at(-1)!.query).not.toContain("respect dependency layer import"); + expect(log.at(-1)!.query).not.toContain("escape.ts"); const git = { schemaVersion: "archcontext.git-change-metadata/v1" as const, @@ -440,113 +433,6 @@ process.exit(2); } }); - test("extracts callers and callees from the CodeGraph node trail", async () => { - const root = mkdtempSync(join(tmpdir(), "archctx-codegraph-trail-")); - const shimPath = join(root, "fake-codegraph.js"); - try { - writeFileSync(shimPath, ` -const args = process.argv.slice(2); -if (args[0] === "impact") { - console.log(JSON.stringify({ - symbol: args[args.length - 1], - depth: 1, - nodeCount: 1, - edgeCount: 0, - affected: [ - { name: "getImpact", kind: "method", filePath: "packages/local-runtime/codegraph-adapter/src/index.ts", startLine: 274 } - ] - })); - process.exit(0); -} -if (args[0] === "node") { - console.log("**getImpact** (method)"); - console.log(""); - console.log("**Location:** packages/local-runtime/codegraph-adapter/src/index.ts:274"); - console.log("**Calls →** assertCompatible (packages/local-runtime/codegraph-adapter/src/index.ts:303), assertCompatible (packages/local-runtime/codegraph-adapter/src/index.ts:303), ImpactQuery (packages/contracts/src/ports.ts:111)"); - console.log("**Called by ←** getCallers (packages/local-runtime/codegraph-adapter/src/index.ts:279), getCallees (packages/local-runtime/codegraph-adapter/src/index.ts:283), +28 more"); - process.exit(0); -} -console.error("unexpected fake codegraph args", args); -process.exit(2); -`); - - const adapter = new CodeGraphAdapter(new CodeGraphCliProvider(root, shimPath)); - const impact = await adapter.getImpact({ symbolId: "CodeGraphAdapter::getImpact", depth: 1 }); - - expect(impact.affectedPaths).toEqual(["packages/local-runtime/codegraph-adapter/src/index.ts"]); - // Duplicate "assertCompatible" entry in the canned trail above must collapse to one - // edge, proving uniqueEdges() is applied before the result is returned. - expect(impact.callees).toEqual([ - { - source: "CodeGraphAdapter::getImpact", - target: expectedSyntheticId("assertCompatible", "packages/local-runtime/codegraph-adapter/src/index.ts"), - kind: "calls", - confidence: "high" - }, - { - source: "CodeGraphAdapter::getImpact", - target: expectedSyntheticId("ImpactQuery", "packages/contracts/src/ports.ts"), - kind: "calls", - confidence: "high" - } - ]); - // The trailing "+28 more" truncation marker must not be parsed as a third entry. - expect(impact.callers).toEqual([ - { - source: expectedSyntheticId("getCallers", "packages/local-runtime/codegraph-adapter/src/index.ts"), - target: "CodeGraphAdapter::getImpact", - kind: "calls", - confidence: "high" - }, - { - source: expectedSyntheticId("getCallees", "packages/local-runtime/codegraph-adapter/src/index.ts"), - target: "CodeGraphAdapter::getImpact", - kind: "calls", - confidence: "high" - } - ]); - - const callers = await adapter.getCallers("CodeGraphAdapter::getImpact"); - const callees = await adapter.getCallees("CodeGraphAdapter::getImpact"); - expect(callers).toEqual(impact.callers); - expect(callees).toEqual(impact.callees); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); - - test("resolves callers and callees to empty arrays when the CLI node trail has no recognizable calls", async () => { - const root = mkdtempSync(join(tmpdir(), "archctx-codegraph-trail-empty-")); - const shimPath = join(root, "fake-codegraph.js"); - try { - writeFileSync(shimPath, ` -const args = process.argv.slice(2); -if (args[0] === "impact") { - console.log(JSON.stringify({ affected: [] })); - process.exit(0); -} -if (args[0] === "node") { - console.log('Symbol "' + args[1] + '" not found in the codebase'); - process.exit(0); -} -console.error("unexpected fake codegraph args", args); -process.exit(2); -`); - - const provider = new CodeGraphCliProvider(root, shimPath); - const impact = await provider.getImpactRadius("class:a58304a6dfe8667b0efd3c9c5b707eaf", 1); - - expect(impact).toEqual({ - symbolId: "class:a58304a6dfe8667b0efd3c9c5b707eaf", - callers: [], - callees: [], - affectedPaths: [] - }); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); - test("aggregates per-repo contexts with stable repo-scoped symbol ids", async () => { const web = new MockCodeGraphProvider(); const api = new MockCodeGraphProvider(); @@ -591,6 +477,186 @@ process.exit(2); expect(adapter.crossRepoImpact([relation], "repo.api")).toEqual([relation]); expect(adapter.crossRepoImpact([relation], "repo.worker")).toEqual([]); }); + + test("a read the session refuses fails closed, and an index that moves during the read is retryable", async () => { + const graph = { searchNodes: () => [], generatedFilePredicate: () => () => false }; + const failingRead = () => { throw new Error("private raw provider error /outside/repository"); }; + // [label, provider, code, reason, retryable, handles opened, handles closed] + const cases: [string, (root: string) => FakeReadProvider, string, string, boolean, number, number][] = [ + ["no index", (root) => { const provider = sdkReadProvider(root, graph); rmSync(join(root, ".codegraph"), { recursive: true, force: true }); return provider; }, "AC_CODE_FACTS_UNAVAILABLE", "index-missing", true, 0, 0], + ["open fails", (root) => sdkReadProvider(root, graph, { openFails: true }), "AC_CODE_FACTS_UNAVAILABLE", "index-open-failed", false, 1, 0], + ["build lock", (root) => { const provider = sdkReadProvider(root, graph); writeFileSync(join(root, ".codegraph", "codegraph.lock"), String(process.pid)); return provider; }, "AC_CODE_FACTS_NOT_READY", "code-facts-not-ready", true, 0, 0], + ["revision moves", (root) => sdkReadProvider(root, graph, { revisionMoves: true }), "AC_CODE_FACTS_NOT_READY", "code-facts-not-ready", true, 1, 1], + // A read that throws because the index moved under it is the same retryable refusal (G4). + ["read throws while the index moves", (root) => sdkReadProvider(root, { ...graph, searchNodes: failingRead }, { revisionMoves: true }), "AC_CODE_FACTS_NOT_READY", "code-facts-not-ready", true, 1, 1], + // A failing close keeps the read's own error. + ["read and close throw", (root) => sdkReadProvider(root, { ...graph, searchNodes: failingRead, close: failingRead }), "AC_CODE_FACTS_UNAVAILABLE", "index-open-failed", false, 1, 0] + ]; + for (const [label, provide, code, reasonCode, retryable, opened, closed] of cases) { + const root = mkdtempSync(join(tmpdir(), "archctx-codegraph-read-refusal-")); + try { + const provider = provide(root); + const failure = await readFailure(new CodeGraphCliProvider(root, undefined, provider).findSymbols({ query: "render" })); + expect([label, failure.code, failure.reasonCode, failure.retryable]).toEqual([label, code, reasonCode, retryable]); + expect([label, provider.calls.open, provider.calls.close]).toEqual([label, opened, closed]); + expect(failure.message).not.toContain("outside"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + } + }); + + test("a provider that cannot load is code-facts-unavailable, as the readiness gate maps it", async () => { + const root = mkdtempSync(join(tmpdir(), "archctx-codegraph-provider-unavailable-")); + const open = spyOn(readSessionModule, "openCodeGraphReadSession").mockImplementation(() => { + throw new CodeGraphProviderUnavailableError("CodeGraph SDK could not be loaded from /outside/repository"); + }); + try { + const failure = await readFailure(new CodeGraphCliProvider(root).findSymbols({ query: "render" })); + expect([failure.code, failure.reasonCode, failure.action, failure.retryable]).toEqual(["AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable", "inspect-diagnostics", false]); + expect(failure.message).not.toContain("outside"); + expect(open).toHaveBeenCalledTimes(1); + } finally { + open.mockRestore(); + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("CodeGraphCliProvider SDK reads on a real index (#574)", () => { + const files: Record = { + "src/pricing/leaf.ts": "export function basePrice(sku: string): number {\n return sku.length;\n}\n", + "src/pricing/tax.ts": "import { basePrice } from \"./leaf\";\n\nexport function taxedPrice(sku: string): number {\n return basePrice(sku) * 2;\n}\n", + "src/checkout/cart.ts": "import { taxedPrice } from \"../pricing/tax\";\n\nexport function cartTotal(skus: string[]): number {\n return skus.reduce((sum, sku) => sum + taxedPrice(sku), 0);\n}\n", + "src/views/page.ts": "export function render(): string {\n return \"page\";\n}\n\nexport function showPage(): string {\n return render();\n}\n", + "src/views/widget.ts": "export class Widget {\n render(): string {\n return \"widget\";\n }\n}\n\nexport function showWidget(widget: Widget): string {\n return widget.render();\n}\n", + "src/pricing/sku.ts": "export interface Sku {\n code: string;\n}\n", + "src/checkout/label.ts": "import type { Sku } from \"../pricing/sku\";\n\nexport function label(sku: Sku): string {\n return sku.code;\n}\n" + }; + let root = ""; + beforeAll(() => { + root = realpathSync.native(mkdtempSync(join(tmpdir(), "archctx-codegraph-sdk-reads-"))); + for (const [path, content] of Object.entries(files)) { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), content); + } + execFileSync("git", ["init", "-q"], { cwd: root, stdio: ["ignore", "pipe", "pipe"] }); + indexCodeGraphFixture(root); + }, 120_000); + afterAll(() => { if (root) rmSync(root, { recursive: true, force: true }); }); + + const symbolId = async (query: string, kind: string) => (await new CodeGraphCliProvider(root).findSymbols({ query, kinds: [kind] }))[0]!.id; + const calls = (source: string, target: string) => ({ source, target, kind: "calls" as const, confidence: "high" as const }); + + test("buildContext returns the query nodes, then the relevant files in relevance order, deterministically", async () => { + const provider = new CodeGraphCliProvider(root); + const build = () => provider.buildContext("basePrice", { maxSymbols: 4, includeSource: false }); + const context = await build(); + // `basePrice` is the only search hit; the files come from its relevant context, its own file + // and its caller's before the caller's caller. + expect(context.symbols.map((symbol) => [symbol.kind, symbol.name, symbol.path, symbol.range])).toEqual([ + ["function", "basePrice", "src/pricing/leaf.ts", { startLine: 1, endLine: 3 }], + ["file", "leaf.ts", "src/pricing/leaf.ts", { startLine: 1, endLine: 4 }], + ["file", "tax.ts", "src/pricing/tax.ts", { startLine: 1, endLine: 6 }], + ["file", "cart.ts", "src/checkout/cart.ts", { startLine: 1, endLine: 6 }] + ]); + expect(context.symbols[0]!.id).toBe(await symbolId("basePrice", "function")); + expect(context.edges).toEqual([{ source: "file:src/pricing/tax.ts", target: "file:src/pricing/leaf.ts", kind: "imports", confidence: "high" }]); + expect(context.evidence.map((item) => item.selector.symbolId)).toEqual(context.symbols.map((symbol) => symbol.id)); + expect(await build()).toEqual(context); + }); + + test("findSymbols keeps only the requested kinds", async () => { + const provider = new CodeGraphCliProvider(root); + expect(await provider.findSymbols({ query: "render", kinds: ["method"] })).toEqual([ + { id: expect.stringMatching(/^method:[0-9a-f]{32}$/), name: "render", kind: "method", path: "src/views/widget.ts", range: { startLine: 2, endLine: 4 } } + ]); + expect((await provider.findSymbols({ query: "render", kinds: ["function"] })).map((symbol) => [symbol.kind, symbol.path])).toEqual([["function", "src/views/page.ts"]]); + // Unfiltered, both definitions tie on the search; the file path breaks the tie. + expect((await provider.findSymbols({ query: "render" })).map((symbol) => [symbol.kind, symbol.path])).toEqual([ + ["function", "src/views/page.ts"], + ["method", "src/views/widget.ts"] + ]); + }); + + test("impact widens with depth; callers are the direct call edges", async () => { + const provider = new CodeGraphCliProvider(root); + const basePrice = await symbolId("basePrice", "function"); + const caller = calls(await symbolId("taxedPrice", "function"), basePrice); + expect(await provider.getImpactRadius("basePrice", 1)).toEqual({ + symbolId: "basePrice", callers: [caller], callees: [], affectedPaths: ["src/pricing/leaf.ts", "src/pricing/tax.ts"] + }); + expect(await provider.getImpactRadius("basePrice", 2)).toEqual({ + symbolId: "basePrice", callers: [caller], callees: [], affectedPaths: ["src/checkout/cart.ts", "src/pricing/leaf.ts", "src/pricing/tax.ts"] + }); + // A node id names the same definition as its name. + expect({ ...await provider.getImpactRadius(basePrice, 2), symbolId: "basePrice" }).toEqual(await provider.getImpactRadius("basePrice", 2)); + expect((await provider.getImpactRadius("taxedPrice", 1)).callees).toEqual([calls(await symbolId("taxedPrice", "function"), basePrice)]); + }); + + test("a type reference reaches affectedPaths but is no caller", async () => { + // `label` uses `Sku` only as its parameter type. + expect(await new CodeGraphCliProvider(root).getImpactRadius("Sku", 1)).toEqual({ + symbolId: "Sku", callers: [], callees: [], affectedPaths: ["src/checkout/label.ts", "src/pricing/sku.ts"] + }); + }); + + test("an unknown symbol has an empty impact", async () => { + expect(await new CodeGraphCliProvider(root).getImpactRadius("noSuchSymbol", 2)).toEqual({ symbolId: "noSuchSymbol", callers: [], callees: [], affectedPaths: [] }); + }); + + test("an ambiguous name gives the union over its definitions; a qualified name narrows it", async () => { + const provider = new CodeGraphCliProvider(root); + const method = calls(await symbolId("showWidget", "function"), await symbolId("render", "method")); + const fn = calls(await symbolId("showPage", "function"), await symbolId("render", "function")); + const impact = await provider.getImpactRadius("render", 1); + expect(impact.affectedPaths).toEqual(["src/views/page.ts", "src/views/widget.ts"]); + // Callers are ordered by source id: showWidget's id (`function:5d5d…`) sorts before showPage's (`function:b00b…`). + expect(impact.callers).toEqual([method, fn]); + expect(await provider.getImpactRadius("Widget::render", 1)).toEqual({ + symbolId: "Widget::render", callers: [method], callees: [], affectedPaths: ["src/views/widget.ts"] + }); + }); + + test("a write the read-only handle refuses inside an SDK call is a read failure, never retried writable", async () => { + const pinned = pinnedCodeGraphProvider(); + const { CodeGraph } = codeGraphBundle().sdk; + const sdkOpen = { open: CodeGraph.open, openSync: CodeGraph.openSync }; + const opens: unknown[] = []; + const refusals: string[] = []; + let closes = 0; + const provider: CodeGraphReadProvider = { + ...pinned, + openReadOnly: (openedRoot) => { + const graph = pinned.openReadOnly(openedRoot); + const close = graph.close.bind(graph); + graph.close = () => { closes++; close(); }; + const findRelevantContext = graph.findRelevantContext.bind(graph); + // Fault injection: CodeGraph 1.6.2's findRelevantContext makes no heal write, so this wrapper + // makes one, a real write through the read-only handle's database, which SQLite refuses. + graph.findRelevantContext = async (query, options) => { + try { (graph as unknown as { queries: { insertNameSegmentsBatch(names: string[]): void } }).queries.insertNameSegmentsBatch(["healed"]); } + catch (error) { refusals.push(String(error)); throw error; } + return findRelevantContext(query, options); + }; + return graph; + } + }; + CodeGraph.open = ((...args: Parameters) => { opens.push(["open", args[1]]); return sdkOpen.open.apply(CodeGraph, args); }) as typeof CodeGraph.open; + CodeGraph.openSync = ((...args: Parameters) => { opens.push(["openSync", args[1]]); return sdkOpen.openSync.apply(CodeGraph, args); }) as typeof CodeGraph.openSync; + let failure: FailureError; + try { + failure = await readFailure(new CodeGraphCliProvider(root, undefined, provider).buildContext("basePrice", { maxSymbols: 4, includeSource: false })); + } finally { + Object.assign(CodeGraph, sdkOpen); + } + expect(refusals).toHaveLength(1); + expect(refusals[0]).toContain("readonly"); + expect([failure.code, failure.reasonCode]).toEqual(["AC_CODE_FACTS_UNAVAILABLE", "index-open-failed"]); + expect(failure.message).not.toContain("readonly"); + expect(opens).toEqual([["openSync", { readOnly: true }]]); + expect(closes).toBe(1); + }); }); describe("CodeGraph symbol reader (#298)", () => { 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 23043a88..7db2d7b4 100644 --- a/packages/local-runtime/codegraph-adapter/test/refactor-baseline.test.ts +++ b/packages/local-runtime/codegraph-adapter/test/refactor-baseline.test.ts @@ -5,8 +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` — using the - * written-to-disk fake CLI pattern this package already uses. No `export` was added. + * `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. * * 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 @@ -24,8 +25,10 @@ import { loadCapabilityCodeGraphProjectionInputs, prepareProjectionCodeFacts, type CapabilityCodeGraphProjectionInputs, - type CodeGraphProjectionHandshakeV1 + type CodeGraphProjectionHandshakeV1, + type CodeGraphReadProvider } from "../src/index"; +import { fakeReadProvider } from "./read-provider-fixture"; interface BaselineFixture { id: string; @@ -89,7 +92,7 @@ const model: NativeModel = { relations: [] }; -/** Fake CLI that answers the import dump, one symbol query, and `explore`; anything else fails loudly. */ +/** Fake CLI that answers the import dump; anything else fails loudly. */ const IMPORT_DUMP_CLI = ` const argv = process.argv.slice(2); const importNodes = ${JSON.stringify(importNodes)}; @@ -100,17 +103,30 @@ if (argv[0] === "query" && argv.includes("-k") && argv[argv.indexOf("-k") + 1] = })))); process.exit(0); } -if (argv[0] === "query") { - process.stdout.write(JSON.stringify([ - { node: { id: "sym:main", kind: "function", name: "main", filePath: "packages/app/src/main.ts", startLine: 1, endLine: 1 } } - ])); - process.exit(0); -} -if (argv[0] === "explore") { process.stdout.write(""); process.exit(0); } process.stderr.write("unexpected codegraph invocation: " + JSON.stringify(argv)); process.exit(1); `; +/** + * Read-only provider answering what the fake CLI answers: the import dump for an import search, + * one symbol for any other search, and an empty relevant context. Scores fall in dump order, so the + * search ranks the dump as the CLI listed it. + */ +function importDumpReadProvider(root: string): CodeGraphReadProvider { + const base = fakeReadProvider(root); + return { + ...base, + openReadOnly: (openedRoot) => Object.assign(base.openReadOnly(openedRoot), { + findRelevantContext: async () => ({ nodes: new Map(), edges: [], roots: [] }), + getNodesInFiles: () => [], + generatedFilePredicate: () => () => false, + searchNodes: (_query: string, options: { limit: number; kinds?: string[] }) => options.kinds?.includes("import") + ? importNodes.slice(0, options.limit).map((node, index) => ({ node: { id: `import:${node.filePath}:${node.name}`, kind: "import", name: node.name, filePath: node.filePath }, score: importNodes.length - index })) + : [{ node: { id: "sym:main", kind: "function", name: "main", filePath: "packages/app/src/main.ts", startLine: 1, endLine: 1 }, score: 1 }] + }) + }; +} + function seedWorkspace(): { root: string; binary: string } { const root = mkdtempSync(join(tmpdir(), "archctx-rf0-codegraph-")); mkdirSync(join(root, ".codegraph")); @@ -218,7 +234,7 @@ describe("RF0 baseline: normalized task-context import edges", () => { test(`${fixture.id} — ${fixture.description}`, async () => { const { root, binary } = seedWorkspace(); try { - const provider = new CodeGraphCliProvider(root, binary); + const provider = new CodeGraphCliProvider(root, binary, importDumpReadProvider(root)); const context = await provider.buildContext(fixture.input.task, { maxSymbols: fixture.input.maxSymbols, includeSource: fixture.input.includeSource,