From f14b6a8d2c44005e80f4a0e5d419c06c11a6555b Mon Sep 17 00:00:00 2001 From: Ancienttwo <215803476+Ancienttwo@users.noreply.github.com> Date: Sat, 14 Nov 2026 04:17:31 +0800 Subject: [PATCH 1/2] feat(codegraph-adapter): SDK-backed provider reads (#574 S5) CodeGraphCliProvider keeps the CLI only to build the index (sync) and reads buildContext, findSymbols and impact through one read-only session per read. buildContext takes its file set from findRelevantContext and its query nodes from searchNodes (C6). The explore, query, node and impact CLI reads and their parsers (parseExploreSymbols, parseNodeTrail, trailEntries, normalizeCliNode) are removed. Refs #574 --- .../codegraph-adapter/src/cli-provider.ts | 274 ++++++++------ .../test/codegraph-adapter.test.ts | 337 ++++++++++-------- .../test/refactor-baseline.test.ts | 40 ++- 3 files changed, 375 insertions(+), 276 deletions(-) diff --git a/packages/local-runtime/codegraph-adapter/src/cli-provider.ts b/packages/local-runtime/codegraph-adapter/src/cli-provider.ts index 2cf7ae8a..d76e3d55 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 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,88 +90,106 @@ 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); - } - - 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); - } - - private importNodesForChangedPaths(changedPaths: string[]): CodeGraphCliNode[] { - const query = importQuery(changedPaths); - if (!query) return []; - return this.queryNodes(query, Math.max(changedPaths.length * 8, 12), ["import"]); + const nodes = await this.read((graph) => rankedSearch(graph, query.query, query.limit ?? 10, query.kinds)); + return nodes.map(normalizeNode); } + /** + * `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. + */ 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) - }; + return this.read((graph) => impactOf(graph, symbolId, depth)); } - private run(args: string[]): string { - disableCodeGraphTelemetryByDefault(); - const invocation = this.binary === DEFAULT_CODEGRAPH_BINARY ? packagedCodeGraphCliInvocation() : codeGraphCliInvocation(this.binary, this.workspaceRoot); + /** + * Runs `read` against one read-only session that is closed when it settles. A refusal by the + * handle, including a write the read-only open refuses inside an SDK call, is a read failure; the + * read is never retried, and never with a writable open. Facts read across a build or a sync are + * not one index state, so they are refused as retryable (design G4). + */ + private async read(read: (graph: CodeGraphIndexHandle) => T | Promise): Promise { + const opened = openCodeGraphReadSession(this.workspaceRoot, this.readProvider ? { provider: this.readProvider } : {}); + if (opened.state !== "open") throw readRefusal(opened.state); + const { session } = opened; 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}`); + let result: T; + let unchanged: boolean; + try { + result = await read(session.graph); + unchanged = session.unchangedSinceOpen(); + } catch { + // No provider text crosses this boundary. + throw readRefusal("index-open-failed"); + } + // A build or sync that committed during the read: refused as a build in progress is. + if (!unchanged) throw readRefusal("build-in-progress"); + return result; + } finally { + session.close(); } } } +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 }); + } +} + export interface CodeGraphCliInvocation { command: string; argsPrefix: string[]; @@ -286,17 +328,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 +342,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 +429,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 +449,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..278463cd 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,25 @@ import { execFileSync } from "node:child_process"; -import { describe, expect, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, 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 { 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 +309,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 +350,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 +432,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 +476,150 @@ 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 }; + // [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] + ]; + 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]); + } finally { + 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" + }; + 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"]]); + }); + + 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("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"]); + expect(impact.callers).toHaveLength(2); + expect(impact.callers).toContainEqual(method); + expect(impact.callers).toContainEqual(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); + // As a segment-vocabulary heal would: a real write through the read-only handle's database. + 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, From d59c8f83cead0f1dcf311d281b2b2ba1745e426b Mon Sep 17 00:00:00 2001 From: Ancienttwo <215803476+Ancienttwo@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:16:59 +0800 Subject: [PATCH 2/2] fix(codegraph-adapter): preserve provider read failure contracts (#597) Preserve the S5 round-2 worker fixes for cross-machine continuation. Updated-head review remains pending. --- .../codegraph-adapter/src/cli-provider.ts | 64 +++++++++++++------ .../test/codegraph-adapter.test.ts | 53 ++++++++++++--- 2 files changed, 89 insertions(+), 28 deletions(-) diff --git a/packages/local-runtime/codegraph-adapter/src/cli-provider.ts b/packages/local-runtime/codegraph-adapter/src/cli-provider.ts index d76e3d55..bf674cbc 100644 --- a/packages/local-runtime/codegraph-adapter/src/cli-provider.ts +++ b/packages/local-runtime/codegraph-adapter/src/cli-provider.ts @@ -6,7 +6,7 @@ import { createRequire } from "node:module"; 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, openCodeGraphReadSession, type CodeGraphIndexHandle, type CodeGraphReadProvider, type CodeGraphReadSessionOpenV1 } 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 { 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"; @@ -142,41 +142,65 @@ export class CodeGraphCliProvider implements CodeGraphProvider { * `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. + * 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)); } /** - * Runs `read` against one read-only session that is closed when it settles. A refusal by the - * handle, including a write the read-only open refuses inside an SDK call, is a read failure; the - * read is never retried, and never with a writable open. Facts read across a build or a sync are - * not one index state, so they are refused as retryable (design G4). + * 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 { - const opened = openCodeGraphReadSession(this.workspaceRoot, this.readProvider ? { provider: this.readProvider } : {}); + 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 { - let result: T; - let unchanged: boolean; - try { - result = await read(session.graph); - unchanged = session.unchangedSinceOpen(); - } catch { - // No provider text crosses this boundary. - throw readRefusal("index-open-failed"); - } - // A build or sync that committed during the read: refused as a build in progress is. - if (!unchanged) throw readRefusal("build-in-progress"); - return result; - } finally { session.close(); + } catch { + if ("value" in outcome) outcome = { error: readRefusal("index-open-failed") }; } + if ("error" in outcome) throw outcome.error; + return outcome.value; + } +} + +/** 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; } } +/** 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": 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 278463cd..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,11 +1,12 @@ import { execFileSync } from "node:child_process"; -import { afterAll, beforeAll, 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 { 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 { codeGraphBundle } from "../src/read-session"; +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"; @@ -479,12 +480,17 @@ process.exit(2); 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] + ["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-")); @@ -493,11 +499,28 @@ process.exit(2); 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)", () => { @@ -506,7 +529,9 @@ describe("CodeGraphCliProvider SDK reads on a real index (#574)", () => { "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/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(() => { @@ -547,6 +572,11 @@ describe("CodeGraphCliProvider SDK reads on a real index (#574)", () => { { 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 () => { @@ -564,6 +594,13 @@ describe("CodeGraphCliProvider SDK reads on a real index (#574)", () => { 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: [] }); }); @@ -574,9 +611,8 @@ describe("CodeGraphCliProvider SDK reads on a real index (#574)", () => { 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"]); - expect(impact.callers).toHaveLength(2); - expect(impact.callers).toContainEqual(method); - expect(impact.callers).toContainEqual(fn); + // 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"] }); @@ -596,7 +632,8 @@ describe("CodeGraphCliProvider SDK reads on a real index (#574)", () => { const close = graph.close.bind(graph); graph.close = () => { closes++; close(); }; const findRelevantContext = graph.findRelevantContext.bind(graph); - // As a segment-vocabulary heal would: a real write through the read-only handle's database. + // 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; }