diff --git a/packages/local-runtime/codegraph-adapter/src/cli-provider.ts b/packages/local-runtime/codegraph-adapter/src/cli-provider.ts new file mode 100644 index 00000000..2cf7ae8a --- /dev/null +++ b/packages/local-runtime/codegraph-adapter/src/cli-provider.ts @@ -0,0 +1,397 @@ +import { assertLocalEgressAllowed, localEgressMode } from "@archcontext/local-runtime/egress-admission"; +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 { codeGraphChildEnv } from "./child-env"; +import { REQUIRED_CODEGRAPH_PACKAGE, REQUIRED_CODEGRAPH_VERSION, type CodeGraphReadProvider } 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 { importEdgesFromQueryNodes, importQuery, uniqueStrings } from "./import-population"; +import { assertRepositoryCodeFactsReady } from "./repository-code-facts"; + +export const CODEGRAPH_TELEMETRY_ENV = "DO_NOT_TRACK"; +export const CODEGRAPH_TELEMETRY_DISABLED_VALUE = "1"; +export const DEFAULT_CODEGRAPH_BINARY = "codegraph"; +const requireFromAdapter = createRequire(import.meta.url); + +type MutableEnv = Record; +export const CODEGRAPH_OUTPUT_MAX_BYTES = 32 * 1024 * 1024; +export const CODEGRAPH_QUERY_TIMEOUT_MS = 30_000; + +export function disableCodeGraphTelemetryByDefault(env: MutableEnv = process.env): string { + localEgressMode(env); + if (env[CODEGRAPH_TELEMETRY_ENV] !== undefined && env[CODEGRAPH_TELEMETRY_ENV] !== CODEGRAPH_TELEMETRY_DISABLED_VALUE) assertLocalEgressAllowed("codegraph-telemetry", env); + env[CODEGRAPH_TELEMETRY_ENV] ??= CODEGRAPH_TELEMETRY_DISABLED_VALUE; + return env[CODEGRAPH_TELEMETRY_ENV] ?? CODEGRAPH_TELEMETRY_DISABLED_VALUE; +} + +export interface CodeGraphProvider { + version: string; + capabilities: string[]; + assertReady?(workspaceRoot: string): void; + assertCanSync?(workspaceRoot: string): void; + indexAll(workspaceRoot: string): Promise; + buildContext(task: string, options: { maxSymbols: number; includeSource: boolean; changedPaths?: string[] }): Promise; + findSymbols(query: SymbolQuery): Promise; + getImpactRadius(symbolId: string, depth: number): Promise; +} + +export class CodeGraphCliProvider implements CodeGraphProvider { + version: string = REQUIRED_CODEGRAPH_VERSION; + capabilities = ["index", "context", "impact"]; + private workspaceRoot: string; + + /** + * `binary` builds the index (`sync`); reads never start it. `readProvider` is the read-only + * provider readiness reads through, the pinned one unless a test injects one. + */ + constructor(workspaceRoot = process.cwd(), private readonly binary = DEFAULT_CODEGRAPH_BINARY, private readonly readProvider?: CodeGraphReadProvider) { + this.workspaceRoot = workspaceRoot; + } + + async indexAll(workspaceRoot: string): Promise { + this.workspaceRoot = workspaceRoot; + this.assertCanSync(workspaceRoot); + this.run(["sync", workspaceRoot]); + this.assertReady(workspaceRoot); + } + + assertReady(workspaceRoot: string): void { + assertRepositoryCodeFactsReady(workspaceRoot, this.readProvider ? { provider: this.readProvider } : {}); + } + + assertCanSync(workspaceRoot: string): void { + this.assertReady(workspaceRoot); + } + + 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 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)); + return { + task, + symbols: fallback, + edges, + evidence: fallback.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}`, + confidence: "observed", + snapshot: { + repositoryId: "codegraph", + headSha: "codegraph", + worktreeDigest: digestJson({ task, path: symbol.path, output } as unknown as Json) + } + })), + digest: digestJson({ task, symbols: fallback, edges, includeSource: options.includeSource, outputDigest: digestJson(output as unknown as Json) } as unknown as Json) + }; + } + + 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"]); + } + + 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) + }; + } + + 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}`); + } + } +} + +export interface CodeGraphCliInvocation { + command: string; + argsPrefix: string[]; +} + +/** Projection-runtime resolution is deliberately package-local; PATH is not an authority. */ +export function packagedCodeGraphCliInvocation(): CodeGraphCliInvocation { + const packagedShim = resolvePackagedCodeGraphShim(); + if (!packagedShim) { + throw new Error(`CodeGraph package-local ${REQUIRED_CODEGRAPH_VERSION} binary is unavailable`); + } + return { command: process.execPath, argsPrefix: [packagedShim] }; +} + +export function codeGraphCliInvocation(binary: string, cwd: string, pathValue = process.env.PATH ?? ""): CodeGraphCliInvocation { + const resolved = resolveExecutable(binary, cwd, pathValue); + if (resolved && isNodeRuntimeScript(resolved)) { + return { command: process.execPath, argsPrefix: [resolved] }; + } + if (!resolved && binary === DEFAULT_CODEGRAPH_BINARY) { + const packagedShim = resolvePackagedCodeGraphShim(); + if (packagedShim) return { command: process.execPath, argsPrefix: [packagedShim] }; + } + return { command: binary, argsPrefix: [] }; +} + +function resolveExecutable(binary: string, cwd: string, pathValue: string): string | undefined { + if (binary.includes("/") || binary.includes("\\")) { + const path = isAbsolute(binary) ? binary : join(cwd, binary); + return existsSync(path) ? realpathSync.native(path) : undefined; + } + for (const dir of pathValue.split(delimiter)) { + if (!dir) continue; + const path = join(dir, binary); + if (isExecutablePath(path)) return realpathSync.native(path); + } + return undefined; +} + +export function isExecutablePath(path: string): boolean { + try { + if (!statSync(path).isFile()) return false; + if (process.platform !== "win32") accessSync(path, fsConstants.X_OK); + return true; + } catch { + return false; + } +} + +function resolvePackagedCodeGraphShim(): string | undefined { + try { + const manifestPath = requireFromAdapter.resolve(`${REQUIRED_CODEGRAPH_PACKAGE}/package.json`); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { + version?: string; + bin?: Record; + }; + const relativeBin = manifest.bin?.codegraph; + if (manifest.version !== REQUIRED_CODEGRAPH_VERSION || !relativeBin) return undefined; + const shimPath = resolve(dirname(manifestPath), relativeBin); + return existsSync(shimPath) ? realpathSync.native(shimPath) : undefined; + } catch { + return undefined; + } +} + +/** + * Binds the projection receipt to the executable platform bundle, not only to the npm launcher. + * CodeGraph's public package is a thin shim which execs a platform package containing its own + * Node runtime and CLI entrypoint, so all of those executable layers are part of the authority. + */ +export function packagedCodeGraphRuntimeDigest(): string { + try { + const manifestPath = requireFromAdapter.resolve(`${REQUIRED_CODEGRAPH_PACKAGE}/package.json`); + const packageRequire = createRequire(manifestPath); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { + version?: string; + bin?: Record; + }; + if (manifest.version !== REQUIRED_CODEGRAPH_VERSION || !manifest.bin?.codegraph) { + throw new Error(`package manifest is not exact ${REQUIRED_CODEGRAPH_VERSION}`); + } + const platformPackage = `${REQUIRED_CODEGRAPH_PACKAGE}-${process.platform}-${process.arch}`; + const platformManifestPath = packageRequire.resolve(`${platformPackage}/package.json`); + const platformManifest = JSON.parse(readFileSync(platformManifestPath, "utf8")) as { version?: string }; + if (platformManifest.version !== REQUIRED_CODEGRAPH_VERSION) { + throw new Error(`platform bundle is not exact ${REQUIRED_CODEGRAPH_VERSION}`); + } + const packageRoot = dirname(manifestPath); + const platformRoot = dirname(platformManifestPath); + const runtimeFiles = process.platform === "win32" + ? ["node.exe", "lib/dist/bin/codegraph.js"] + : ["bin/codegraph", "node", "lib/dist/bin/codegraph.js"]; + return digestJson({ + packageName: REQUIRED_CODEGRAPH_PACKAGE, + packageVersion: REQUIRED_CODEGRAPH_VERSION, + packageManifestDigest: digestFile(manifestPath), + launcherDigest: digestFile(resolve(packageRoot, manifest.bin.codegraph)), + platformPackage, + platformManifestDigest: digestFile(platformManifestPath), + runtimeFileDigests: runtimeFiles.map((path) => ({ path, digest: digestFile(resolve(platformRoot, path)) })) + } as unknown as Json); + } catch (error) { + throw new Error(`CodeGraph package-local runtime is unreadable: ${error instanceof Error ? error.message : String(error)}`); + } +} + +function isNodeRuntimeScript(path: string): boolean { + if (/\.[cm]?js$/i.test(path)) return true; + const prefix = readFilePrefix(path, 256); + return prefix.startsWith("#!/usr/bin/env node") + || prefix.startsWith("#!/usr/bin/env bun") + || prefix.startsWith("#!/usr/bin/node") + || prefix.startsWith("#!/usr/local/bin/node"); +} + +function readFilePrefix(path: string, length: number): string { + let fd: number | undefined; + try { + fd = openSync(path, "r"); + const buffer = Buffer.alloc(length); + const bytes = readSync(fd, buffer, 0, length, 0); + return buffer.subarray(0, bytes).toString("utf8"); + } catch { + return ""; + } finally { + if (fd !== undefined) closeSync(fd); + } +} + +export interface CodeGraphCliNode { + id?: string; + kind: string; + name: string; + filePath: string; + startLine?: number; + 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 { + return { + id: node.id ?? syntheticSymbolId(node.name, node.filePath), + name: node.name, + kind: node.kind, + path: node.filePath, + range: node.startLine ? { startLine: node.startLine, endLine: node.endLine ?? node.startLine } : undefined + }; +} + +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 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]) }]; + }); +} + +function uniqueSymbols(symbols: NormalizedSymbol[]): NormalizedSymbol[] { + const seen = new Set(); + return symbols.filter((symbol) => { + const key = `${symbol.id}:${symbol.path}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function uniqueEdges(edges: NormalizedEdge[]): NormalizedEdge[] { + const seen = new Set(); + return edges.filter((edge) => { + const key = `${edge.kind}:${edge.source}:${edge.target}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +export function digestFile(path: string): string { + try { + return `sha256:${createHash("sha256").update(readFileSync(path)).digest("hex")}`; + } catch (error) { + throw new Error(`CodeGraph binary is unreadable: ${error instanceof Error ? error.message : String(error)}`); + } +} + +export function runCodeGraphCli(binary: string, workspaceRoot: string, args: string[], maxBuffer = CODEGRAPH_OUTPUT_MAX_BYTES, envOverrides?: Record): string { + disableCodeGraphTelemetryByDefault(); + const invocation = codeGraphCliInvocation(binary, workspaceRoot); + try { + return execFileSync(invocation.command, [...invocation.argsPrefix, ...args], { + cwd: workspaceRoot, + env: codeGraphChildEnv(envOverrides), + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + maxBuffer, + 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}`); + } +} + +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/src/import-population.ts b/packages/local-runtime/codegraph-adapter/src/import-population.ts new file mode 100644 index 00000000..c43b71e3 --- /dev/null +++ b/packages/local-runtime/codegraph-adapter/src/import-population.ts @@ -0,0 +1,281 @@ +import { posix } from "node:path"; +import type { CapabilitySourceFootprint, CapabilityImportEdge, CapabilityImportGraph } from "@archcontext/core/projection-engine"; +import type { NormalizedEdge } from "@archcontext/contracts"; +import { CODEGRAPH_OUTPUT_MAX_BYTES, CODEGRAPH_QUERY_TIMEOUT_MS, codeGraphCliInvocation, runCodeGraphCli, type CodeGraphCliNode } from "./cli-provider"; +import { readProjectionCodeGraphStatus, type CodeGraphProjectionStatusV1 } from "./projection-handshake"; +import { codeGraphIndexAvailable } from "./repository-code-facts"; + +const SOURCE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".json"]; + +export function importQuery(changedPaths: string[]): string | undefined { + const scoped = normalizeChangedPaths(changedPaths); + if (scoped.length === 0) return undefined; + return uniqueStrings(["import", ...scoped]).join(" "); +} + +export function importEdgesFromQueryNodes(gitVisibleFiles: ReadonlySet, nodes: CodeGraphCliNode[]): NormalizedEdge[] { + return resolvedImportPairs(gitVisibleFiles, nodes).map((pair) => ({ + source: fileSymbolId(pair.from), + target: fileSymbolId(pair.to), + kind: "imports" as const, + confidence: "high" as const + })); +} + +/** + * Import nodes whose specifier resolved to a Git-visible file. A specifier the resolver cannot pin + * to such a file (bare package name, workspace alias, missing or ignored target) yields no pair: + * the caller gets fewer edges, never a guessed one. + */ +function resolvedImportPairs(gitVisibleFiles: ReadonlySet, nodes: CodeGraphCliNode[]): { from: string; to: string }[] { + return nodes + .filter((node) => node.kind === "import") + .flatMap((node) => { + const target = resolveImportTarget(gitVisibleFiles, node.filePath, node.name); + return target ? [{ from: node.filePath, to: target }] : []; + }); +} + +/** + * Resolves a relative specifier against the Git-visible files (`listGitVisibleFiles`), never the + * working tree, so an untracked-but-ignored file cannot change a result between two checkouts of + * one commit. + */ +export function resolveImportTarget(gitVisibleFiles: ReadonlySet, fromPath: string, specifier: string): string | undefined { + if (!specifier.startsWith(".")) return undefined; + const base = posix.normalize(posix.join(posix.dirname(fromPath), specifier)); + if (base === "." || base.startsWith("../") || posix.isAbsolute(base)) return undefined; + return importTargetCandidates(base).find((candidate) => gitVisibleFiles.has(candidate)); +} + +function importTargetCandidates(base: string): string[] { + return [ + base, + ...SOURCE_EXTENSIONS.map((extension) => `${base}${extension}`), + ...SOURCE_EXTENSIONS.map((extension) => posix.join(base, `index${extension}`)) + ]; +} + +function fileSymbolId(path: string): string { + return `file:${path}`; +} + +export function normalizeChangedPaths(paths: string[]): string[] { + return uniqueStrings(paths + .map((path) => path.trim().replaceAll("\\", "/")) + .filter((path) => path.length > 0 && !path.startsWith("/") && !path.includes("..")) + ).sort(); +} + +export function uniqueStrings(items: string[]): string[] { + return [...new Set(items)]; +} + +/** + * Default cap on the import nodes `repositoryImportPairs` and the docs projection read. The read + * is sized from the index's own import count, so this cap only bounds memory and time. One import + * node costs about 0.7 KiB of CLI output, so the default is about 35 MiB. + */ +export const CODEGRAPH_IMPORT_NODE_READ_LIMIT = 50_000; +/** Output allowance per import node, so a large read is not cut by the CLI output buffer. */ +const CODEGRAPH_IMPORT_NODE_OUTPUT_BYTES = 4 * 1024; + +export type ProjectionImportPopulationV1 = Omit; + +export interface RepositoryImportPairV1 { + from: string; + /** The specifier verbatim. Two different specifiers from one file are two observations. */ + specifier: string; + /** Repo-relative target, or `null` when this producer could not resolve the specifier. */ + to: string | null; + /** 1-based line of the first such import in `from`, when the index reported one. */ + line?: number; +} + +export interface RepositoryImportPairsV1 { + pairs: RepositoryImportPairV1[]; + /** + * True when the pairs are not the whole population for a reason other than the limit: no + * index answered, or the query returned a different number of import nodes than the index + * reported (the index changed during the read). + */ + truncated: boolean; + /** `ready` only when an index exists and reports no pending source changes. */ + availability: "ready" | "unavailable"; + /** The worktree the index attests to, or `null` when it attests to nothing usable. */ + indexedWorktreeDigest: string | null; + /** + * Import nodes in the index, from `codegraph status` (`nodesByKind.import`). `null` when no + * index answered, or when the status gave no count and the read stopped at the limit. + */ + importNodeCount: number | null; + /** The read limit this call applied. */ + importNodeLimit: number; + /** + * True when the index holds more import nodes than `importNodeLimit`. The pairs then come from + * the first `importNodeLimit` nodes. This is a configured limit, not an incomplete index, so + * `truncated` stays false. + */ + importNodeLimitExceeded: boolean; +} + +/** + * Every import edge in the repository as repo-relative pairs, plus the identity of the index they + * were read from. Unlike `capabilityImportGraphs` this keeps specifiers it could not resolve as + * `to: null` with the specifier retained, because a module snapshot has to report how much of its + * boundary is unresolved and a dropped edge is indistinguishable from an absent one. Bare + * workspace specifiers are deliberately left unresolved here: mapping `@scope/pkg/sub` through a + * package `exports` map is the builder's job, which keeps this adapter free of resolution rules + * and keeps the resolution itself unit-testable without an index. The two producers share + * `codeGraphImportPopulation` and `importPairsOf`; `capabilityImportGraphs` also returns these + * pairs as `importPopulation`, and keeps its resolved-only graph edge set, which the capability + * projection fixtures pin byte for byte. + * + * Freshness is producer-owned evidence, never a caller assertion: the returned + * `indexedWorktreeDigest` binds the edges to `worktreeDigest` only when the index reports itself + * clean, so a consumer cannot claim complete coverage over an index that never saw this tree. + * + * `limit` is a safety cap, not a page size. The read asks the index for its import count first + * and then reads that whole population in one query, so a complete index gives complete pairs up + * to `limit` import nodes. Above it, `importNodeLimitExceeded` is true and `truncated` stays false. + */ +export function repositoryImportPairs( + root: string, + binary: string, + limit: number, + worktreeDigest: string, + /** The Git-visible files (`listGitVisibleFiles`) that `worktreeDigest` was computed over. */ + sourceFiles: readonly string[] +): RepositoryImportPairsV1 { + if (!Number.isInteger(limit) || limit < 1) throw new Error(`import node limit must be a positive integer, got ${limit}`); + if (!codeGraphIndexAvailable(root)) { + return { + pairs: [], + truncated: true, + availability: "unavailable", + indexedWorktreeDigest: null, + importNodeCount: null, + importNodeLimit: limit, + importNodeLimitExceeded: false + }; + } + const status = readProjectionCodeGraphStatus(codeGraphCliInvocation(binary, root, ""), root, CODEGRAPH_QUERY_TIMEOUT_MS); + const nodes = codeGraphImportPopulation(root, binary, limit, statusImportNodeCount(status)); + const pending = status.pendingChanges; + const clean = status.initialized && pending !== undefined && pending.added === 0 && pending.modified === 0 && pending.removed === 0; + return { + pairs: importPairsOf(new Set(sourceFiles), nodes.imports), + truncated: nodes.truncated, + availability: clean ? "ready" : "unavailable", + indexedWorktreeDigest: clean ? worktreeDigest : null, + importNodeCount: nodes.count, + importNodeLimit: limit, + importNodeLimitExceeded: nodes.limitExceeded + }; +} + +/** Import nodes as repo-relative pairs, unresolved specifiers kept as `to: null`, sorted by file then specifier. */ +function importPairsOf(gitVisibleFiles: ReadonlySet, imports: CodeGraphCliNode[]): RepositoryImportPairV1[] { + const pairs = new Map(); + for (const node of imports) { + if (node.kind !== "import") continue; + const to = resolveImportTarget(gitVisibleFiles, node.filePath, node.name) ?? null; + // Keyed on (file, specifier) so three distinct unresolved specifiers stay three records. A + // specifier imported twice from one file is one edge, located at its first import line. + const key = JSON.stringify([node.filePath, node.name]); + const line = Number.isInteger(node.startLine) && node.startLine! >= 1 ? node.startLine! : undefined; + const previousLine = pairs.get(key)?.line; + const firstLine = line === undefined ? previousLine : previousLine === undefined ? line : Math.min(line, previousLine); + pairs.set(key, { from: node.filePath, specifier: node.name, to, ...(firstLine === undefined ? {} : { line: firstLine }) }); + } + return [...pairs.keys()].sort().map((key) => pairs.get(key)!); +} + +/** + * The import population the index reports, or `null` when the status gives no `import` count. + * A missing `import` kind is unknown, not zero: a CodeGraph that names the kind differently must + * fall back to the limit-sized read, not to a one-node read that is always short. + */ +export function statusImportNodeCount(status: CodeGraphProjectionStatusV1): number | null { + if (!status.nodesByKind || typeof status.nodesByKind !== "object") return null; + const count = status.nodesByKind.import; + return Number.isInteger(count) && count! >= 0 ? count! : null; +} + +/** + * Reads the whole import population in one query. `codegraph query` has no offset, so the query + * limit is the count the index reported, plus one: a reply longer or shorter than that count + * means the index changed during the read, which is real truncation. Without a reported count, + * the read asks for `limit + 1` and reports the reply size as the count. + */ +function codeGraphImportPopulation( + root: string, + binary: string, + limit: number, + reportedCount: number | null, + envOverrides?: Record +): { imports: CodeGraphCliNode[]; count: number | null; truncated: boolean; limitExceeded: boolean } { + const queryLimit = Math.min(reportedCount ?? limit, limit) + 1; + const output = runCodeGraphCli( + binary, + root, + ["query", "-p", root, "-j", "-l", String(queryLimit), "-k", "import", "import"], + Math.max(CODEGRAPH_OUTPUT_MAX_BYTES, queryLimit * CODEGRAPH_IMPORT_NODE_OUTPUT_BYTES), + envOverrides + ); + const imports = (JSON.parse(output) as { node: CodeGraphCliNode }[]).map(({ node }) => node); + const limitExceeded = (reportedCount ?? imports.length) > limit; + if (limitExceeded) { + // The cap explains a reply of exactly `limit` nodes. A shorter reply means the index shrank + // during the read, which is real truncation on top of the limit. + return { imports: imports.slice(0, limit), count: reportedCount, truncated: imports.length < limit, limitExceeded }; + } + return { + imports, + count: reportedCount ?? imports.length, + truncated: reportedCount !== null && imports.length !== reportedCount, + limitExceeded + }; +} + + +export function capabilityImportGraphs( + root: string, + binary: string, + gitVisibleFiles: ReadonlySet, + footprints: readonly CapabilitySourceFootprint[], + limit: number, + reportedCount: number | null, + envOverrides?: Record +): { importGraphs: CapabilityImportGraph[]; importPopulation: ProjectionImportPopulationV1 } { + if (!Number.isInteger(limit) || limit < 1) throw new Error(`import node limit must be a positive integer, got ${limit}`); + const nodes = codeGraphImportPopulation(root, binary, limit, reportedCount, envOverrides); + const population = importPairsOf(gitVisibleFiles, nodes.imports); + // The graphs keep their resolved-only edge set, which the capability projection fixtures pin. + const resolved = population.flatMap((pair) => pair.to === null ? [] : [{ from: pair.from, to: pair.to }]); + const importGraphs = footprints.map((footprint) => { + const scope = new Set(footprint.files); + const edges = new Map(); + for (const pair of resolved) { + if (!scope.has(pair.from)) continue; + edges.set(`${pair.from} ${pair.to}`, pair); + } + return { + nodeId: footprint.nodeId, + files: footprint.files, + edges: [...edges.values()].sort((left, right) => left.from.localeCompare(right.from) || left.to.localeCompare(right.to)), + truncated: nodes.truncated, + ...(nodes.limitExceeded ? { importNodeLimitExceeded: true as const } : {}) + }; + }); + return { + importGraphs, + importPopulation: { + pairs: population, + truncated: nodes.truncated, + importNodeCount: nodes.count, + importNodeLimit: limit, + importNodeLimitExceeded: nodes.limitExceeded + } + }; +} diff --git a/packages/local-runtime/codegraph-adapter/src/index.ts b/packages/local-runtime/codegraph-adapter/src/index.ts index e816c1c3..47145aaf 100644 --- a/packages/local-runtime/codegraph-adapter/src/index.ts +++ b/packages/local-runtime/codegraph-adapter/src/index.ts @@ -1,44 +1,9 @@ -import { assertLocalEgressAllowed, localEgressMode } from "@archcontext/local-runtime/egress-admission"; -import { CODE_FACTS_ISSUE_MESSAGES, FailureError } from "@archcontext/contracts"; -import { execFileSync, spawnSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { accessSync, closeSync, constants as fsConstants, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { createRequire } from "node:module"; -import { basename, delimiter, dirname, isAbsolute, join, posix, relative, resolve } from "node:path"; -import type { Edge as CodeGraphEdge, Node as CodeGraphNode } from "@colbymchenry/codegraph"; -import { codeGraphChildEnv } from "./child-env"; -import { - REQUIRED_CODEGRAPH_PACKAGE, - REQUIRED_CODEGRAPH_VERSION, - codeGraphBundle, - codeGraphIndexBuildInProgress, - CodeGraphProviderUnavailableError, - pinnedCodeGraphProvider, - withCodeGraphReadSession, - type CodeGraphReadProvider, - type CodeGraphSdk -} from "./read-session"; -import { readSnapshotQueries, type SnapshotQueryRequest, type SnapshotQueryReader } from "./snapshot-query"; +import { REQUIRED_CODEGRAPH_PACKAGE, REQUIRED_CODEGRAPH_VERSION } from "./read-session"; import { buildArchitectureCandidateDelta, type ArchitectureDeltaDeclaredGraph, type ArchitectureDeltaGitChangeMetadata } from "@archcontext/core/architecture-delta"; -import { parseJsonOrStableYaml, repoScopedArchitectureId, type CrossRepoRelation } from "@archcontext/core/architecture-domain"; -import { listGitVisibleFiles } from "@archcontext/local-runtime/git-adapter"; -import { - ARCHITECTURE_DOCS_LAYOUT_VERSION, - ARCHITECTURE_DOCS_RENDERER_VERSION, - architectureDocumentationProjectionProvenance, - architectureDocumentationProjectionWorktreeDigest, - digestArchitectureDocumentationSourceTree, - loadCapabilitySourceFootprints, - nativeNodeSource, - type ArchitectureSelectorEvidenceV1, - type CapabilitySourceFootprint, - type CapabilityImportEdge, - type CapabilityImportGraph, - type ArchitectureDocumentationProjectionRuntimeSnapshot, - type NativeModel -} from "@archcontext/core/projection-engine"; -import { digestJson, type ArchitectureCandidateDeltaV1, type ArchitectureDocumentationProjectionProvenanceV3, type ArchitectureRepositoryIdentityV1, type ArchitectureWorktreeIdentityV1, type CodeFactsPort, type CodeFactsSnapshot, type ImpactQuery, type Json, type NormalizedCodeContext, type NormalizedEdge, type NormalizedImpact, type NormalizedSymbol, type ObservedEvidence, type SourceSelector, type SymbolQuery, type WorkspaceRef } from "@archcontext/contracts"; +import { repoScopedArchitectureId, type CrossRepoRelation } from "@archcontext/core/architecture-domain"; +import { digestJson, type ArchitectureCandidateDeltaV1, type ArchitectureRepositoryIdentityV1, type ArchitectureWorktreeIdentityV1, type CodeFactsPort, type CodeFactsSnapshot, type ImpactQuery, type NormalizedCodeContext, type NormalizedEdge, type NormalizedImpact, type NormalizedSymbol, type ObservedEvidence, type SourceSelector, type SymbolQuery, type WorkspaceRef } from "@archcontext/contracts"; +import { disableCodeGraphTelemetryByDefault, type CodeGraphProvider } from "./cli-provider"; +import { normalizeChangedPaths, uniqueStrings } from "./import-population"; export { CODEGRAPH_PROVIDER_INTERNAL_EXPORTS, @@ -55,300 +20,65 @@ export { type CodeGraphReadSession, type CodeGraphReadSessionOpenV1 } from "./read-session"; -export const CODEGRAPH_TELEMETRY_ENV = "DO_NOT_TRACK"; -export const CODEGRAPH_TELEMETRY_DISABLED_VALUE = "1"; -const DEFAULT_CODEGRAPH_BINARY = "codegraph"; -const requireFromAdapter = createRequire(import.meta.url); -// The SDK stays in its installed package so its platform/cache resolver stays relocatable. It is -// resolved only when a CodeGraph operation needs it; the stateless native export never loads it. -function codeGraphSdk(): CodeGraphSdk { - return codeGraphBundle().sdk; -} - -/** - * The SDK and its Node executable must come from the same provider-selected platform bundle. - * Only the private snapshot reader uses it; S4 of #574 removes both. - */ -export function codeGraphSnapshotRuntime(): { node: string; sdk: string } { - let bundle: ReturnType; - let version: string; - try { - bundle = codeGraphBundle(); - version = pinnedCodeGraphProvider().version; - } catch { - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable"); - } - if (version !== REQUIRED_CODEGRAPH_VERSION) throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable"); - const node = join(bundle.platformRoot, process.platform === "win32" ? "node.exe" : "node"); - if (!isExecutablePath(node)) throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable"); - return { node, sdk: join(bundle.dist, "index.js") }; -} - -function codeGraphSourceScanner(): (root: string) => string[] { - try { return pinnedCodeGraphProvider().scanSourceFiles; } - catch { throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable"); } -} - -type MutableEnv = Record; -const CODEGRAPH_OUTPUT_MAX_BYTES = 32 * 1024 * 1024; -const CODEGRAPH_QUERY_TIMEOUT_MS = 30_000; - -export function disableCodeGraphTelemetryByDefault(env: MutableEnv = process.env): string { - localEgressMode(env); - if (env[CODEGRAPH_TELEMETRY_ENV] !== undefined && env[CODEGRAPH_TELEMETRY_ENV] !== CODEGRAPH_TELEMETRY_DISABLED_VALUE) assertLocalEgressAllowed("codegraph-telemetry", env); - env[CODEGRAPH_TELEMETRY_ENV] ??= CODEGRAPH_TELEMETRY_DISABLED_VALUE; - return env[CODEGRAPH_TELEMETRY_ENV] ?? CODEGRAPH_TELEMETRY_DISABLED_VALUE; -} - -export interface CodeGraphProvider { - version: string; - capabilities: string[]; - assertReady?(workspaceRoot: string): void; - assertCanSync?(workspaceRoot: string): void; - indexAll(workspaceRoot: string): Promise; - buildContext(task: string, options: { maxSymbols: number; includeSource: boolean; changedPaths?: string[] }): Promise; - findSymbols(query: SymbolQuery): Promise; - getImpactRadius(symbolId: string, depth: number): Promise; -} - -export class CodeGraphCliProvider implements CodeGraphProvider { - version: string = REQUIRED_CODEGRAPH_VERSION; - capabilities = ["index", "context", "impact"]; - private workspaceRoot: string; - - /** - * `binary` builds the index (`sync`); reads never start it. `readProvider` is the read-only - * provider readiness reads through, the pinned one unless a test injects one. - */ - constructor(workspaceRoot = process.cwd(), private readonly binary = DEFAULT_CODEGRAPH_BINARY, private readonly readProvider?: CodeGraphReadProvider) { - this.workspaceRoot = workspaceRoot; - } - - async indexAll(workspaceRoot: string): Promise { - this.workspaceRoot = workspaceRoot; - this.assertCanSync(workspaceRoot); - this.run(["sync", workspaceRoot]); - this.assertReady(workspaceRoot); - } - - assertReady(workspaceRoot: string): void { - assertRepositoryCodeFactsReady(workspaceRoot, this.readProvider ? { provider: this.readProvider } : {}); - } - - assertCanSync(workspaceRoot: string): void { - this.assertReady(workspaceRoot); - } - - 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 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)); - return { - task, - symbols: fallback, - edges, - evidence: fallback.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}`, - confidence: "observed", - snapshot: { - repositoryId: "codegraph", - headSha: "codegraph", - worktreeDigest: digestJson({ task, path: symbol.path, output } as unknown as Json) - } - })), - digest: digestJson({ task, symbols: fallback, edges, includeSource: options.includeSource, outputDigest: digestJson(output as unknown as Json) } as unknown as Json) - }; - } - - 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"]); - } - - 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) - }; - } - - 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}`); - } - } -} - -export interface CodeGraphCliInvocation { - command: string; - argsPrefix: string[]; -} - -/** Projection-runtime resolution is deliberately package-local; PATH is not an authority. */ -export function packagedCodeGraphCliInvocation(): CodeGraphCliInvocation { - const packagedShim = resolvePackagedCodeGraphShim(); - if (!packagedShim) { - throw new Error(`CodeGraph package-local ${REQUIRED_CODEGRAPH_VERSION} binary is unavailable`); - } - return { command: process.execPath, argsPrefix: [packagedShim] }; -} - -export function codeGraphCliInvocation(binary: string, cwd: string, pathValue = process.env.PATH ?? ""): CodeGraphCliInvocation { - const resolved = resolveExecutable(binary, cwd, pathValue); - if (resolved && isNodeRuntimeScript(resolved)) { - return { command: process.execPath, argsPrefix: [resolved] }; - } - if (!resolved && binary === DEFAULT_CODEGRAPH_BINARY) { - const packagedShim = resolvePackagedCodeGraphShim(); - if (packagedShim) return { command: process.execPath, argsPrefix: [packagedShim] }; - } - return { command: binary, argsPrefix: [] }; -} - -function resolveExecutable(binary: string, cwd: string, pathValue: string): string | undefined { - if (binary.includes("/") || binary.includes("\\")) { - const path = isAbsolute(binary) ? binary : join(cwd, binary); - return existsSync(path) ? realpathSync.native(path) : undefined; - } - for (const dir of pathValue.split(delimiter)) { - if (!dir) continue; - const path = join(dir, binary); - if (isExecutablePath(path)) return realpathSync.native(path); - } - return undefined; -} - -function isExecutablePath(path: string): boolean { - try { - if (!statSync(path).isFile()) return false; - if (process.platform !== "win32") accessSync(path, fsConstants.X_OK); - return true; - } catch { - return false; - } -} - -function resolvePackagedCodeGraphShim(): string | undefined { - try { - const manifestPath = requireFromAdapter.resolve(`${REQUIRED_CODEGRAPH_PACKAGE}/package.json`); - const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { - version?: string; - bin?: Record; - }; - const relativeBin = manifest.bin?.codegraph; - if (manifest.version !== REQUIRED_CODEGRAPH_VERSION || !relativeBin) return undefined; - const shimPath = resolve(dirname(manifestPath), relativeBin); - return existsSync(shimPath) ? realpathSync.native(shimPath) : undefined; - } catch { - return undefined; - } -} - -/** - * Binds the projection receipt to the executable platform bundle, not only to the npm launcher. - * CodeGraph's public package is a thin shim which execs a platform package containing its own - * Node runtime and CLI entrypoint, so all of those executable layers are part of the authority. - */ -function packagedCodeGraphRuntimeDigest(): string { - try { - const manifestPath = requireFromAdapter.resolve(`${REQUIRED_CODEGRAPH_PACKAGE}/package.json`); - const packageRequire = createRequire(manifestPath); - const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { - version?: string; - bin?: Record; - }; - if (manifest.version !== REQUIRED_CODEGRAPH_VERSION || !manifest.bin?.codegraph) { - throw new Error(`package manifest is not exact ${REQUIRED_CODEGRAPH_VERSION}`); - } - const platformPackage = `${REQUIRED_CODEGRAPH_PACKAGE}-${process.platform}-${process.arch}`; - const platformManifestPath = packageRequire.resolve(`${platformPackage}/package.json`); - const platformManifest = JSON.parse(readFileSync(platformManifestPath, "utf8")) as { version?: string }; - if (platformManifest.version !== REQUIRED_CODEGRAPH_VERSION) { - throw new Error(`platform bundle is not exact ${REQUIRED_CODEGRAPH_VERSION}`); - } - const packageRoot = dirname(manifestPath); - const platformRoot = dirname(platformManifestPath); - const runtimeFiles = process.platform === "win32" - ? ["node.exe", "lib/dist/bin/codegraph.js"] - : ["bin/codegraph", "node", "lib/dist/bin/codegraph.js"]; - return digestJson({ - packageName: REQUIRED_CODEGRAPH_PACKAGE, - packageVersion: REQUIRED_CODEGRAPH_VERSION, - packageManifestDigest: digestFile(manifestPath), - launcherDigest: digestFile(resolve(packageRoot, manifest.bin.codegraph)), - platformPackage, - platformManifestDigest: digestFile(platformManifestPath), - runtimeFileDigests: runtimeFiles.map((path) => ({ path, digest: digestFile(resolve(platformRoot, path)) })) - } as unknown as Json); - } catch (error) { - throw new Error(`CodeGraph package-local runtime is unreadable: ${error instanceof Error ? error.message : String(error)}`); - } -} - -function isNodeRuntimeScript(path: string): boolean { - if (/\.[cm]?js$/i.test(path)) return true; - const prefix = readFilePrefix(path, 256); - return prefix.startsWith("#!/usr/bin/env node") - || prefix.startsWith("#!/usr/bin/env bun") - || prefix.startsWith("#!/usr/bin/node") - || prefix.startsWith("#!/usr/local/bin/node"); -} - -function readFilePrefix(path: string, length: number): string { - let fd: number | undefined; - try { - fd = openSync(path, "r"); - const buffer = Buffer.alloc(length); - const bytes = readSync(fd, buffer, 0, length, 0); - return buffer.subarray(0, bytes).toString("utf8"); - } catch { - return ""; - } finally { - if (fd !== undefined) closeSync(fd); - } -} +export { + CODEGRAPH_TELEMETRY_DISABLED_VALUE, + CODEGRAPH_TELEMETRY_ENV, + CodeGraphCliProvider, + codeGraphCliInvocation, + disableCodeGraphTelemetryByDefault, + packagedCodeGraphCliInvocation, + type CodeGraphCliInvocation, + type CodeGraphProvider +} from "./cli-provider"; +export { + assertRepositoryCodeFactsReady, + codeGraphIndexAvailable, + inspectRepositoryCodeFacts, + type RepositoryCodeFactsInspectionOptions, + type RepositoryCodeFactsPendingChangesV1, + type RepositoryCodeFactsV1 +} from "./repository-code-facts"; +export { + ProjectionCodeFactsUnavailableError, + assertProjectionCodeFactsAvailable, + prepareArchitectureDocumentationProjectionSnapshot, + prepareProjectionCodeFacts, + projectionCodeFactsRequired, + projectionCodeGraphStatusIssues, + type CodeGraphProjectionHandshakeV1, + type CodeGraphProjectionStatusV1, + type PreparedArchitectureDocumentationProjectionSnapshot, + type PreparedProjectionCodeFacts, + type ProjectionCodeGraphStatusIssueCode, + type ProjectionCodeGraphStatusIssueV1 +} from "./projection-handshake"; +export { + CODEGRAPH_SNAPSHOT_MAX_BYTES, + CODEGRAPH_SNAPSHOT_MAX_ENTRIES, + codeGraphSnapshotQueries, + codeGraphSnapshotRuntime, + withExistingCodeGraphSnapshot +} from "./snapshot"; +export { + CODEGRAPH_IMPORT_NODE_READ_LIMIT, + repositoryImportPairs, + type ProjectionImportPopulationV1, + type RepositoryImportPairV1, + type RepositoryImportPairsV1 +} from "./import-population"; +export { + RUNTIME_BUILTIN_CALL_NAMES, + loadCapabilityCodeGraphProjectionInputs, + memoizedSourceDeclarations, + openCodeGraphSymbolReader, + type CapabilityCodeGraphProjectionInputs, + type CodeGraphCallee, + type CodeGraphIndexedDeclaration, + type CodeGraphSelectorIndex, + type CodeGraphSymbolDeclaration, + type CodeGraphSymbolReader, + type CodeGraphSymbolReaderResult +} from "./selector-index"; export class CodeGraphAdapter implements CodeFactsPort { #snapshot?: CodeFactsSnapshot; @@ -570,150 +300,6 @@ export function assertNoCodeGraphInternalPathAccess(path: string): void { } } -interface CodeGraphCliNode { - id?: string; - kind: string; - name: string; - filePath: string; - startLine?: number; - endLine?: number; -} - -const SOURCE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".json"]; - -function syntheticSymbolId(name: string, path: string): string { - return `codegraph.${digestJson({ name, path } as unknown as Json).slice(7, 19)}`; -} - -function normalizeCliNode(node: CodeGraphCliNode): NormalizedSymbol { - return { - id: node.id ?? syntheticSymbolId(node.name, node.filePath), - name: node.name, - kind: node.kind, - path: node.filePath, - range: node.startLine ? { startLine: node.startLine, endLine: node.endLine ?? node.startLine } : undefined - }; -} - -function scopedTaskQuery(task: string, changedPaths: string[]): string { - return uniqueStrings([task, ...changedPaths]).join(" "); -} - -function importQuery(changedPaths: string[]): string | undefined { - const scoped = normalizeChangedPaths(changedPaths); - if (scoped.length === 0) return undefined; - return uniqueStrings(["import", ...scoped]).join(" "); -} - -function importEdgesFromQueryNodes(gitVisibleFiles: ReadonlySet, nodes: CodeGraphCliNode[]): NormalizedEdge[] { - return resolvedImportPairs(gitVisibleFiles, nodes).map((pair) => ({ - source: fileSymbolId(pair.from), - target: fileSymbolId(pair.to), - kind: "imports" as const, - confidence: "high" as const - })); -} - -/** - * Import nodes whose specifier resolved to a Git-visible file. A specifier the resolver cannot pin - * to such a file (bare package name, workspace alias, missing or ignored target) yields no pair: - * the caller gets fewer edges, never a guessed one. - */ -function resolvedImportPairs(gitVisibleFiles: ReadonlySet, nodes: CodeGraphCliNode[]): { from: string; to: string }[] { - return nodes - .filter((node) => node.kind === "import") - .flatMap((node) => { - const target = resolveImportTarget(gitVisibleFiles, node.filePath, node.name); - return target ? [{ from: node.filePath, to: target }] : []; - }); -} - -/** - * Resolves a relative specifier against the Git-visible files (`listGitVisibleFiles`), never the - * working tree, so an untracked-but-ignored file cannot change a result between two checkouts of - * one commit. - */ -function resolveImportTarget(gitVisibleFiles: ReadonlySet, fromPath: string, specifier: string): string | undefined { - if (!specifier.startsWith(".")) return undefined; - const base = posix.normalize(posix.join(posix.dirname(fromPath), specifier)); - if (base === "." || base.startsWith("../") || posix.isAbsolute(base)) return undefined; - return importTargetCandidates(base).find((candidate) => gitVisibleFiles.has(candidate)); -} - -function importTargetCandidates(base: string): string[] { - return [ - base, - ...SOURCE_EXTENSIONS.map((extension) => `${base}${extension}`), - ...SOURCE_EXTENSIONS.map((extension) => posix.join(base, `index${extension}`)) - ]; -} - -function fileSymbolId(path: string): string { - return `file:${path}`; -} - -// 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 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]) }]; - }); -} - -function uniqueSymbols(symbols: NormalizedSymbol[]): NormalizedSymbol[] { - const seen = new Set(); - return symbols.filter((symbol) => { - const key = `${symbol.id}:${symbol.path}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); -} - -function uniqueEdges(edges: NormalizedEdge[]): NormalizedEdge[] { - const seen = new Set(); - return edges.filter((edge) => { - const key = `${edge.kind}:${edge.source}:${edge.target}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); -} - -function normalizeChangedPaths(paths: string[]): string[] { - return uniqueStrings(paths - .map((path) => path.trim().replaceAll("\\", "/")) - .filter((path) => path.length > 0 && !path.startsWith("/") && !path.includes("..")) - ).sort(); -} - -function uniqueStrings(items: string[]): string[] { - return [...new Set(items)]; -} - function architectureDeltaTask(git: ArchitectureDeltaGitChangeMetadata): string { return uniqueStrings([ "architecture delta", @@ -721,1506 +307,3 @@ function architectureDeltaTask(git: ArchitectureDeltaGitChangeMetadata): string ...git.paths.flatMap((change) => [change.path, change.previousPath ?? ""]) ].filter(Boolean)).join(" "); } - -// --- Capability documentation projection measurements and exact semantic bindings --- -// -// A thin read-only query surface over the same CodeGraph CLI the provider uses. It exists so the -// documentation projection stays a pure function in @archcontext/core: the child process lives -// here, the renderer only receives measured facts. Import graphs remain diagnostic measurements; -// exact node-v2 selectors are the only CodeGraph facts that can bind a semantic flow step. - -/** - * Default cap on the import nodes `repositoryImportPairs` and the docs projection read. The read - * is sized from the index's own import count, so this cap only bounds memory and time. One import - * node costs about 0.7 KiB of CLI output, so the default is about 35 MiB. - */ -export const CODEGRAPH_IMPORT_NODE_READ_LIMIT = 50_000; -/** Output allowance per import node, so a large read is not cut by the CLI output buffer. */ -const CODEGRAPH_IMPORT_NODE_OUTPUT_BYTES = 4 * 1024; -export interface CapabilityCodeGraphProjectionInputs { - importGraphs: CapabilityImportGraph[]; - selectorEvidence: ArchitectureSelectorEvidenceV1[]; - /** - * The repository import population the import-graph read returned, with unresolved specifiers - * kept, exactly as `repositoryImportPairs` reports pairs. Present only when that read ran (an - * index exists and a node declares a footprint). Freshness is the handshake's, so it carries no - * availability of its own. Not part of `graphDigest`: it is the same read the graphs come from. - */ - importPopulation?: ProjectionImportPopulationV1; -} - -export type ProjectionImportPopulationV1 = Omit; - -export interface CodeGraphSelectorIndex { - getNodesByName(name: string): CodeGraphNode[]; - getOutgoingEdges(nodeId: string): CodeGraphEdge[]; - getNode(nodeId: string): CodeGraphNode | null; - /** Read only for a call to a runtime built-in name, to find the source file's imports. */ - getNodesInFile(filePath: string): CodeGraphNode[]; - close(): void; -} - -export interface CodeGraphProjectionStatusV1 { - initialized: boolean; - version: string; - projectPath: string; - lastIndexed: string | null; - fileCount?: number; - nodeCount?: number; - edgeCount?: number; - nodesByKind?: Record; - languages?: string[]; - pendingChanges?: { added: number; modified: number; removed: number }; - worktreeMismatch?: unknown; - index?: { - builtWithVersion?: string; - builtWithExtractionVersion?: number; - currentExtractionVersion?: number; - reindexRecommended?: boolean; - state?: string; - pendingRefs?: number; - }; -} - -export interface CodeGraphProjectionHandshakeV1 { - schemaVersion: "archcontext.codegraph-projection-handshake/v1"; - packageName: typeof REQUIRED_CODEGRAPH_PACKAGE; - requiredVersion: typeof REQUIRED_CODEGRAPH_VERSION; - actualVersion: string; - binaryDigest: string; - availability: "ready" | "unavailable"; - reasonCode?: "index-missing"; - preSyncStatusDigest: string | null; - postSyncStatusDigest: string | null; - syncDigest: string | null; - indexedWorktreeDigest: string | null; - /** - * Digest of the CodeGraph version and the code evidence this handshake read (import graphs, - * selector evidence). It excludes the binary digest and the index status, which differ per - * machine and per index build, so re-indexing an unchanged tree never moves it. - */ - graphDigest: string; -} - -export interface PreparedProjectionCodeFacts extends CapabilityCodeGraphProjectionInputs { - handshake: CodeGraphProjectionHandshakeV1; -} - -export interface PreparedArchitectureDocumentationProjectionSnapshot extends PreparedProjectionCodeFacts { - provenance: ArchitectureDocumentationProjectionProvenanceV3; - /** HEAD, worktree and CodeGraph state this snapshot read; runtime receipts only, never committed. */ - runtimeSnapshot: ArchitectureDocumentationProjectionRuntimeSnapshot; - /** - * The Git-visible files (`listGitVisibleFiles`) this snapshot measured footprints over. - * Callers stamp footprints and scale signals from this same list, so every digest of one run - * agrees on which files exist. - */ - sourceFiles: string[]; - /** - * `loadCapabilitySourceFootprints` over `sourceFiles`, resolved once for the whole snapshot. The - * source tree digest and the import graphs read it, and callers stamp footprint digests and scale - * signals from it, so one build matches the globs once (#324). - */ - footprints: CapabilitySourceFootprint[]; -} - -/** Single snapshot assembly shared by CLI, daemon completion gates, and verification tests. */ -export function prepareArchitectureDocumentationProjectionSnapshot( - root: string, - model: NativeModel, - options: { - binary?: string; - importNodeLimit?: number; - timeouts?: { versionMs?: number; statusMs?: number; syncMs?: number }; - /** The read-only provider the readiness gate reads through; the pinned one unless injected. */ - readProvider?: CodeGraphReadProvider; - } = {} -): PreparedArchitectureDocumentationProjectionSnapshot { - // Classify repository readiness with the same doctor evaluator the task commands use, before - // any code-facts read. A present but incompatible index is a typed refusal; pending-only - // freshness is accepted and cleared by the sync below. An absent index keeps its existing - // AC_CODE_FACTS_UNAVAILABLE contract. - const { readProvider, ...projectionOptions } = options; - if (codeGraphIndexAvailable(root)) assertRepositoryCodeFactsReady(root, readProvider ? { provider: readProvider } : {}); - const baseHeadSha = readProjectionHeadSha(root); - const sourceFiles = listGitVisibleFiles(root); - const worktreeDigest = architectureDocumentationProjectionWorktreeDigest(root, model, sourceFiles); - const footprints = loadCapabilitySourceFootprints(root, model, sourceFiles); - const sourceTreeDigest = digestArchitectureDocumentationSourceTree(root, model, footprints); - const modelDigest = digestJson(model as unknown as Json); - const prepared = prepareProjectionCodeFacts(root, model, { ...projectionOptions, sourceTreeDigest, sourceFiles, footprints }); - if (architectureDocumentationProjectionWorktreeDigest(root, model, listGitVisibleFiles(root)) !== worktreeDigest) { - throw new Error("architecture-docs-projection-worktree-changed-during-codegraph-sync"); - } - const provenance = architectureDocumentationProjectionProvenance({ - sourceTreeDigest, - modelDigest, - rendererVersion: ARCHITECTURE_DOCS_RENDERER_VERSION, - layoutVersion: ARCHITECTURE_DOCS_LAYOUT_VERSION, - generatedFrom: { - codeGraphPackage: prepared.handshake.packageName, - codeGraphVersion: prepared.handshake.actualVersion - } - }); - return { - ...prepared, - provenance, - runtimeSnapshot: { - headSha: baseHeadSha, - worktreeDigest, - codeGraphDigest: prepared.handshake.graphDigest, - indexedWorktreeDigest: prepared.handshake.indexedWorktreeDigest, - codeGraphStatus: prepared.handshake.availability - }, - sourceFiles, - footprints - }; -} - -/** - * Thrown when `.archcontext/manifest.yaml` declares `codeFacts.required: true` and the projection - * snapshot has no code facts. Without them every selector is unproven, so the major-change - * classifier would report a flow-proof change for every capability; an environment failure must not - * read as an architecture change. Surfaces map it to `AC_CODE_FACTS_UNAVAILABLE`. - */ -export class ProjectionCodeFactsUnavailableError extends Error { - readonly code = "AC_CODE_FACTS_UNAVAILABLE" as const; - constructor(readonly reasonCode: NonNullable) { - super(`code facts are required by .archcontext/manifest.yaml but unavailable (${reasonCode}); run \`codegraph init\` and retry`); - this.name = "ProjectionCodeFactsUnavailableError"; - } -} - -/** `codeFacts.required` from `.archcontext/manifest.yaml`; only an explicit `true` requires them. */ -export function projectionCodeFactsRequired(root: string): boolean { - const path = join(root, ".archcontext/manifest.yaml"); - if (!existsSync(path)) return false; - let manifest: Json; - try { - manifest = parseJsonOrStableYaml(readFileSync(path, "utf8"), ".archcontext/manifest.yaml"); - } catch { - // Manifest shape is validated by `archctx validate`; an unreadable file declares nothing here. - return false; - } - const codeFacts = manifest && typeof manifest === "object" && !Array.isArray(manifest) ? manifest.codeFacts : undefined; - return !!codeFacts && typeof codeFacts === "object" && !Array.isArray(codeFacts) && codeFacts.required === true; -} - -/** - * Projection gate for required code facts. Runs before the snapshot reaches the renderer, so a - * missing index never classifies a major change, writes an acceptance candidate or emits a refresh - * signal. - */ -export function assertProjectionCodeFactsAvailable(root: string, prepared: PreparedProjectionCodeFacts): void { - if (prepared.handshake.availability === "ready" || !projectionCodeFactsRequired(root)) return; - throw new ProjectionCodeFactsUnavailableError(prepared.handshake.reasonCode ?? "index-missing"); -} - -/** - * Freezes the public CodeGraph proof used by one docs projection. The adapter never reads the - * `.codegraph` database: actual binary/version and both status snapshots come from the package-local - * CLI. A missing index is an explicit unavailable state; an existing but stale/incompatible index - * is an error and cannot produce verified facts. - */ -export function prepareProjectionCodeFacts( - root: string, - model: NativeModel, - options: { - sourceTreeDigest: string; - /** The Git-visible files (`listGitVisibleFiles`); read from `root` when omitted. */ - sourceFiles?: readonly string[]; - /** Footprints resolved over the Git-visible files; resolved from `root` when omitted. */ - footprints?: readonly CapabilitySourceFootprint[]; - binary?: string; - importNodeLimit?: number; - timeouts?: { versionMs?: number; statusMs?: number; syncMs?: number }; - /** `false` reads selector evidence only (`flows check`); a projection always reads both. */ - importGraphs?: boolean; - } -): PreparedProjectionCodeFacts { - const invocation = options.binary - ? codeGraphCliInvocation(options.binary, root, "") - : packagedCodeGraphCliInvocation(); - const binaryPath = invocation.argsPrefix[0] ?? invocation.command; - // `--version` starts the same launcher chain as `status`, so it gets the same stall budget. On - // Windows CI it takes 94 ms (p50) and 2.4 s cold; a runner stall once pushed it past 5 s. - const actualVersion = runProjectionCodeGraph(invocation, root, ["--version"], options.timeouts?.versionMs ?? 10_000).trim(); - if (actualVersion !== REQUIRED_CODEGRAPH_VERSION) { - throw new Error(`CodeGraph ${REQUIRED_CODEGRAPH_VERSION} required, got actual binary ${actualVersion || "unknown"}`); - } - const binaryDigest = options.binary ? digestFile(binaryPath) : packagedCodeGraphRuntimeDigest(); - if (!codeGraphIndexAvailable(root)) { - const graphDigest = digestJson({ - schemaVersion: "archcontext.codegraph-projection-handshake/v1", - actualVersion, - availability: "unavailable", - reasonCode: "index-missing" - } as unknown as Json); - return { - importGraphs: [], - selectorEvidence: [], - handshake: { - schemaVersion: "archcontext.codegraph-projection-handshake/v1", - packageName: REQUIRED_CODEGRAPH_PACKAGE, - requiredVersion: REQUIRED_CODEGRAPH_VERSION, - actualVersion, - binaryDigest, - availability: "unavailable", - reasonCode: "index-missing", - preSyncStatusDigest: null, - postSyncStatusDigest: null, - syncDigest: null, - indexedWorktreeDigest: null, - graphDigest - } - }; - } - - const statusTimeout = options.timeouts?.statusMs ?? 10_000; - const preStatus = readProjectionCodeGraphStatus(invocation, root, statusTimeout); - assertProjectionCodeGraphStatus(preStatus, root, "pre-sync"); - runProjectionCodeGraph(invocation, root, ["sync", root, "--quiet"], options.timeouts?.syncMs ?? 120_000); - const postStatus = readProjectionCodeGraphStatus(invocation, root, statusTimeout); - assertProjectionCodeGraphStatus(postStatus, root, "post-sync"); - assertProjectionCodeGraphClean(postStatus, "post-sync"); - const inputs = loadCapabilityCodeGraphProjectionInputs(root, model, { - ...(options.sourceFiles ? { sourceFiles: options.sourceFiles } : {}), - ...(options.footprints ? { footprints: options.footprints } : {}), - binary: binaryPath, - importNodeLimit: options.importNodeLimit, - importNodeCount: statusImportNodeCount(postStatus), - ...(options.importGraphs === false ? { importGraphs: false } : {}) - }); - const finalStatus = readProjectionCodeGraphStatus(invocation, root, statusTimeout); - assertProjectionCodeGraphStatus(finalStatus, root, "post-query"); - assertProjectionCodeGraphClean(finalStatus, "post-query"); - const postDigest = projectionCodeGraphStatusDigest(postStatus); - const finalDigest = projectionCodeGraphStatusDigest(finalStatus); - if (postDigest !== finalDigest) throw new Error("CodeGraph indexed snapshot changed during projection queries"); - const preDigest = projectionCodeGraphStatusDigest(preStatus); - const indexedWorktreeDigest = digestJson({ - sourceTreeDigest: options.sourceTreeDigest, - statusDigest: postDigest - } as unknown as Json); - const graphDigest = digestJson({ - actualVersion, - importGraphs: inputs.importGraphs, - selectorEvidence: inputs.selectorEvidence - } as unknown as Json); - return { - ...inputs, - handshake: { - schemaVersion: "archcontext.codegraph-projection-handshake/v1", - packageName: REQUIRED_CODEGRAPH_PACKAGE, - requiredVersion: REQUIRED_CODEGRAPH_VERSION, - actualVersion, - binaryDigest, - availability: "ready", - preSyncStatusDigest: preDigest, - postSyncStatusDigest: postDigest, - syncDigest: digestJson({ preSyncStatusDigest: preDigest, postSyncStatusDigest: postDigest } as unknown as Json), - indexedWorktreeDigest, - graphDigest - } - }; -} - -/** - * `.codegraph/codegraph.lock` is the provider's cross-process writer lock, held for a whole - * `codegraph init`, `index` or `sync` build. `codegraph status` is not a read of the live - * database: 1.6.2 opens it writable and replays crash recovery, recreating the secondary indexes - * and FTS triggers that a bulk build deliberately dropped. Those writes take SQLite write locks and - * starve the running build into `database is locked` (#426), so no probe may touch a database that - * is currently being built. - */ -class CodeGraphIndexBuildInProgressError extends Error { - constructor() { - super("CodeGraph index build is in progress"); - this.name = "CodeGraphIndexBuildInProgressError"; - } -} - -function readProjectionCodeGraphStatus( - invocation: CodeGraphCliInvocation, - root: string, - timeout: number, - envOverrides?: Record -): CodeGraphProjectionStatusV1 { - if (codeGraphIndexBuildInProgress(root)) throw new CodeGraphIndexBuildInProgressError(); - const output = runProjectionCodeGraph(invocation, root, ["status", "-j", root], timeout, envOverrides); - try { - return JSON.parse(output) as CodeGraphProjectionStatusV1; - } catch (error) { - throw new Error(`CodeGraph status JSON invalid: ${error instanceof Error ? error.message : String(error)}`); - } -} - -function readProjectionHeadSha(root: string): string { - try { - const headSha = execFileSync("git", ["rev-parse", "HEAD"], { - cwd: root, - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - timeout: 5_000 - }).trim(); - if (!/^[a-f0-9]{40}$/.test(headSha)) throw new Error("HEAD is not a full commit SHA"); - return headSha; - } catch (error) { - throw new Error(`Architecture projection HEAD is unreadable: ${error instanceof Error ? error.message : String(error)}`); - } -} - -export type ProjectionCodeGraphStatusIssueCode = - | "not-initialized" - | "version-mismatch" - | "project-path-mismatch" - | "index-incomplete" - | "extraction-version-mismatch" - | "worktree-mismatch" - | "pending-source-changes"; - -export interface ProjectionCodeGraphStatusIssueV1 { - code: ProjectionCodeGraphStatusIssueCode; - /** The exact text `prepareProjectionCodeFacts` throws for this issue. */ - message: string; -} - -/** - * Pure evaluation of one `codegraph status -j` snapshot against the projection contract. It - * returns every issue in evaluation order; an uninitialized index returns only that issue because - * the remaining fields carry no meaning then. - */ -export function projectionCodeGraphStatusIssues( - status: CodeGraphProjectionStatusV1, - root: string, - phase: string -): ProjectionCodeGraphStatusIssueV1[] { - const issue = (code: ProjectionCodeGraphStatusIssueCode, text: string): ProjectionCodeGraphStatusIssueV1 => ({ - code, - message: phase === "repository" ? CODE_FACTS_ISSUE_MESSAGES[code] : `CodeGraph ${phase} ${text}` - }); - if (!status.initialized) return [issue("not-initialized", "status is not initialized")]; - const issues: ProjectionCodeGraphStatusIssueV1[] = []; - if (status.version !== REQUIRED_CODEGRAPH_VERSION || status.index?.builtWithVersion !== REQUIRED_CODEGRAPH_VERSION) { - issues.push(issue("version-mismatch", "status version mismatch")); - } - if (!sameRealPath(status.projectPath, root)) issues.push(issue("project-path-mismatch", "project path mismatch")); - if (status.index?.state !== "complete" || status.index.reindexRecommended !== false || status.index.pendingRefs !== 0) { - issues.push(issue("index-incomplete", "index is not complete and compatible")); - } else if (status.index.builtWithExtractionVersion !== status.index.currentExtractionVersion) { - issues.push(issue("extraction-version-mismatch", "extraction version mismatch")); - } - if (status.worktreeMismatch !== null) issues.push(issue("worktree-mismatch", "worktree mismatch")); - return issues; -} - -function sameRealPath(left: unknown, right: string): boolean { - if (typeof left !== "string") return false; - try { - return realpathSync.native(left) === realpathSync.native(right); - } catch { - return false; - } -} - -function assertProjectionCodeGraphStatus(status: CodeGraphProjectionStatusV1, root: string, phase: string): void { - const [first] = projectionCodeGraphStatusIssues(status, root, phase); - if (first) throw new Error(first.message); -} - -function assertProjectionCodeGraphClean(status: CodeGraphProjectionStatusV1, phase: string): void { - const pending = status.pendingChanges; - if (!pending || pending.added !== 0 || pending.modified !== 0 || pending.removed !== 0) { - throw new Error(`CodeGraph ${phase} index still has pending source changes`); - } -} - -function projectionCodeGraphStatusDigest(status: CodeGraphProjectionStatusV1): string { - return digestJson({ - version: status.version, - projectPathDigest: digestJson(realpathSync.native(status.projectPath) as unknown as Json), - lastIndexed: status.lastIndexed, - fileCount: status.fileCount, - nodeCount: status.nodeCount, - edgeCount: status.edgeCount, - nodesByKind: status.nodesByKind, - languages: [...(status.languages ?? [])].sort(), - pendingChanges: status.pendingChanges, - worktreeMismatch: status.worktreeMismatch, - index: status.index - } as unknown as Json); -} - -function runProjectionCodeGraph( - invocation: CodeGraphCliInvocation, - root: string, - args: string[], - timeout: number, - envOverrides?: Record -): string { - disableCodeGraphTelemetryByDefault(); - const startedAt = performance.now(); - try { - return execFileSync(invocation.command, [...invocation.argsPrefix, ...args], { - cwd: root, - env: codeGraphChildEnv(envOverrides), - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - maxBuffer: CODEGRAPH_OUTPUT_MAX_BYTES, - timeout - }); - } 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)); - const childError = error as { code?: string | number; status?: number | null; signal?: string | null } | null; - // Only the fixed subcommand is recorded; arguments may contain repository paths. - const diagnostics = { - subcommand: args[0], - deadlineMs: timeout, - elapsedMs: Math.round(performance.now() - startedAt), - code: childError?.code ?? null, - exitCode: childError?.status ?? null, - signal: childError?.signal ?? null - }; - throw new Error(`CodeGraph projection handshake failed: ${message}\nCodeGraph handshake diagnostics: ${JSON.stringify(diagnostics)}`); - } -} - -function digestFile(path: string): string { - try { - return `sha256:${createHash("sha256").update(readFileSync(path)).digest("hex")}`; - } catch (error) { - throw new Error(`CodeGraph binary is unreadable: ${error instanceof Error ? error.message : String(error)}`); - } -} - - -/** Bytes bound the copied source/config, Git metadata, database and WAL; entries also count the uncopied Git objects. */ -export const CODEGRAPH_SNAPSHOT_MAX_BYTES = 512 * 1024 * 1024; -export const CODEGRAPH_SNAPSHOT_MAX_ENTRIES = 100_000; - -export function codeGraphSnapshotQueries(nodes: NativeModel["nodes"], purpose: "flow" | "ref" = "flow"): SnapshotQueryRequest { - const names = new Set(); - const sources = new Map(); - for (const node of nodes) for (const entry of nativeNodeSource(node)?.entrypoints ?? []) { - for (const symbol of entry.symbols) for (const sink of symbol.sinks) { - names.add(symbol.name); names.add(sink.symbol); - const path = normalizedIndexPath(entry.path); - sources.set(`${path}\0${symbol.name}`, { path, name: symbol.name }); - } - } - return { names: [...names].sort(), sources: purpose === "flow" ? [...sources.values()] : [], includeFiles: purpose === "ref" }; -} - -/** - * Runs synchronous readers against a private, ready CodeGraph snapshot. Provider status opens - * writable and SQLite readOnly can create WAL/SHM files, so neither ever sees the original root. - * The provider-enumerated filesystem mirror preserves source discovery and configuration rather - * than approximating its source extensions, ignores or content hashes. Nothing is re-indexed. - */ -export function withExistingCodeGraphSnapshot( - root: string, - queries: SnapshotQueryRequest, - callback: (graph: SnapshotQueryReader) => T -): T { - const originalRoot = realpathSync.native(root); - const redirectedGitInputs = ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CONFIG", "GIT_CONFIG_SYSTEM", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_COUNT", "GIT_CONFIG_PARAMETERS"]; - if (redirectedGitInputs.some(key => process.env[key]) || process.env.CODEGRAPH_DIR) { - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); - } - const indexDir = join(originalRoot, ".codegraph"); - if (!existsSync(join(indexDir, "codegraph.db"))) throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "index-missing"); - if (lstatSync(indexDir).isSymbolicLink()) throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); - const gitPath = (flag: string) => realpathSync.native(execFileSync("git", ["rev-parse", "--path-format=absolute", flag], { - cwd: originalRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 5_000 - }).trim()); - const gitDir = gitPath("--git-dir"); - const commonDir = gitPath("--git-common-dir"); - assertSnapshotGitReadSafety(originalRoot); - const temporaryParent = realpathSync.native(tmpdir()); - const temporaryRelative = relative(originalRoot, temporaryParent); - if (!temporaryRelative || (temporaryRelative !== ".." && !temporaryRelative.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) && !isAbsolute(temporaryRelative))) { - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); - } - const temporaryRoot = mkdtempSync(join(temporaryParent, "archctx-codegraph-snapshot-")); - try { - const snapshotRoot = join(temporaryRoot, "repository"); - mkdirSync(snapshotRoot); - const before = mirrorCodeGraphInputs(originalRoot, gitDir, commonDir, snapshotRoot); - if (before !== mirrorCodeGraphInputs(originalRoot, gitDir, commonDir)) { - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-changed"); - } - const scan = codeGraphSourceScanner(); - if (JSON.stringify([...scan(originalRoot)].sort()) !== JSON.stringify([...scan(snapshotRoot)].sort())) { - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); - } - const ready = inspectRepositoryCodeFacts(snapshotRoot); - if (ready.state !== "ready") { - // C2: an unopenable index keeps its own reason; any other unavailable answer is code-facts-unavailable. - if (ready.state === "unavailable") throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", ready.reasonCode === "index-open-failed" ? "index-open-failed" : "code-facts-unavailable"); - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-read-not-ready", { issues: ready.issues }); - } - // Readiness above opened the copied index in-process, through the read session, and closed it. - // The queries below still run in the provider's Node child, whose exit releases its SQLite - // handles before the copy is removed. S4 of #574 removes this path. - const graph = readSnapshotQueries(codeGraphSnapshotRuntime(), snapshotRoot, queries); - const result = callback(graph); - if (result !== null && typeof result === "object" && "then" in result) { - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); - } - if (before !== mirrorCodeGraphInputs(originalRoot, gitDir, commonDir)) { - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-changed"); - } - return result; - } catch (error) { - if (error instanceof FailureError) throw error; - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable"); - } finally { - rmSync(temporaryRoot, { recursive: true, force: true }); - } -} - -function assertSnapshotGitReadSafety(root: string): void { - const options = { cwd: root, stdio: "pipe" as const, timeout: 5_000, maxBuffer: CODEGRAPH_OUTPUT_MAX_BYTES }; - const config = spawnSync("git", ["config", "--get-regexp", "^(core\\.(worktree|fsmonitor)|include\\.|include[iI]f\\.)"], { ...options, encoding: "utf8" }); - if ((config.status !== 0 && config.status !== 1) || config.stdout.trim()) { - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); - } - // Provider readiness runs git status, which may execute clean/process filters on tracked - // content. Configuration alone (e.g. a system LFS installation) does not select a filter. - // Git owns attribute evaluation, including macros, nested attributes and info/attributes. - let attributes: string[]; - let ambiguousDrivers: Set; - try { - const tracked = execFileSync("git", ["ls-files", "--cached", "-z"], options); - if (tracked.length === 0) return; - const output = execFileSync("git", ["check-attr", "-z", "--stdin", "filter"], { ...options, input: tracked }).toString("utf8"); - attributes = output.split("\0"); - if (attributes.pop() !== "" || attributes.length % 3 !== 0) throw new Error("Invalid attribute response"); - // check-attr prints literal driver names "unset"/"unspecified" identically to its - // sentinels. A configured driver with either name makes that response ambiguous. - const drivers = spawnSync("git", ["config", "--name-only", "--get-regexp", "^filter\\.(unspecified|unset)\\.(clean|process)$"], { ...options, encoding: "utf8" }); - if (drivers.status !== 0 && drivers.status !== 1) throw new Error("Cannot inspect filter drivers"); - ambiguousDrivers = new Set(drivers.stdout.trim().split(/\r?\n/).filter(Boolean).map(key => key.split(".")[1]!)); - } catch { - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable"); - } - for (let index = 0; index < attributes.length; index += 3) { - if (attributes[index + 1] !== "filter" || !["unspecified", "unset"].includes(attributes[index + 2]!) || ambiguousDrivers.has(attributes[index + 2]!)) { - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); - } - } -} - -/** - * A repository-local core.hooksPath replaces any copied, global or system value, so provider Git - * operations (an index refresh runs post-index-change) cannot execute repository or user hooks. - * A higher-precedence source such as config.worktree would beat the pin, so the value Git will - * actually use must be exactly the pinned directory. - */ -function pinSnapshotHooks(snapshotRepository: string): void { - const gitDir = join(snapshotRepository, ".git"); - const hooks = join(gitDir, "archctx-empty-hooks"); - mkdirSync(hooks); - const path = process.platform === "win32" ? hooks.replaceAll("\\", "/") : hooks; - const options = { cwd: snapshotRepository, encoding: "utf8" as const, stdio: "pipe" as const, timeout: 5_000 }; - const pinned = spawnSync("git", ["config", "--file", join(gitDir, "config"), "--replace-all", "core.hooksPath", path], options); - const effective = spawnSync("git", ["config", "--get", "core.hooksPath"], options); - if (pinned.status !== 0 || effective.status !== 0 || effective.stdout.replace(/\r?\n$/, "") !== path) { - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); - } -} - -function mirrorCodeGraphInputs(root: string, gitDir: string, commonDir: string, destination?: string): string { - const hash = createHash("sha256"); - const measured = { sourceBytes: 0, sourceEntries: 0, gitBytes: 0, gitEntries: 0, gitObjectEntries: 0, indexBytes: 0, indexEntries: 0 }; - let bytes = 0; - let entries = 0; - // After the first cap trips nothing more is read or copied; the walk only completes the counts. - let limit: "bytes" | "entries" | undefined; - const fingerprint = (source: string, target: string) => { - const stat = lstatSync(source, { bigint: true }); - if (++entries > CODEGRAPH_SNAPSHOT_MAX_ENTRIES) limit ??= "entries"; - hash.update(JSON.stringify([source, target, String(stat.ino), String(stat.size), String(stat.mtimeNs), String(stat.ctimeNs)])); - return stat; - }; - const visit = (part: "source" | "git" | "index", source: string, target: string, skip: ReadonlySet = new Set()) => { - const stat = fingerprint(source, target); - measured[`${part}Entries`]++; - if (stat.isDirectory()) { - if (destination && !limit) mkdirSync(join(destination, target), { recursive: true }); - for (const name of readdirSync(source).sort()) { - if (!skip.has(name)) visit(part, join(source, name), join(target, name)); - } - } else if (stat.isFile()) { - if (basename(target) === ".git") throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); - measured[`${part}Bytes`] += Number(stat.size); - bytes += Number(stat.size); - if (bytes > CODEGRAPH_SNAPSHOT_MAX_BYTES) limit ??= "bytes"; - if (limit) return; - // Bound the actual read as well as the prior stat: a growing file must not bypass the cap. - const data = Buffer.alloc(Number(stat.size)); - const fd = openSync(source, "r"); - try { - let offset = 0; - while (offset < data.length) { - const count = readSync(fd, data, offset, data.length - offset, offset); - if (count === 0) throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-changed"); - offset += count; - } - if (readSync(fd, Buffer.alloc(1), 0, 1, offset) !== 0) throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-changed"); - } finally { closeSync(fd); } - hash.update(data); - if (destination) { - mkdirSync(dirname(join(destination, target)), { recursive: true }); - writeFileSync(join(destination, target), data, { mode: Number(stat.mode) & 0o777 }); - } - } else { - // Following a symlink can silently omit an external source/config population or let a - // provider write outside the mirror. Such layouts require a separate snapshot contract. - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); - } - }; - // Objects are fingerprinted, never read or copied: a write to the original store changes the - // fingerprint, and the copy grows with what the read needs rather than with history. - const visitObjects = (source: string, target: string) => { - const stat = fingerprint(source, target); - measured.gitObjectEntries++; - if (stat.isDirectory()) { - for (const name of readdirSync(source).sort()) visitObjects(join(source, name), join(target, name)); - } else if (!stat.isFile()) { - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); - } - }; - // The snapshot reads the original objects read-only through a snapshot-local alternate. - // An original alternates chain, or a path Git cannot store in that file, is a separate layout. - // Repository hooks are not part of the read; the snapshot pins its own empty hooks directory. - const visitRepository = (source: string, target: string, skip: ReadonlySet) => { - const objects = join(source, "objects"); - if (existsSync(join(objects, "info/alternates"))) throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); - visit("git", source, target, new Set([...skip, "objects", "hooks", "archctx-empty-hooks"])); - visitObjects(objects, join(target, "objects")); - const original = realpathSync.native(objects); - const alternate = process.platform === "win32" ? original.replaceAll("\\", "/") : original; - if (/[\r\n]/.test(alternate)) throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); - if (destination && !limit) { - mkdirSync(join(destination, target, "objects/pack"), { recursive: true }); - mkdirSync(join(destination, target, "objects/info"), { recursive: true }); - writeFileSync(join(destination, target, "objects/info/alternates"), `${alternate}\n`); - } - }; - const sourceFiles = codeGraphSourceScanner()(root); - const files = new Set([...sourceFiles, ...listGitVisibleFiles(root)]); - const directories = new Set(["."]); - const repositories = new Set(["."]); - for (const path of files) { - if (isAbsolute(path) || path.split(/[\\/]/).includes("..")) throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); - let directory = dirname(path); - while (directory !== ".") { directories.add(directory); directory = dirname(directory); } - } - for (const directory of directories) { - for (const name of [".gitignore", ".gitattributes", "codegraph.json"]) { - const path = join(directory, name); - if (existsSync(join(root, path))) files.add(path); - } - if (directory !== "." && existsSync(join(root, directory, ".git"))) { - visitRepository(join(root, directory, ".git"), join(directory, ".git"), new Set()); - assertSnapshotGitReadSafety(join(root, directory)); - repositories.add(directory); - } - } - hash.update(JSON.stringify([...sourceFiles].sort())); - for (const file of [...files].sort()) { - if (file.split(/[\\/]/).some(part => part === ".git" || part === ".codegraph")) { - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); - } - visit("source", join(root, file), file); - } - visitRepository(commonDir, ".git", new Set(["worktrees"])); - if (gitDir !== commonDir) visit("git", gitDir, ".git", new Set(["commondir", "gitdir"])); - for (const suffix of ["", "-wal"]) { - const source = join(root, ".codegraph", `codegraph.db${suffix}`); - hash.update(JSON.stringify([suffix, existsSync(source)])); - if (existsSync(source)) visit("index", source, join(".codegraph", `codegraph.db${suffix}`)); - } - if (limit) { - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-limit-exceeded", { - limit, maxBytes: CODEGRAPH_SNAPSHOT_MAX_BYTES, maxEntries: CODEGRAPH_SNAPSHOT_MAX_ENTRIES, ...measured - }); - } - // Recheck the exact copied attribute population before any provider status operation. - if (destination) for (const repository of repositories) { - pinSnapshotHooks(join(destination, repository)); - assertSnapshotGitReadSafety(join(destination, repository)); - } - return hash.digest("hex"); -} - -export interface RepositoryCodeFactsPendingChangesV1 { - added: number; - modified: number; - removed: number; -} - -/** - * Read-only state of this repository's CodeGraph index (ADR-0058). `unavailable` means the pinned - * CodeGraph provider could not load (`codegraph-unavailable`) or the index exists but could not be - * opened and read read-only (`index-open-failed`); no other field is then reported. `index-missing` - * means the provider loaded but the repository has no `.codegraph` index. `ready` and `not-ready` - * carry the index facts, and `issues` lists why `not-ready` is not ready. - */ -export type RepositoryCodeFactsV1 = - | { - schemaVersion: "archcontext.repository-code-facts/v1"; - state: "unavailable"; - requiredVersion: typeof REQUIRED_CODEGRAPH_VERSION; - reasonCode: "codegraph-unavailable" | "index-open-failed"; - message: string; - } - | { - schemaVersion: "archcontext.repository-code-facts/v1"; - state: "index-missing"; - indexPresent: false; - requiredVersion: typeof REQUIRED_CODEGRAPH_VERSION; - codeGraphVersion: string; - issues: ProjectionCodeGraphStatusIssueV1[]; - } - | { - schemaVersion: "archcontext.repository-code-facts/v1"; - state: "ready" | "not-ready"; - indexPresent: true; - requiredVersion: typeof REQUIRED_CODEGRAPH_VERSION; - codeGraphVersion: string; - indexBuiltWithVersion: string | null; - extractionVersion: { builtWith: number | null; current: number | null }; - indexState: string | null; - reindexRecommended: boolean | null; - pendingRefs: number | null; - /** Source changes the index has not synced yet; `null` when they were not measured. */ - pendingChanges: RepositoryCodeFactsPendingChangesV1 | null; - issues: ProjectionCodeGraphStatusIssueV1[]; - }; - -/** - * The packaged CodeGraph npm shim downloads its platform bundle from GitHub Releases when the - * optional platform package is absent. `CODEGRAPH_NO_DOWNLOAD` (read by the shim's selfHealBundle) - * turns that fetch off, so the snapshot's CLI probe stays local whatever the egress mode is. - */ -const CODEGRAPH_NO_DOWNLOAD_ENV: Record = { CODEGRAPH_NO_DOWNLOAD: "1" }; - -const REPOSITORY_CODE_FACTS_SCHEMA = "archcontext.repository-code-facts/v1" as const; -const PENDING_SOURCE_CHANGES_ISSUE: ProjectionCodeGraphStatusIssueV1 = { - code: "pending-source-changes", - message: CODE_FACTS_ISSUE_MESSAGES["pending-source-changes"] -}; -const INDEX_OPEN_FAILED_MESSAGE = "CodeGraph index could not be opened and read read-only"; - -/** Facts every repository-code-facts reader reports the same way, from one status snapshot. */ -function repositoryCodeFactsFromStatus( - root: string, - codeGraphVersion: string, - status: CodeGraphProjectionStatusV1, - measuresFreshness: boolean -): RepositoryCodeFactsV1 { - const issues = projectionCodeGraphStatusIssues(status, root, "repository"); - const pending = status.pendingChanges; - // A projection runs `sync` first, so pending changes are not a projection-status issue; for a - // read-only inspection they mean the index lags the worktree. - if (measuresFreshness && (!pending || pending.added !== 0 || pending.modified !== 0 || pending.removed !== 0)) { - issues.push(PENDING_SOURCE_CHANGES_ISSUE); - } - return { - schemaVersion: REPOSITORY_CODE_FACTS_SCHEMA, - state: issues.length === 0 ? "ready" : "not-ready", - indexPresent: true, - requiredVersion: REQUIRED_CODEGRAPH_VERSION, - codeGraphVersion, - indexBuiltWithVersion: status.index?.builtWithVersion ?? null, - extractionVersion: { - builtWith: status.index?.builtWithExtractionVersion ?? null, - current: status.index?.currentExtractionVersion ?? null - }, - indexState: status.index?.state ?? null, - reindexRecommended: issues.some(issue => issue.code !== "pending-source-changes"), - pendingRefs: status.index?.pendingRefs ?? null, - pendingChanges: pending ? { added: pending.added, modified: pending.modified, removed: pending.removed } : null, - issues - }; -} - -function buildInProgressCodeFacts(codeGraphVersion: string, versionIssues: ProjectionCodeGraphStatusIssueV1[]): RepositoryCodeFactsV1 { - // Every index field would be a mid-build guess. `null` is the contract's unknown value. - return { - schemaVersion: REPOSITORY_CODE_FACTS_SCHEMA, - state: "not-ready", - indexPresent: true, - requiredVersion: REQUIRED_CODEGRAPH_VERSION, - codeGraphVersion, - indexBuiltWithVersion: null, - extractionVersion: { builtWith: null, current: null }, - indexState: null, - reindexRecommended: null, - pendingRefs: null, - pendingChanges: null, - issues: [...versionIssues, { code: "index-incomplete", message: CODE_FACTS_ISSUE_MESSAGES["index-incomplete"] }] - }; -} - -function versionIssuesOf(codeGraphVersion: string): ProjectionCodeGraphStatusIssueV1[] { - return codeGraphVersion === REQUIRED_CODEGRAPH_VERSION ? [] : [{ code: "version-mismatch", message: CODE_FACTS_ISSUE_MESSAGES["version-mismatch"] }]; -} - -function unavailableCodeFacts(reasonCode: "codegraph-unavailable" | "index-open-failed", message: string): RepositoryCodeFactsV1 { - return { schemaVersion: REPOSITORY_CODE_FACTS_SCHEMA, state: "unavailable", requiredVersion: REQUIRED_CODEGRAPH_VERSION, reasonCode, message }; -} - -export interface RepositoryCodeFactsInspectionOptions { - /** The pinned provider of this installation unless a test injects one. */ - provider?: CodeGraphReadProvider; -} - -/** - * Inspects the repository's code-facts state through one read-only session (ADR-0058): no CodeGraph - * command, no `git status`, no writable open. It measures freshness with CodeGraph's own change - * test and never throws for an unavailable provider or index. While a provider build holds the - * writer lock it does not open the index (#426). - */ -export function inspectRepositoryCodeFacts(root: string, options: RepositoryCodeFactsInspectionOptions = {}): RepositoryCodeFactsV1 { - return inspectRepositoryCodeFactsTier(root, "freshness", options).facts; -} - -/** - * When a provider build held the writer lock, at open or after the reads, or the index revision - * changed while the session read it (design G4), the facts are the not-ready build-in-progress - * answer and nothing read is reported. `retryable` is true then, unless the loaded provider has the - * wrong version: a retry after the build cannot fix that. - */ -function inspectRepositoryCodeFactsTier(root: string, tier: "compatibility" | "freshness", options: RepositoryCodeFactsInspectionOptions): { facts: RepositoryCodeFactsV1; retryable: boolean } { - let provider: CodeGraphReadProvider; - let codeGraphVersion: string; - try { - provider = options.provider ?? pinnedCodeGraphProvider(); - codeGraphVersion = provider.version; - } catch (error) { - return { facts: unavailableCodeFacts("codegraph-unavailable", error instanceof CodeGraphProviderUnavailableError ? error.message : "CodeGraph provider could not be loaded"), retryable: false }; - } - const versionIssues = versionIssuesOf(codeGraphVersion); - const moving = { facts: buildInProgressCodeFacts(codeGraphVersion, versionIssues), retryable: versionIssues.length === 0 }; - try { - return withCodeGraphReadSession(root, (opened): { facts: RepositoryCodeFactsV1; retryable: boolean } => { - switch (opened.state) { - case "index-missing": - return { facts: { schemaVersion: REPOSITORY_CODE_FACTS_SCHEMA, state: "index-missing", indexPresent: false, requiredVersion: REQUIRED_CODEGRAPH_VERSION, codeGraphVersion, issues: versionIssues }, retryable: false }; - case "index-open-failed": - return { facts: unavailableCodeFacts("index-open-failed", INDEX_OPEN_FAILED_MESSAGE), retryable: false }; - case "build-in-progress": - return moving; - case "open": { - const compatibility = opened.session.compatibility(); - const pendingChanges = tier === "freshness" ? opened.session.freshness() : undefined; - // Facts read across a build or a sync are not one index state (design G4). - if (!opened.session.unchangedSinceOpen()) return moving; - const status: CodeGraphProjectionStatusV1 = { - initialized: true, - version: compatibility.version, - projectPath: compatibility.projectPath, - lastIndexed: compatibility.lastIndexed, - ...(pendingChanges === undefined ? {} : { pendingChanges }), - // `openSync(root)` never walks up to another worktree's index (ADR-0058). - worktreeMismatch: null, - index: { - ...(compatibility.builtWithVersion === null ? {} : { builtWithVersion: compatibility.builtWithVersion }), - ...(compatibility.builtWithExtractionVersion === null ? {} : { builtWithExtractionVersion: compatibility.builtWithExtractionVersion }), - currentExtractionVersion: compatibility.currentExtractionVersion, - reindexRecommended: compatibility.reindexRecommended, - ...(compatibility.state === null ? {} : { state: compatibility.state }), - pendingRefs: compatibility.pendingRefs - } - }; - return { facts: repositoryCodeFactsFromStatus(root, codeGraphVersion, status, tier === "freshness"), retryable: false }; - } - } - }, { provider }); - } catch { - // The index opened but a read of it failed; no provider text crosses this boundary. - return { facts: unavailableCodeFacts("index-open-failed", INDEX_OPEN_FAILED_MESSAGE), retryable: false }; - } -} - -/** - * Read-only usability gate (ADR-0058): compatibility facts only. Pending edits affect freshness, not - * index compatibility, so the gate reads no source file. - */ -export function assertRepositoryCodeFactsReady(root: string, options: RepositoryCodeFactsInspectionOptions = {}): void { - const { facts, retryable } = inspectRepositoryCodeFactsTier(root, "compatibility", options); - if (facts.state === "index-missing") throw new ProjectionCodeFactsUnavailableError("index-missing"); - if (facts.state === "unavailable") { - if (facts.reasonCode === "index-open-failed") throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "index-open-failed"); - throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable", {}, { action: "inspect-diagnostics", retryable: false }); - } - // The compatibility tier never measures pending changes, so every not-ready answer refuses. - if (facts.state === "not-ready") { - const action = facts.codeGraphVersion !== REQUIRED_CODEGRAPH_VERSION ? "install-compatible-codegraph" - : "codegraph-index"; - throw new FailureError("AC_CODE_FACTS_NOT_READY", "code-facts-not-ready", { issues: facts.issues }, { action, ...(retryable ? { retryable: true } : {}) }); - } -} - -/** True when this workspace carries a CodeGraph index the CLI can be pointed at. */ -export function codeGraphIndexAvailable(workspaceRoot: string): boolean { - return existsSync(join(workspaceRoot, ".codegraph")); -} - -export interface CodeGraphSymbolDeclaration { - name: string; - qualifiedName: string; - startLine: number; - endLine: number; -} - -/** Read-only symbol lookups for `docs anchors` (#298). */ -export interface CodeGraphSymbolReader { - /** True when CodeGraph has a parser for this path's language. */ - supportsPath(path: string): boolean; - /** - * Every declaration parsed from `content` now; the index is not read, so it cannot be stale. - * `undefined` when CodeGraph cannot parse the file. - */ - declarationsInSource(path: string, content: string): CodeGraphSymbolDeclaration[] | undefined; - /** Declarations named `name` in the CodeGraph index. */ - declarationsNamed(name: string): CodeGraphSymbolDeclaration[]; - /** - * The declarations that the index holds for `path`, or `undefined` when the index does not hold - * `bytes` as the file's content. The test is CodeGraph's own change test: the SHA-256 of the - * UTF-8 text equals the recorded content hash. - */ - indexedDeclarations(path: string, bytes: Uint8Array): CodeGraphIndexedDeclaration[] | undefined; - /** - * The resolved targets of the `calls` and `references` edges from each id, read in one batch and - * keyed by source id. A reference that CodeGraph left unresolved has no target and is absent. - */ - callees(ids: readonly string[]): Map; - /** The ids that each id contains, directly or nested, by the index's `contains` edges. */ - contained(ids: readonly string[]): Map>; - close(): void; -} - -/** A declaration as the CodeGraph index records it. */ -export interface CodeGraphIndexedDeclaration extends CodeGraphSymbolDeclaration { - id: string; - kind: string; - path: string; -} - -export interface CodeGraphCallee extends CodeGraphIndexedDeclaration { - /** The kinds of the declarations that contain this one, by `contains` edges. */ - containerKinds: string[]; -} - -export type CodeGraphSymbolReaderResult = - | { availability: "ready"; reader: CodeGraphSymbolReader } - | { availability: "unavailable"; reasonCode: "index-missing" | "index-open-failed" }; - -/** Kinds that name a reference or a scope, not a declaration a document can cite. */ -const NON_DECLARATION_KINDS: ReadonlySet = new Set(["file", "import", "export", "parameter"]); - -/** - * Opens the workspace CodeGraph index read-only for symbol lookups. It never opens the index - * writable. When the read-only open fails, the result is `index-open-failed`. Whether a read-only - * open works with no live holder depends on the SQLite build: Node's `node:sqlite` opens it and - * creates the WAL files, and the macOS system SQLite that Bun uses there refuses it. - */ -export function openCodeGraphSymbolReader( - workspaceRoot: string, - openIndex: (root: string, options: { readOnly: true }) => ReturnType = (root, options) => codeGraphSdk().CodeGraph.openSync(root, options) -): CodeGraphSymbolReaderResult { - if (!codeGraphIndexAvailable(workspaceRoot)) return { availability: "unavailable", reasonCode: "index-missing" }; - disableCodeGraphTelemetryByDefault(); - let graph: ReturnType; - try { - graph = openIndex(workspaceRoot, { readOnly: true }); - } catch { - return { availability: "unavailable", reasonCode: "index-open-failed" }; - } - const declarations = (nodes: CodeGraphNode[]): CodeGraphSymbolDeclaration[] => nodes - .filter((node) => !NON_DECLARATION_KINDS.has(node.kind)) - .map((node) => ({ name: node.name, qualifiedName: node.qualifiedName, startLine: node.startLine, endLine: node.endLine })) - .sort((left, right) => left.startLine - right.startLine || left.qualifiedName.localeCompare(right.qualifiedName)); - return { - availability: "ready", - reader: { - supportsPath: (path) => codeGraphSdk().isLanguageSupported(codeGraphSdk().detectLanguage(path)), - declarationsInSource: (path, content) => { - try { - return declarations(graph.extractFromSource(path, content).nodes); - } catch { - return undefined; - } - }, - declarationsNamed: (name) => declarations(graph.getNodesByName(name).filter((node) => node.name === name)), - indexedDeclarations: (path, bytes) => { - const record = graph.getFile(path); - const text = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("utf8"); - if (record === null || record.contentHash !== createHash("sha256").update(text).digest("hex")) return undefined; - return graph.getNodesInFile(path).filter((node) => !NON_DECLARATION_KINDS.has(node.kind)).map(indexedDeclaration); - }, - callees: (ids) => { - const edges = graph.getOutgoingEdgesFrom(ids, ["calls", "references"]); - const targetIds = [...new Set(edges.map((edge) => edge.target))]; - const targets = graph.getNodesByIds(targetIds); - const containment = graph.getIncomingEdgesTo(targetIds, ["contains"]); - const containers = graph.getNodesByIds([...new Set(containment.map((edge) => edge.source))]); - const containerKinds = new Map(); - for (const edge of containment) { - const container = containers.get(edge.source); - if (container !== undefined) containerKinds.set(edge.target, [...containerKinds.get(edge.target) ?? [], container.kind]); - } - const bySource = new Map>(); - for (const edge of edges) { - const target = targets.get(edge.target); - if (target === undefined) continue; - let entries = bySource.get(edge.source); - if (entries === undefined) bySource.set(edge.source, entries = new Map()); - entries.set(target.id, { ...indexedDeclaration(target), containerKinds: containerKinds.get(target.id) ?? [] }); - } - return new Map([...bySource].map(([source, entries]) => [source, [...entries.values()]])); - }, - contained: (ids) => { - const result = new Map(ids.map((id) => [id, new Set()])); - // One edge read per nesting level, for all ids at once. - let frontier = ids.map((id) => [id, id] as const); - while (frontier.length > 0) { - const roots = new Map(); - for (const [root, node] of frontier) roots.set(node, [...roots.get(node) ?? [], root]); - frontier = graph.getOutgoingEdgesFrom([...roots.keys()], ["contains"]) - .flatMap((edge) => roots.get(edge.source)!.map((root) => [root, edge.target] as const)) - .filter(([root, node]) => !result.get(root)!.has(node)); - for (const [root, node] of frontier) result.get(root)!.add(node); - } - return result; - }, - close: () => graph.close() - } - }; -} - -function indexedDeclaration(node: CodeGraphNode): CodeGraphIndexedDeclaration { - return { id: node.id, kind: node.kind, path: node.filePath, name: node.name, qualifiedName: node.qualifiedName, startLine: node.startLine, endLine: node.endLine }; -} - -/** - * `reader.declarationsInSource` over the content that `contents` returns, each path parsed at most - * once. `undefined` for a path whose language CodeGraph has no parser for, or that it cannot parse. - */ -export function memoizedSourceDeclarations(reader: CodeGraphSymbolReader, contents: (path: string) => string): (path: string) => CodeGraphSymbolDeclaration[] | undefined { - const parsed = new Map(); - return (path) => { - if (!parsed.has(path)) parsed.set(path, reader.supportsPath(path) ? reader.declarationsInSource(path, contents(path)) : undefined); - return parsed.get(path); - }; -} - -/** - * Measures import facts and exact node-v2 entrypoint→sink selectors. CodeGraph proves only the - * declared binding: it never chooses seed symbols, labels, order, branches or outcomes. - * - * With no index present this returns empty evidence. The semantic compiler then reports - * `unprovable`; it never degrades into a path-based diagram. - */ -export function loadCapabilityCodeGraphProjectionInputs( - root: string, - model: NativeModel, - options: { - /** The Git-visible files (`listGitVisibleFiles`); read once from `root` when omitted. */ - sourceFiles?: readonly string[]; - /** Footprints resolved over the Git-visible files; resolved from `root` when omitted. */ - footprints?: readonly CapabilitySourceFootprint[]; - binary?: string; - /** Safety cap on the import nodes read; `CODEGRAPH_IMPORT_NODE_READ_LIMIT` when omitted. */ - importNodeLimit?: number; - /** - * Import nodes in the index, from the `codegraph status` the caller asserted. The read is sized - * from it. `null` or omitted is an unknown count: the read then asks for the limit. - */ - importNodeCount?: number | null; - selectorIndexFactory?: (root: string, options: { readOnly: boolean }) => CodeGraphSelectorIndex; - /** Read consumers open selectors read-only and prohibit the import-query shim from downloading. */ - readOnly?: boolean; - /** `false` skips the import-graph query; `flows check` reads selector evidence only. */ - importGraphs?: boolean; - } = {} -): CapabilityCodeGraphProjectionInputs { - let visibleFiles: readonly string[] | undefined = options.sourceFiles; - const sourceFiles = () => visibleFiles ??= listGitVisibleFiles(root); - const declaresFootprint = options.importGraphs !== false - && model.nodes.some((node) => (nativeNodeSource(node)?.include ?? []).length > 0); - const footprints = declaresFootprint - ? options.footprints ?? loadCapabilitySourceFootprints(root, model, sourceFiles()) - : []; - const entrypointNodes = model.nodes - .map((node) => ({ nodeId: node.id, entrypoints: nativeNodeSource(node)?.entrypoints ?? [] })) - .filter((entry) => entry.entrypoints.length > 0) - .sort((left, right) => left.nodeId.localeCompare(right.nodeId)); - if (footprints.length === 0 && entrypointNodes.length === 0) return { importGraphs: [], selectorEvidence: [] }; - if (!codeGraphIndexAvailable(root)) return { importGraphs: [], selectorEvidence: [] }; - - disableCodeGraphTelemetryByDefault(); - const binary = options.binary ?? DEFAULT_CODEGRAPH_BINARY; - const importTargets = new Set(sourceFiles()); - const imports = footprints.length > 0 - ? capabilityImportGraphs(root, binary, importTargets, footprints, options.importNodeLimit ?? CODEGRAPH_IMPORT_NODE_READ_LIMIT, options.importNodeCount ?? null, - options.readOnly ? CODEGRAPH_NO_DOWNLOAD_ENV : undefined) - : undefined; - const importGraphs = imports?.importGraphs ?? []; - const selectorIndex = entrypointNodes.length > 0 - ? (options.selectorIndexFactory ?? ((path, openOptions) => codeGraphSdk().CodeGraph.openSync(path, openOptions)))(root, { readOnly: options.readOnly === true }) - : undefined; - let selectorEvidence: ArchitectureSelectorEvidenceV1[]; - try { - selectorEvidence = entrypointNodes.flatMap((entry) => entry.entrypoints.flatMap((entrypoint) => - entrypoint.symbols.flatMap((source) => source.sinks.map((sink) => exactSelectorEvidence(importTargets, selectorIndex!, { - nodeId: entry.nodeId, - entrypointId: entrypoint.id, - sourcePath: entrypoint.path, - sourceSymbol: source.name, - sinkId: sink.id, - sinkPath: sink.path, - sinkSymbol: sink.symbol - }))))); - } finally { - selectorIndex?.close(); - } - return { importGraphs, selectorEvidence, ...(imports ? { importPopulation: imports.importPopulation } : {}) }; -} - -export interface RepositoryImportPairV1 { - from: string; - /** The specifier verbatim. Two different specifiers from one file are two observations. */ - specifier: string; - /** Repo-relative target, or `null` when this producer could not resolve the specifier. */ - to: string | null; - /** 1-based line of the first such import in `from`, when the index reported one. */ - line?: number; -} - -export interface RepositoryImportPairsV1 { - pairs: RepositoryImportPairV1[]; - /** - * True when the pairs are not the whole population for a reason other than the limit: no - * index answered, or the query returned a different number of import nodes than the index - * reported (the index changed during the read). - */ - truncated: boolean; - /** `ready` only when an index exists and reports no pending source changes. */ - availability: "ready" | "unavailable"; - /** The worktree the index attests to, or `null` when it attests to nothing usable. */ - indexedWorktreeDigest: string | null; - /** - * Import nodes in the index, from `codegraph status` (`nodesByKind.import`). `null` when no - * index answered, or when the status gave no count and the read stopped at the limit. - */ - importNodeCount: number | null; - /** The read limit this call applied. */ - importNodeLimit: number; - /** - * True when the index holds more import nodes than `importNodeLimit`. The pairs then come from - * the first `importNodeLimit` nodes. This is a configured limit, not an incomplete index, so - * `truncated` stays false. - */ - importNodeLimitExceeded: boolean; -} - -/** - * Every import edge in the repository as repo-relative pairs, plus the identity of the index they - * were read from. Unlike `capabilityImportGraphs` this keeps specifiers it could not resolve as - * `to: null` with the specifier retained, because a module snapshot has to report how much of its - * boundary is unresolved and a dropped edge is indistinguishable from an absent one. Bare - * workspace specifiers are deliberately left unresolved here: mapping `@scope/pkg/sub` through a - * package `exports` map is the builder's job, which keeps this adapter free of resolution rules - * and keeps the resolution itself unit-testable without an index. The two producers share - * `codeGraphImportPopulation` and `importPairsOf`; `capabilityImportGraphs` also returns these - * pairs as `importPopulation`, and keeps its resolved-only graph edge set, which the capability - * projection fixtures pin byte for byte. - * - * Freshness is producer-owned evidence, never a caller assertion: the returned - * `indexedWorktreeDigest` binds the edges to `worktreeDigest` only when the index reports itself - * clean, so a consumer cannot claim complete coverage over an index that never saw this tree. - * - * `limit` is a safety cap, not a page size. The read asks the index for its import count first - * and then reads that whole population in one query, so a complete index gives complete pairs up - * to `limit` import nodes. Above it, `importNodeLimitExceeded` is true and `truncated` stays false. - */ -export function repositoryImportPairs( - root: string, - binary: string, - limit: number, - worktreeDigest: string, - /** The Git-visible files (`listGitVisibleFiles`) that `worktreeDigest` was computed over. */ - sourceFiles: readonly string[] -): RepositoryImportPairsV1 { - if (!Number.isInteger(limit) || limit < 1) throw new Error(`import node limit must be a positive integer, got ${limit}`); - if (!codeGraphIndexAvailable(root)) { - return { - pairs: [], - truncated: true, - availability: "unavailable", - indexedWorktreeDigest: null, - importNodeCount: null, - importNodeLimit: limit, - importNodeLimitExceeded: false - }; - } - const status = readProjectionCodeGraphStatus(codeGraphCliInvocation(binary, root, ""), root, CODEGRAPH_QUERY_TIMEOUT_MS); - const nodes = codeGraphImportPopulation(root, binary, limit, statusImportNodeCount(status)); - const pending = status.pendingChanges; - const clean = status.initialized && pending !== undefined && pending.added === 0 && pending.modified === 0 && pending.removed === 0; - return { - pairs: importPairsOf(new Set(sourceFiles), nodes.imports), - truncated: nodes.truncated, - availability: clean ? "ready" : "unavailable", - indexedWorktreeDigest: clean ? worktreeDigest : null, - importNodeCount: nodes.count, - importNodeLimit: limit, - importNodeLimitExceeded: nodes.limitExceeded - }; -} - -/** Import nodes as repo-relative pairs, unresolved specifiers kept as `to: null`, sorted by file then specifier. */ -function importPairsOf(gitVisibleFiles: ReadonlySet, imports: CodeGraphCliNode[]): RepositoryImportPairV1[] { - const pairs = new Map(); - for (const node of imports) { - if (node.kind !== "import") continue; - const to = resolveImportTarget(gitVisibleFiles, node.filePath, node.name) ?? null; - // Keyed on (file, specifier) so three distinct unresolved specifiers stay three records. A - // specifier imported twice from one file is one edge, located at its first import line. - const key = JSON.stringify([node.filePath, node.name]); - const line = Number.isInteger(node.startLine) && node.startLine! >= 1 ? node.startLine! : undefined; - const previousLine = pairs.get(key)?.line; - const firstLine = line === undefined ? previousLine : previousLine === undefined ? line : Math.min(line, previousLine); - pairs.set(key, { from: node.filePath, specifier: node.name, to, ...(firstLine === undefined ? {} : { line: firstLine }) }); - } - return [...pairs.keys()].sort().map((key) => pairs.get(key)!); -} - -/** - * The import population the index reports, or `null` when the status gives no `import` count. - * A missing `import` kind is unknown, not zero: a CodeGraph that names the kind differently must - * fall back to the limit-sized read, not to a one-node read that is always short. - */ -function statusImportNodeCount(status: CodeGraphProjectionStatusV1): number | null { - if (!status.nodesByKind || typeof status.nodesByKind !== "object") return null; - const count = status.nodesByKind.import; - return Number.isInteger(count) && count! >= 0 ? count! : null; -} - -/** - * Reads the whole import population in one query. `codegraph query` has no offset, so the query - * limit is the count the index reported, plus one: a reply longer or shorter than that count - * means the index changed during the read, which is real truncation. Without a reported count, - * the read asks for `limit + 1` and reports the reply size as the count. - */ -function codeGraphImportPopulation( - root: string, - binary: string, - limit: number, - reportedCount: number | null, - envOverrides?: Record -): { imports: CodeGraphCliNode[]; count: number | null; truncated: boolean; limitExceeded: boolean } { - const queryLimit = Math.min(reportedCount ?? limit, limit) + 1; - const output = runCodeGraphCli( - binary, - root, - ["query", "-p", root, "-j", "-l", String(queryLimit), "-k", "import", "import"], - Math.max(CODEGRAPH_OUTPUT_MAX_BYTES, queryLimit * CODEGRAPH_IMPORT_NODE_OUTPUT_BYTES), - envOverrides - ); - const imports = (JSON.parse(output) as { node: CodeGraphCliNode }[]).map(({ node }) => node); - const limitExceeded = (reportedCount ?? imports.length) > limit; - if (limitExceeded) { - // The cap explains a reply of exactly `limit` nodes. A shorter reply means the index shrank - // during the read, which is real truncation on top of the limit. - return { imports: imports.slice(0, limit), count: reportedCount, truncated: imports.length < limit, limitExceeded }; - } - return { - imports, - count: reportedCount ?? imports.length, - truncated: reportedCount !== null && imports.length !== reportedCount, - limitExceeded - }; -} - - -function capabilityImportGraphs( - root: string, - binary: string, - gitVisibleFiles: ReadonlySet, - footprints: readonly CapabilitySourceFootprint[], - limit: number, - reportedCount: number | null, - envOverrides?: Record -): { importGraphs: CapabilityImportGraph[]; importPopulation: ProjectionImportPopulationV1 } { - if (!Number.isInteger(limit) || limit < 1) throw new Error(`import node limit must be a positive integer, got ${limit}`); - const nodes = codeGraphImportPopulation(root, binary, limit, reportedCount, envOverrides); - const population = importPairsOf(gitVisibleFiles, nodes.imports); - // The graphs keep their resolved-only edge set, which the capability projection fixtures pin. - const resolved = population.flatMap((pair) => pair.to === null ? [] : [{ from: pair.from, to: pair.to }]); - const importGraphs = footprints.map((footprint) => { - const scope = new Set(footprint.files); - const edges = new Map(); - for (const pair of resolved) { - if (!scope.has(pair.from)) continue; - edges.set(`${pair.from} ${pair.to}`, pair); - } - return { - nodeId: footprint.nodeId, - files: footprint.files, - edges: [...edges.values()].sort((left, right) => left.from.localeCompare(right.from) || left.to.localeCompare(right.to)), - truncated: nodes.truncated, - ...(nodes.limitExceeded ? { importNodeLimitExceeded: true as const } : {}) - }; - }); - return { - importGraphs, - importPopulation: { - pairs: population, - truncated: nodes.truncated, - importNodeCount: nodes.count, - importNodeLimit: limit, - importNodeLimitExceeded: nodes.limitExceeded - } - }; -} - -/** - * Runtime built-in names a call can carry without any repository import: the named exports of - * `node:path`, `node:fs`, `node:child_process`, `node:os`, `node:url` and `node:crypto` that code - * imports by name, plus globals. CodeGraph resolves a call by name, so `resolve()` from - * `node:path` can appear as a `calls` edge into a repository file that defines `resolve` (#300). - * The list is explicit, not read from `node:module` at run time, so a proof does not change with - * the Node or Bun version that computes it. Add a name only when it is such a built-in export. - */ -export const RUNTIME_BUILTIN_CALL_NAMES: ReadonlySet = new Set([ - // node:path - "basename", "dirname", "extname", "format", "isAbsolute", "join", "normalize", "parse", "relative", "resolve", "toNamespacedPath", - // node:fs - "access", "accessSync", "appendFile", "appendFileSync", "chmod", "chmodSync", "close", "closeSync", "copyFile", "copyFileSync", - "cp", "cpSync", "createReadStream", "createWriteStream", "exists", "existsSync", "fsync", "fsyncSync", "lstat", "lstatSync", - "mkdir", "mkdirSync", "mkdtemp", "mkdtempSync", "open", "openSync", "opendir", "opendirSync", "read", "readFile", "readFileSync", - "readSync", "readdir", "readdirSync", "readlink", "readlinkSync", "realpath", "realpathSync", "rename", "renameSync", "rm", - "rmSync", "rmdir", "rmdirSync", "stat", "statSync", "symlink", "symlinkSync", "unlink", "unlinkSync", "watch", "write", - "writeFile", "writeFileSync", "writeSync", - // node:child_process - "exec", "execFile", "execFileSync", "execSync", "fork", "spawn", "spawnSync", - // node:os, node:url, node:crypto - "homedir", "hostname", "platform", "tmpdir", "fileURLToPath", "pathToFileURL", "createHash", "randomBytes", "randomUUID", - // globals - "clearInterval", "clearTimeout", "fetch", "queueMicrotask", "setInterval", "setTimeout", "structuredClone" -]); - -function exactSelectorEvidence( - gitVisibleFiles: ReadonlySet, - index: CodeGraphSelectorIndex, - selector: Omit -): ArchitectureSelectorEvidenceV1 { - const sources = indexedSymbolsAt(index, selector.sourceSymbol, selector.sourcePath); - // A selector names one symbol. Two indexed declarations of the sink name in the sink file (for - // example a function and a constant) make the sink ambiguous even when only one is called. - const sinkDeclarations = indexedSymbolsAt(index, selector.sinkSymbol, selector.sinkPath); - const exactEdges = sources.flatMap((source) => index.getOutgoingEdges(source.id) - .filter((edge) => edge.kind === "calls" || edge.kind === "references") - .flatMap((edge) => { - const target = index.getNode(edge.target); - return target - && target.name === selector.sinkSymbol - && normalizedIndexPath(target.filePath) === normalizedIndexPath(selector.sinkPath) - ? [{ edge, target }] - : []; - })); - const calls = exactEdges.filter(({ edge }) => edge.kind === "calls"); - const falseBuiltinCalls = calls.length > 0 && isFalseBuiltinCall(gitVisibleFiles, index, sources[0]!.filePath, selector); - const exactCalls = falseBuiltinCalls ? [] : calls; - const references = exactCalls.length === 0 ? exactEdges.filter(({ edge }) => edge.kind === "references") : []; - const sinkIdentities = new Set(exactCalls.map(({ target }) => target.id)); - const ambiguous = sources.length > 1 || sinkDeclarations.length > 1 || sinkIdentities.size > 1; - return { - ...selector, - matched: !ambiguous && sinkIdentities.size === 1, - ambiguous, - // The structured SDK queries above are uncapped index lookups (name index plus the full - // outgoing-edge set of each source), so this producer enumerates the whole population and - // can never drop a match; only the CLI-backed import query has a limit to report. - truncated: false, - callSites: edgeSites(selector.sourcePath, exactCalls), - ...(falseBuiltinCalls ? { rejectedBuiltinCallSites: edgeSites(selector.sourcePath, calls) } : {}), - ...(references.length > 0 ? { referenceSites: edgeSites(selector.sourcePath, references) } : {}) - }; -} - -function indexedSymbolsAt(index: CodeGraphSelectorIndex, name: string, path: string): CodeGraphNode[] { - return index.getNodesByName(name).filter((node) => normalizedIndexPath(node.filePath) === normalizedIndexPath(path)); -} - -function edgeSites(sourcePath: string, edges: Array<{ edge: CodeGraphEdge }>): Array<{ path: string; line?: number }> { - return [...new Map(edges.map(({ edge }) => { - const site = { path: sourcePath, ...(edge.line === undefined ? {} : { line: edge.line }) }; - return [`${site.path}\0${site.line ?? ""}`, site] as const; - })).values()].sort((left, right) => left.path.localeCompare(right.path) || (left.line ?? 0) - (right.line ?? 0)); -} - -/** - * A call to a runtime built-in name proves nothing unless the source file imports the sink file: - * otherwise CodeGraph may have matched a built-in (`resolve` from `node:path`) to a same-named - * repository function. A call inside the sink file needs no import. Only relative specifiers are - * resolved, so a call through a package alias or a re-exporting barrel is not proven this way. - */ -function isFalseBuiltinCall( - gitVisibleFiles: ReadonlySet, - index: CodeGraphSelectorIndex, - indexedSourcePath: string, - selector: { sourcePath: string; sinkPath: string; sinkSymbol: string } -): boolean { - if (!RUNTIME_BUILTIN_CALL_NAMES.has(selector.sinkSymbol)) return false; - const sourcePath = normalizedIndexPath(selector.sourcePath); - const sinkPath = normalizedIndexPath(selector.sinkPath); - if (sourcePath === sinkPath) return false; - return !index.getNodesInFile(indexedSourcePath).some((node) => node.kind === "import" - // A TypeScript ESM specifier names the emitted `.js` file; try the extensionless form too. - && [node.name, node.name.replace(/\.[cm]?js$/, "")].some((specifier) => resolveImportTarget(gitVisibleFiles, sourcePath, specifier) === sinkPath)); -} - -function normalizedIndexPath(path: string): string { - return path.replaceAll("\\", "/").replace(/^\.\//, ""); -} - -function runCodeGraphCli(binary: string, workspaceRoot: string, args: string[], maxBuffer = CODEGRAPH_OUTPUT_MAX_BYTES, envOverrides?: Record): string { - disableCodeGraphTelemetryByDefault(); - const invocation = codeGraphCliInvocation(binary, workspaceRoot); - try { - return execFileSync(invocation.command, [...invocation.argsPrefix, ...args], { - cwd: workspaceRoot, - env: codeGraphChildEnv(envOverrides), - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - maxBuffer, - 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}`); - } -} - -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/src/projection-handshake.ts b/packages/local-runtime/codegraph-adapter/src/projection-handshake.ts new file mode 100644 index 00000000..1e03cc03 --- /dev/null +++ b/packages/local-runtime/codegraph-adapter/src/projection-handshake.ts @@ -0,0 +1,465 @@ +import { CODE_FACTS_ISSUE_MESSAGES } from "@archcontext/contracts"; +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { join } from "node:path"; +import { codeGraphChildEnv } from "./child-env"; +import { REQUIRED_CODEGRAPH_PACKAGE, REQUIRED_CODEGRAPH_VERSION, codeGraphIndexBuildInProgress, type CodeGraphReadProvider } from "./read-session"; +import { parseJsonOrStableYaml } from "@archcontext/core/architecture-domain"; +import { listGitVisibleFiles } from "@archcontext/local-runtime/git-adapter"; +import { + ARCHITECTURE_DOCS_LAYOUT_VERSION, + ARCHITECTURE_DOCS_RENDERER_VERSION, + architectureDocumentationProjectionProvenance, + architectureDocumentationProjectionWorktreeDigest, + digestArchitectureDocumentationSourceTree, + loadCapabilitySourceFootprints, + type CapabilitySourceFootprint, + type ArchitectureDocumentationProjectionRuntimeSnapshot, + type NativeModel +} from "@archcontext/core/projection-engine"; +import { digestJson, type ArchitectureDocumentationProjectionProvenanceV3, type Json } from "@archcontext/contracts"; +import { + CODEGRAPH_OUTPUT_MAX_BYTES, + codeGraphCliInvocation, + digestFile, + disableCodeGraphTelemetryByDefault, + packagedCodeGraphCliInvocation, + packagedCodeGraphRuntimeDigest, + type CodeGraphCliInvocation +} from "./cli-provider"; +import { statusImportNodeCount } from "./import-population"; +import { assertRepositoryCodeFactsReady, codeGraphIndexAvailable } from "./repository-code-facts"; +import { loadCapabilityCodeGraphProjectionInputs, type CapabilityCodeGraphProjectionInputs } from "./selector-index"; + +// --- Capability documentation projection measurements and exact semantic bindings --- +// +// A thin read-only query surface over the same CodeGraph CLI the provider uses. It exists so the +// documentation projection stays a pure function in @archcontext/core: the child process lives +// here, the renderer only receives measured facts. Import graphs remain diagnostic measurements; +// exact node-v2 selectors are the only CodeGraph facts that can bind a semantic flow step. + +export interface CodeGraphProjectionStatusV1 { + initialized: boolean; + version: string; + projectPath: string; + lastIndexed: string | null; + fileCount?: number; + nodeCount?: number; + edgeCount?: number; + nodesByKind?: Record; + languages?: string[]; + pendingChanges?: { added: number; modified: number; removed: number }; + worktreeMismatch?: unknown; + index?: { + builtWithVersion?: string; + builtWithExtractionVersion?: number; + currentExtractionVersion?: number; + reindexRecommended?: boolean; + state?: string; + pendingRefs?: number; + }; +} + +export interface CodeGraphProjectionHandshakeV1 { + schemaVersion: "archcontext.codegraph-projection-handshake/v1"; + packageName: typeof REQUIRED_CODEGRAPH_PACKAGE; + requiredVersion: typeof REQUIRED_CODEGRAPH_VERSION; + actualVersion: string; + binaryDigest: string; + availability: "ready" | "unavailable"; + reasonCode?: "index-missing"; + preSyncStatusDigest: string | null; + postSyncStatusDigest: string | null; + syncDigest: string | null; + indexedWorktreeDigest: string | null; + /** + * Digest of the CodeGraph version and the code evidence this handshake read (import graphs, + * selector evidence). It excludes the binary digest and the index status, which differ per + * machine and per index build, so re-indexing an unchanged tree never moves it. + */ + graphDigest: string; +} + +export interface PreparedProjectionCodeFacts extends CapabilityCodeGraphProjectionInputs { + handshake: CodeGraphProjectionHandshakeV1; +} + +export interface PreparedArchitectureDocumentationProjectionSnapshot extends PreparedProjectionCodeFacts { + provenance: ArchitectureDocumentationProjectionProvenanceV3; + /** HEAD, worktree and CodeGraph state this snapshot read; runtime receipts only, never committed. */ + runtimeSnapshot: ArchitectureDocumentationProjectionRuntimeSnapshot; + /** + * The Git-visible files (`listGitVisibleFiles`) this snapshot measured footprints over. + * Callers stamp footprints and scale signals from this same list, so every digest of one run + * agrees on which files exist. + */ + sourceFiles: string[]; + /** + * `loadCapabilitySourceFootprints` over `sourceFiles`, resolved once for the whole snapshot. The + * source tree digest and the import graphs read it, and callers stamp footprint digests and scale + * signals from it, so one build matches the globs once (#324). + */ + footprints: CapabilitySourceFootprint[]; +} + +/** Single snapshot assembly shared by CLI, daemon completion gates, and verification tests. */ +export function prepareArchitectureDocumentationProjectionSnapshot( + root: string, + model: NativeModel, + options: { + binary?: string; + importNodeLimit?: number; + timeouts?: { versionMs?: number; statusMs?: number; syncMs?: number }; + /** The read-only provider the readiness gate reads through; the pinned one unless injected. */ + readProvider?: CodeGraphReadProvider; + } = {} +): PreparedArchitectureDocumentationProjectionSnapshot { + // Classify repository readiness with the same doctor evaluator the task commands use, before + // any code-facts read. A present but incompatible index is a typed refusal; pending-only + // freshness is accepted and cleared by the sync below. An absent index keeps its existing + // AC_CODE_FACTS_UNAVAILABLE contract. + const { readProvider, ...projectionOptions } = options; + if (codeGraphIndexAvailable(root)) assertRepositoryCodeFactsReady(root, readProvider ? { provider: readProvider } : {}); + const baseHeadSha = readProjectionHeadSha(root); + const sourceFiles = listGitVisibleFiles(root); + const worktreeDigest = architectureDocumentationProjectionWorktreeDigest(root, model, sourceFiles); + const footprints = loadCapabilitySourceFootprints(root, model, sourceFiles); + const sourceTreeDigest = digestArchitectureDocumentationSourceTree(root, model, footprints); + const modelDigest = digestJson(model as unknown as Json); + const prepared = prepareProjectionCodeFacts(root, model, { ...projectionOptions, sourceTreeDigest, sourceFiles, footprints }); + if (architectureDocumentationProjectionWorktreeDigest(root, model, listGitVisibleFiles(root)) !== worktreeDigest) { + throw new Error("architecture-docs-projection-worktree-changed-during-codegraph-sync"); + } + const provenance = architectureDocumentationProjectionProvenance({ + sourceTreeDigest, + modelDigest, + rendererVersion: ARCHITECTURE_DOCS_RENDERER_VERSION, + layoutVersion: ARCHITECTURE_DOCS_LAYOUT_VERSION, + generatedFrom: { + codeGraphPackage: prepared.handshake.packageName, + codeGraphVersion: prepared.handshake.actualVersion + } + }); + return { + ...prepared, + provenance, + runtimeSnapshot: { + headSha: baseHeadSha, + worktreeDigest, + codeGraphDigest: prepared.handshake.graphDigest, + indexedWorktreeDigest: prepared.handshake.indexedWorktreeDigest, + codeGraphStatus: prepared.handshake.availability + }, + sourceFiles, + footprints + }; +} + +/** + * Thrown when `.archcontext/manifest.yaml` declares `codeFacts.required: true` and the projection + * snapshot has no code facts. Without them every selector is unproven, so the major-change + * classifier would report a flow-proof change for every capability; an environment failure must not + * read as an architecture change. Surfaces map it to `AC_CODE_FACTS_UNAVAILABLE`. + */ +export class ProjectionCodeFactsUnavailableError extends Error { + readonly code = "AC_CODE_FACTS_UNAVAILABLE" as const; + constructor(readonly reasonCode: NonNullable) { + super(`code facts are required by .archcontext/manifest.yaml but unavailable (${reasonCode}); run \`codegraph init\` and retry`); + this.name = "ProjectionCodeFactsUnavailableError"; + } +} + +/** `codeFacts.required` from `.archcontext/manifest.yaml`; only an explicit `true` requires them. */ +export function projectionCodeFactsRequired(root: string): boolean { + const path = join(root, ".archcontext/manifest.yaml"); + if (!existsSync(path)) return false; + let manifest: Json; + try { + manifest = parseJsonOrStableYaml(readFileSync(path, "utf8"), ".archcontext/manifest.yaml"); + } catch { + // Manifest shape is validated by `archctx validate`; an unreadable file declares nothing here. + return false; + } + const codeFacts = manifest && typeof manifest === "object" && !Array.isArray(manifest) ? manifest.codeFacts : undefined; + return !!codeFacts && typeof codeFacts === "object" && !Array.isArray(codeFacts) && codeFacts.required === true; +} + +/** + * Projection gate for required code facts. Runs before the snapshot reaches the renderer, so a + * missing index never classifies a major change, writes an acceptance candidate or emits a refresh + * signal. + */ +export function assertProjectionCodeFactsAvailable(root: string, prepared: PreparedProjectionCodeFacts): void { + if (prepared.handshake.availability === "ready" || !projectionCodeFactsRequired(root)) return; + throw new ProjectionCodeFactsUnavailableError(prepared.handshake.reasonCode ?? "index-missing"); +} + +/** + * Freezes the public CodeGraph proof used by one docs projection. The adapter never reads the + * `.codegraph` database: actual binary/version and both status snapshots come from the package-local + * CLI. A missing index is an explicit unavailable state; an existing but stale/incompatible index + * is an error and cannot produce verified facts. + */ +export function prepareProjectionCodeFacts( + root: string, + model: NativeModel, + options: { + sourceTreeDigest: string; + /** The Git-visible files (`listGitVisibleFiles`); read from `root` when omitted. */ + sourceFiles?: readonly string[]; + /** Footprints resolved over the Git-visible files; resolved from `root` when omitted. */ + footprints?: readonly CapabilitySourceFootprint[]; + binary?: string; + importNodeLimit?: number; + timeouts?: { versionMs?: number; statusMs?: number; syncMs?: number }; + /** `false` reads selector evidence only (`flows check`); a projection always reads both. */ + importGraphs?: boolean; + } +): PreparedProjectionCodeFacts { + const invocation = options.binary + ? codeGraphCliInvocation(options.binary, root, "") + : packagedCodeGraphCliInvocation(); + const binaryPath = invocation.argsPrefix[0] ?? invocation.command; + // `--version` starts the same launcher chain as `status`, so it gets the same stall budget. On + // Windows CI it takes 94 ms (p50) and 2.4 s cold; a runner stall once pushed it past 5 s. + const actualVersion = runProjectionCodeGraph(invocation, root, ["--version"], options.timeouts?.versionMs ?? 10_000).trim(); + if (actualVersion !== REQUIRED_CODEGRAPH_VERSION) { + throw new Error(`CodeGraph ${REQUIRED_CODEGRAPH_VERSION} required, got actual binary ${actualVersion || "unknown"}`); + } + const binaryDigest = options.binary ? digestFile(binaryPath) : packagedCodeGraphRuntimeDigest(); + if (!codeGraphIndexAvailable(root)) { + const graphDigest = digestJson({ + schemaVersion: "archcontext.codegraph-projection-handshake/v1", + actualVersion, + availability: "unavailable", + reasonCode: "index-missing" + } as unknown as Json); + return { + importGraphs: [], + selectorEvidence: [], + handshake: { + schemaVersion: "archcontext.codegraph-projection-handshake/v1", + packageName: REQUIRED_CODEGRAPH_PACKAGE, + requiredVersion: REQUIRED_CODEGRAPH_VERSION, + actualVersion, + binaryDigest, + availability: "unavailable", + reasonCode: "index-missing", + preSyncStatusDigest: null, + postSyncStatusDigest: null, + syncDigest: null, + indexedWorktreeDigest: null, + graphDigest + } + }; + } + + const statusTimeout = options.timeouts?.statusMs ?? 10_000; + const preStatus = readProjectionCodeGraphStatus(invocation, root, statusTimeout); + assertProjectionCodeGraphStatus(preStatus, root, "pre-sync"); + runProjectionCodeGraph(invocation, root, ["sync", root, "--quiet"], options.timeouts?.syncMs ?? 120_000); + const postStatus = readProjectionCodeGraphStatus(invocation, root, statusTimeout); + assertProjectionCodeGraphStatus(postStatus, root, "post-sync"); + assertProjectionCodeGraphClean(postStatus, "post-sync"); + const inputs = loadCapabilityCodeGraphProjectionInputs(root, model, { + ...(options.sourceFiles ? { sourceFiles: options.sourceFiles } : {}), + ...(options.footprints ? { footprints: options.footprints } : {}), + binary: binaryPath, + importNodeLimit: options.importNodeLimit, + importNodeCount: statusImportNodeCount(postStatus), + ...(options.importGraphs === false ? { importGraphs: false } : {}) + }); + const finalStatus = readProjectionCodeGraphStatus(invocation, root, statusTimeout); + assertProjectionCodeGraphStatus(finalStatus, root, "post-query"); + assertProjectionCodeGraphClean(finalStatus, "post-query"); + const postDigest = projectionCodeGraphStatusDigest(postStatus); + const finalDigest = projectionCodeGraphStatusDigest(finalStatus); + if (postDigest !== finalDigest) throw new Error("CodeGraph indexed snapshot changed during projection queries"); + const preDigest = projectionCodeGraphStatusDigest(preStatus); + const indexedWorktreeDigest = digestJson({ + sourceTreeDigest: options.sourceTreeDigest, + statusDigest: postDigest + } as unknown as Json); + const graphDigest = digestJson({ + actualVersion, + importGraphs: inputs.importGraphs, + selectorEvidence: inputs.selectorEvidence + } as unknown as Json); + return { + ...inputs, + handshake: { + schemaVersion: "archcontext.codegraph-projection-handshake/v1", + packageName: REQUIRED_CODEGRAPH_PACKAGE, + requiredVersion: REQUIRED_CODEGRAPH_VERSION, + actualVersion, + binaryDigest, + availability: "ready", + preSyncStatusDigest: preDigest, + postSyncStatusDigest: postDigest, + syncDigest: digestJson({ preSyncStatusDigest: preDigest, postSyncStatusDigest: postDigest } as unknown as Json), + indexedWorktreeDigest, + graphDigest + } + }; +} + +/** + * `.codegraph/codegraph.lock` is the provider's cross-process writer lock, held for a whole + * `codegraph init`, `index` or `sync` build. `codegraph status` is not a read of the live + * database: 1.6.2 opens it writable and replays crash recovery, recreating the secondary indexes + * and FTS triggers that a bulk build deliberately dropped. Those writes take SQLite write locks and + * starve the running build into `database is locked` (#426), so no probe may touch a database that + * is currently being built. + */ +class CodeGraphIndexBuildInProgressError extends Error { + constructor() { + super("CodeGraph index build is in progress"); + this.name = "CodeGraphIndexBuildInProgressError"; + } +} + +export function readProjectionCodeGraphStatus( + invocation: CodeGraphCliInvocation, + root: string, + timeout: number, + envOverrides?: Record +): CodeGraphProjectionStatusV1 { + if (codeGraphIndexBuildInProgress(root)) throw new CodeGraphIndexBuildInProgressError(); + const output = runProjectionCodeGraph(invocation, root, ["status", "-j", root], timeout, envOverrides); + try { + return JSON.parse(output) as CodeGraphProjectionStatusV1; + } catch (error) { + throw new Error(`CodeGraph status JSON invalid: ${error instanceof Error ? error.message : String(error)}`); + } +} + +function readProjectionHeadSha(root: string): string { + try { + const headSha = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 5_000 + }).trim(); + if (!/^[a-f0-9]{40}$/.test(headSha)) throw new Error("HEAD is not a full commit SHA"); + return headSha; + } catch (error) { + throw new Error(`Architecture projection HEAD is unreadable: ${error instanceof Error ? error.message : String(error)}`); + } +} + +export type ProjectionCodeGraphStatusIssueCode = + | "not-initialized" + | "version-mismatch" + | "project-path-mismatch" + | "index-incomplete" + | "extraction-version-mismatch" + | "worktree-mismatch" + | "pending-source-changes"; + +export interface ProjectionCodeGraphStatusIssueV1 { + code: ProjectionCodeGraphStatusIssueCode; + /** The exact text `prepareProjectionCodeFacts` throws for this issue. */ + message: string; +} + +/** + * Pure evaluation of one `codegraph status -j` snapshot against the projection contract. It + * returns every issue in evaluation order; an uninitialized index returns only that issue because + * the remaining fields carry no meaning then. + */ +export function projectionCodeGraphStatusIssues( + status: CodeGraphProjectionStatusV1, + root: string, + phase: string +): ProjectionCodeGraphStatusIssueV1[] { + const issue = (code: ProjectionCodeGraphStatusIssueCode, text: string): ProjectionCodeGraphStatusIssueV1 => ({ + code, + message: phase === "repository" ? CODE_FACTS_ISSUE_MESSAGES[code] : `CodeGraph ${phase} ${text}` + }); + if (!status.initialized) return [issue("not-initialized", "status is not initialized")]; + const issues: ProjectionCodeGraphStatusIssueV1[] = []; + if (status.version !== REQUIRED_CODEGRAPH_VERSION || status.index?.builtWithVersion !== REQUIRED_CODEGRAPH_VERSION) { + issues.push(issue("version-mismatch", "status version mismatch")); + } + if (!sameRealPath(status.projectPath, root)) issues.push(issue("project-path-mismatch", "project path mismatch")); + if (status.index?.state !== "complete" || status.index.reindexRecommended !== false || status.index.pendingRefs !== 0) { + issues.push(issue("index-incomplete", "index is not complete and compatible")); + } else if (status.index.builtWithExtractionVersion !== status.index.currentExtractionVersion) { + issues.push(issue("extraction-version-mismatch", "extraction version mismatch")); + } + if (status.worktreeMismatch !== null) issues.push(issue("worktree-mismatch", "worktree mismatch")); + return issues; +} + +function sameRealPath(left: unknown, right: string): boolean { + if (typeof left !== "string") return false; + try { + return realpathSync.native(left) === realpathSync.native(right); + } catch { + return false; + } +} + +function assertProjectionCodeGraphStatus(status: CodeGraphProjectionStatusV1, root: string, phase: string): void { + const [first] = projectionCodeGraphStatusIssues(status, root, phase); + if (first) throw new Error(first.message); +} + +function assertProjectionCodeGraphClean(status: CodeGraphProjectionStatusV1, phase: string): void { + const pending = status.pendingChanges; + if (!pending || pending.added !== 0 || pending.modified !== 0 || pending.removed !== 0) { + throw new Error(`CodeGraph ${phase} index still has pending source changes`); + } +} + +function projectionCodeGraphStatusDigest(status: CodeGraphProjectionStatusV1): string { + return digestJson({ + version: status.version, + projectPathDigest: digestJson(realpathSync.native(status.projectPath) as unknown as Json), + lastIndexed: status.lastIndexed, + fileCount: status.fileCount, + nodeCount: status.nodeCount, + edgeCount: status.edgeCount, + nodesByKind: status.nodesByKind, + languages: [...(status.languages ?? [])].sort(), + pendingChanges: status.pendingChanges, + worktreeMismatch: status.worktreeMismatch, + index: status.index + } as unknown as Json); +} + +function runProjectionCodeGraph( + invocation: CodeGraphCliInvocation, + root: string, + args: string[], + timeout: number, + envOverrides?: Record +): string { + disableCodeGraphTelemetryByDefault(); + const startedAt = performance.now(); + try { + return execFileSync(invocation.command, [...invocation.argsPrefix, ...args], { + cwd: root, + env: codeGraphChildEnv(envOverrides), + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: CODEGRAPH_OUTPUT_MAX_BYTES, + timeout + }); + } 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)); + const childError = error as { code?: string | number; status?: number | null; signal?: string | null } | null; + // Only the fixed subcommand is recorded; arguments may contain repository paths. + const diagnostics = { + subcommand: args[0], + deadlineMs: timeout, + elapsedMs: Math.round(performance.now() - startedAt), + code: childError?.code ?? null, + exitCode: childError?.status ?? null, + signal: childError?.signal ?? null + }; + throw new Error(`CodeGraph projection handshake failed: ${message}\nCodeGraph handshake diagnostics: ${JSON.stringify(diagnostics)}`); + } +} diff --git a/packages/local-runtime/codegraph-adapter/src/repository-code-facts.ts b/packages/local-runtime/codegraph-adapter/src/repository-code-facts.ts new file mode 100644 index 00000000..35573f37 --- /dev/null +++ b/packages/local-runtime/codegraph-adapter/src/repository-code-facts.ts @@ -0,0 +1,224 @@ +import { CODE_FACTS_ISSUE_MESSAGES, FailureError } from "@archcontext/contracts"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { + REQUIRED_CODEGRAPH_VERSION, + CodeGraphProviderUnavailableError, + pinnedCodeGraphProvider, + withCodeGraphReadSession, + type CodeGraphReadProvider +} from "./read-session"; +import { + ProjectionCodeFactsUnavailableError, + projectionCodeGraphStatusIssues, + type CodeGraphProjectionStatusV1, + type ProjectionCodeGraphStatusIssueV1 +} from "./projection-handshake"; + +export interface RepositoryCodeFactsPendingChangesV1 { + added: number; + modified: number; + removed: number; +} + +/** + * Read-only state of this repository's CodeGraph index (ADR-0058). `unavailable` means the pinned + * CodeGraph provider could not load (`codegraph-unavailable`) or the index exists but could not be + * opened and read read-only (`index-open-failed`); no other field is then reported. `index-missing` + * means the provider loaded but the repository has no `.codegraph` index. `ready` and `not-ready` + * carry the index facts, and `issues` lists why `not-ready` is not ready. + */ +export type RepositoryCodeFactsV1 = + | { + schemaVersion: "archcontext.repository-code-facts/v1"; + state: "unavailable"; + requiredVersion: typeof REQUIRED_CODEGRAPH_VERSION; + reasonCode: "codegraph-unavailable" | "index-open-failed"; + message: string; + } + | { + schemaVersion: "archcontext.repository-code-facts/v1"; + state: "index-missing"; + indexPresent: false; + requiredVersion: typeof REQUIRED_CODEGRAPH_VERSION; + codeGraphVersion: string; + issues: ProjectionCodeGraphStatusIssueV1[]; + } + | { + schemaVersion: "archcontext.repository-code-facts/v1"; + state: "ready" | "not-ready"; + indexPresent: true; + requiredVersion: typeof REQUIRED_CODEGRAPH_VERSION; + codeGraphVersion: string; + indexBuiltWithVersion: string | null; + extractionVersion: { builtWith: number | null; current: number | null }; + indexState: string | null; + reindexRecommended: boolean | null; + pendingRefs: number | null; + /** Source changes the index has not synced yet; `null` when they were not measured. */ + pendingChanges: RepositoryCodeFactsPendingChangesV1 | null; + issues: ProjectionCodeGraphStatusIssueV1[]; + }; + +const REPOSITORY_CODE_FACTS_SCHEMA = "archcontext.repository-code-facts/v1" as const; +const PENDING_SOURCE_CHANGES_ISSUE: ProjectionCodeGraphStatusIssueV1 = { + code: "pending-source-changes", + message: CODE_FACTS_ISSUE_MESSAGES["pending-source-changes"] +}; +const INDEX_OPEN_FAILED_MESSAGE = "CodeGraph index could not be opened and read read-only"; + +/** Facts every repository-code-facts reader reports the same way, from one status snapshot. */ +function repositoryCodeFactsFromStatus( + root: string, + codeGraphVersion: string, + status: CodeGraphProjectionStatusV1, + measuresFreshness: boolean +): RepositoryCodeFactsV1 { + const issues = projectionCodeGraphStatusIssues(status, root, "repository"); + const pending = status.pendingChanges; + // A projection runs `sync` first, so pending changes are not a projection-status issue; for a + // read-only inspection they mean the index lags the worktree. + if (measuresFreshness && (!pending || pending.added !== 0 || pending.modified !== 0 || pending.removed !== 0)) { + issues.push(PENDING_SOURCE_CHANGES_ISSUE); + } + return { + schemaVersion: REPOSITORY_CODE_FACTS_SCHEMA, + state: issues.length === 0 ? "ready" : "not-ready", + indexPresent: true, + requiredVersion: REQUIRED_CODEGRAPH_VERSION, + codeGraphVersion, + indexBuiltWithVersion: status.index?.builtWithVersion ?? null, + extractionVersion: { + builtWith: status.index?.builtWithExtractionVersion ?? null, + current: status.index?.currentExtractionVersion ?? null + }, + indexState: status.index?.state ?? null, + reindexRecommended: issues.some(issue => issue.code !== "pending-source-changes"), + pendingRefs: status.index?.pendingRefs ?? null, + pendingChanges: pending ? { added: pending.added, modified: pending.modified, removed: pending.removed } : null, + issues + }; +} + +function buildInProgressCodeFacts(codeGraphVersion: string, versionIssues: ProjectionCodeGraphStatusIssueV1[]): RepositoryCodeFactsV1 { + // Every index field would be a mid-build guess. `null` is the contract's unknown value. + return { + schemaVersion: REPOSITORY_CODE_FACTS_SCHEMA, + state: "not-ready", + indexPresent: true, + requiredVersion: REQUIRED_CODEGRAPH_VERSION, + codeGraphVersion, + indexBuiltWithVersion: null, + extractionVersion: { builtWith: null, current: null }, + indexState: null, + reindexRecommended: null, + pendingRefs: null, + pendingChanges: null, + issues: [...versionIssues, { code: "index-incomplete", message: CODE_FACTS_ISSUE_MESSAGES["index-incomplete"] }] + }; +} + +function versionIssuesOf(codeGraphVersion: string): ProjectionCodeGraphStatusIssueV1[] { + return codeGraphVersion === REQUIRED_CODEGRAPH_VERSION ? [] : [{ code: "version-mismatch", message: CODE_FACTS_ISSUE_MESSAGES["version-mismatch"] }]; +} + +function unavailableCodeFacts(reasonCode: "codegraph-unavailable" | "index-open-failed", message: string): RepositoryCodeFactsV1 { + return { schemaVersion: REPOSITORY_CODE_FACTS_SCHEMA, state: "unavailable", requiredVersion: REQUIRED_CODEGRAPH_VERSION, reasonCode, message }; +} + +export interface RepositoryCodeFactsInspectionOptions { + /** The pinned provider of this installation unless a test injects one. */ + provider?: CodeGraphReadProvider; +} + +/** + * Inspects the repository's code-facts state through one read-only session (ADR-0058): no CodeGraph + * command, no `git status`, no writable open. It measures freshness with CodeGraph's own change + * test and never throws for an unavailable provider or index. While a provider build holds the + * writer lock it does not open the index (#426). + */ +export function inspectRepositoryCodeFacts(root: string, options: RepositoryCodeFactsInspectionOptions = {}): RepositoryCodeFactsV1 { + return inspectRepositoryCodeFactsTier(root, "freshness", options).facts; +} + +/** + * When a provider build held the writer lock, at open or after the reads, or the index revision + * changed while the session read it (design G4), the facts are the not-ready build-in-progress + * answer and nothing read is reported. `retryable` is true then, unless the loaded provider has the + * wrong version: a retry after the build cannot fix that. + */ +function inspectRepositoryCodeFactsTier(root: string, tier: "compatibility" | "freshness", options: RepositoryCodeFactsInspectionOptions): { facts: RepositoryCodeFactsV1; retryable: boolean } { + let provider: CodeGraphReadProvider; + let codeGraphVersion: string; + try { + provider = options.provider ?? pinnedCodeGraphProvider(); + codeGraphVersion = provider.version; + } catch (error) { + return { facts: unavailableCodeFacts("codegraph-unavailable", error instanceof CodeGraphProviderUnavailableError ? error.message : "CodeGraph provider could not be loaded"), retryable: false }; + } + const versionIssues = versionIssuesOf(codeGraphVersion); + const moving = { facts: buildInProgressCodeFacts(codeGraphVersion, versionIssues), retryable: versionIssues.length === 0 }; + try { + return withCodeGraphReadSession(root, (opened): { facts: RepositoryCodeFactsV1; retryable: boolean } => { + switch (opened.state) { + case "index-missing": + return { facts: { schemaVersion: REPOSITORY_CODE_FACTS_SCHEMA, state: "index-missing", indexPresent: false, requiredVersion: REQUIRED_CODEGRAPH_VERSION, codeGraphVersion, issues: versionIssues }, retryable: false }; + case "index-open-failed": + return { facts: unavailableCodeFacts("index-open-failed", INDEX_OPEN_FAILED_MESSAGE), retryable: false }; + case "build-in-progress": + return moving; + case "open": { + const compatibility = opened.session.compatibility(); + const pendingChanges = tier === "freshness" ? opened.session.freshness() : undefined; + // Facts read across a build or a sync are not one index state (design G4). + if (!opened.session.unchangedSinceOpen()) return moving; + const status: CodeGraphProjectionStatusV1 = { + initialized: true, + version: compatibility.version, + projectPath: compatibility.projectPath, + lastIndexed: compatibility.lastIndexed, + ...(pendingChanges === undefined ? {} : { pendingChanges }), + // `openSync(root)` never walks up to another worktree's index (ADR-0058). + worktreeMismatch: null, + index: { + ...(compatibility.builtWithVersion === null ? {} : { builtWithVersion: compatibility.builtWithVersion }), + ...(compatibility.builtWithExtractionVersion === null ? {} : { builtWithExtractionVersion: compatibility.builtWithExtractionVersion }), + currentExtractionVersion: compatibility.currentExtractionVersion, + reindexRecommended: compatibility.reindexRecommended, + ...(compatibility.state === null ? {} : { state: compatibility.state }), + pendingRefs: compatibility.pendingRefs + } + }; + return { facts: repositoryCodeFactsFromStatus(root, codeGraphVersion, status, tier === "freshness"), retryable: false }; + } + } + }, { provider }); + } catch { + // The index opened but a read of it failed; no provider text crosses this boundary. + return { facts: unavailableCodeFacts("index-open-failed", INDEX_OPEN_FAILED_MESSAGE), retryable: false }; + } +} + +/** + * Read-only usability gate (ADR-0058): compatibility facts only. Pending edits affect freshness, not + * index compatibility, so the gate reads no source file. + */ +export function assertRepositoryCodeFactsReady(root: string, options: RepositoryCodeFactsInspectionOptions = {}): void { + const { facts, retryable } = inspectRepositoryCodeFactsTier(root, "compatibility", options); + if (facts.state === "index-missing") throw new ProjectionCodeFactsUnavailableError("index-missing"); + if (facts.state === "unavailable") { + if (facts.reasonCode === "index-open-failed") throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "index-open-failed"); + throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable", {}, { action: "inspect-diagnostics", retryable: false }); + } + // The compatibility tier never measures pending changes, so every not-ready answer refuses. + if (facts.state === "not-ready") { + const action = facts.codeGraphVersion !== REQUIRED_CODEGRAPH_VERSION ? "install-compatible-codegraph" + : "codegraph-index"; + throw new FailureError("AC_CODE_FACTS_NOT_READY", "code-facts-not-ready", { issues: facts.issues }, { action, ...(retryable ? { retryable: true } : {}) }); + } +} + +/** True when this workspace carries a CodeGraph index the CLI can be pointed at. */ +export function codeGraphIndexAvailable(workspaceRoot: string): boolean { + return existsSync(join(workspaceRoot, ".codegraph")); +} diff --git a/packages/local-runtime/codegraph-adapter/src/selector-index.ts b/packages/local-runtime/codegraph-adapter/src/selector-index.ts new file mode 100644 index 00000000..4fba008e --- /dev/null +++ b/packages/local-runtime/codegraph-adapter/src/selector-index.ts @@ -0,0 +1,372 @@ +import { createHash } from "node:crypto"; +import type { Edge as CodeGraphEdge, Node as CodeGraphNode } from "@colbymchenry/codegraph"; +import { codeGraphBundle, type CodeGraphSdk } from "./read-session"; +import { listGitVisibleFiles } from "@archcontext/local-runtime/git-adapter"; +import { + loadCapabilitySourceFootprints, + nativeNodeSource, + type ArchitectureSelectorEvidenceV1, + type CapabilitySourceFootprint, + type CapabilityImportGraph, + type NativeModel +} from "@archcontext/core/projection-engine"; +import { DEFAULT_CODEGRAPH_BINARY, disableCodeGraphTelemetryByDefault } from "./cli-provider"; +import { CODEGRAPH_IMPORT_NODE_READ_LIMIT, capabilityImportGraphs, resolveImportTarget, type ProjectionImportPopulationV1 } from "./import-population"; +import { codeGraphIndexAvailable } from "./repository-code-facts"; + +// The SDK stays in its installed package so its platform/cache resolver stays relocatable. It is +// resolved only when a CodeGraph operation needs it; the stateless native export never loads it. +function codeGraphSdk(): CodeGraphSdk { + return codeGraphBundle().sdk; +} + +export interface CapabilityCodeGraphProjectionInputs { + importGraphs: CapabilityImportGraph[]; + selectorEvidence: ArchitectureSelectorEvidenceV1[]; + /** + * The repository import population the import-graph read returned, with unresolved specifiers + * kept, exactly as `repositoryImportPairs` reports pairs. Present only when that read ran (an + * index exists and a node declares a footprint). Freshness is the handshake's, so it carries no + * availability of its own. Not part of `graphDigest`: it is the same read the graphs come from. + */ + importPopulation?: ProjectionImportPopulationV1; +} + +export interface CodeGraphSelectorIndex { + getNodesByName(name: string): CodeGraphNode[]; + getOutgoingEdges(nodeId: string): CodeGraphEdge[]; + getNode(nodeId: string): CodeGraphNode | null; + /** Read only for a call to a runtime built-in name, to find the source file's imports. */ + getNodesInFile(filePath: string): CodeGraphNode[]; + close(): void; +} + +/** + * The packaged CodeGraph npm shim downloads its platform bundle from GitHub Releases when the + * optional platform package is absent. `CODEGRAPH_NO_DOWNLOAD` (read by the shim's selfHealBundle) + * turns that fetch off, so the snapshot's CLI probe stays local whatever the egress mode is. + */ +const CODEGRAPH_NO_DOWNLOAD_ENV: Record = { CODEGRAPH_NO_DOWNLOAD: "1" }; + +export interface CodeGraphSymbolDeclaration { + name: string; + qualifiedName: string; + startLine: number; + endLine: number; +} + +/** Read-only symbol lookups for `docs anchors` (#298). */ +export interface CodeGraphSymbolReader { + /** True when CodeGraph has a parser for this path's language. */ + supportsPath(path: string): boolean; + /** + * Every declaration parsed from `content` now; the index is not read, so it cannot be stale. + * `undefined` when CodeGraph cannot parse the file. + */ + declarationsInSource(path: string, content: string): CodeGraphSymbolDeclaration[] | undefined; + /** Declarations named `name` in the CodeGraph index. */ + declarationsNamed(name: string): CodeGraphSymbolDeclaration[]; + /** + * The declarations that the index holds for `path`, or `undefined` when the index does not hold + * `bytes` as the file's content. The test is CodeGraph's own change test: the SHA-256 of the + * UTF-8 text equals the recorded content hash. + */ + indexedDeclarations(path: string, bytes: Uint8Array): CodeGraphIndexedDeclaration[] | undefined; + /** + * The resolved targets of the `calls` and `references` edges from each id, read in one batch and + * keyed by source id. A reference that CodeGraph left unresolved has no target and is absent. + */ + callees(ids: readonly string[]): Map; + /** The ids that each id contains, directly or nested, by the index's `contains` edges. */ + contained(ids: readonly string[]): Map>; + close(): void; +} + +/** A declaration as the CodeGraph index records it. */ +export interface CodeGraphIndexedDeclaration extends CodeGraphSymbolDeclaration { + id: string; + kind: string; + path: string; +} + +export interface CodeGraphCallee extends CodeGraphIndexedDeclaration { + /** The kinds of the declarations that contain this one, by `contains` edges. */ + containerKinds: string[]; +} + +export type CodeGraphSymbolReaderResult = + | { availability: "ready"; reader: CodeGraphSymbolReader } + | { availability: "unavailable"; reasonCode: "index-missing" | "index-open-failed" }; + +/** Kinds that name a reference or a scope, not a declaration a document can cite. */ +const NON_DECLARATION_KINDS: ReadonlySet = new Set(["file", "import", "export", "parameter"]); + +/** + * Opens the workspace CodeGraph index read-only for symbol lookups. It never opens the index + * writable. When the read-only open fails, the result is `index-open-failed`. Whether a read-only + * open works with no live holder depends on the SQLite build: Node's `node:sqlite` opens it and + * creates the WAL files, and the macOS system SQLite that Bun uses there refuses it. + */ +export function openCodeGraphSymbolReader( + workspaceRoot: string, + openIndex: (root: string, options: { readOnly: true }) => ReturnType = (root, options) => codeGraphSdk().CodeGraph.openSync(root, options) +): CodeGraphSymbolReaderResult { + if (!codeGraphIndexAvailable(workspaceRoot)) return { availability: "unavailable", reasonCode: "index-missing" }; + disableCodeGraphTelemetryByDefault(); + let graph: ReturnType; + try { + graph = openIndex(workspaceRoot, { readOnly: true }); + } catch { + return { availability: "unavailable", reasonCode: "index-open-failed" }; + } + const declarations = (nodes: CodeGraphNode[]): CodeGraphSymbolDeclaration[] => nodes + .filter((node) => !NON_DECLARATION_KINDS.has(node.kind)) + .map((node) => ({ name: node.name, qualifiedName: node.qualifiedName, startLine: node.startLine, endLine: node.endLine })) + .sort((left, right) => left.startLine - right.startLine || left.qualifiedName.localeCompare(right.qualifiedName)); + return { + availability: "ready", + reader: { + supportsPath: (path) => codeGraphSdk().isLanguageSupported(codeGraphSdk().detectLanguage(path)), + declarationsInSource: (path, content) => { + try { + return declarations(graph.extractFromSource(path, content).nodes); + } catch { + return undefined; + } + }, + declarationsNamed: (name) => declarations(graph.getNodesByName(name).filter((node) => node.name === name)), + indexedDeclarations: (path, bytes) => { + const record = graph.getFile(path); + const text = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("utf8"); + if (record === null || record.contentHash !== createHash("sha256").update(text).digest("hex")) return undefined; + return graph.getNodesInFile(path).filter((node) => !NON_DECLARATION_KINDS.has(node.kind)).map(indexedDeclaration); + }, + callees: (ids) => { + const edges = graph.getOutgoingEdgesFrom(ids, ["calls", "references"]); + const targetIds = [...new Set(edges.map((edge) => edge.target))]; + const targets = graph.getNodesByIds(targetIds); + const containment = graph.getIncomingEdgesTo(targetIds, ["contains"]); + const containers = graph.getNodesByIds([...new Set(containment.map((edge) => edge.source))]); + const containerKinds = new Map(); + for (const edge of containment) { + const container = containers.get(edge.source); + if (container !== undefined) containerKinds.set(edge.target, [...containerKinds.get(edge.target) ?? [], container.kind]); + } + const bySource = new Map>(); + for (const edge of edges) { + const target = targets.get(edge.target); + if (target === undefined) continue; + let entries = bySource.get(edge.source); + if (entries === undefined) bySource.set(edge.source, entries = new Map()); + entries.set(target.id, { ...indexedDeclaration(target), containerKinds: containerKinds.get(target.id) ?? [] }); + } + return new Map([...bySource].map(([source, entries]) => [source, [...entries.values()]])); + }, + contained: (ids) => { + const result = new Map(ids.map((id) => [id, new Set()])); + // One edge read per nesting level, for all ids at once. + let frontier = ids.map((id) => [id, id] as const); + while (frontier.length > 0) { + const roots = new Map(); + for (const [root, node] of frontier) roots.set(node, [...roots.get(node) ?? [], root]); + frontier = graph.getOutgoingEdgesFrom([...roots.keys()], ["contains"]) + .flatMap((edge) => roots.get(edge.source)!.map((root) => [root, edge.target] as const)) + .filter(([root, node]) => !result.get(root)!.has(node)); + for (const [root, node] of frontier) result.get(root)!.add(node); + } + return result; + }, + close: () => graph.close() + } + }; +} + +function indexedDeclaration(node: CodeGraphNode): CodeGraphIndexedDeclaration { + return { id: node.id, kind: node.kind, path: node.filePath, name: node.name, qualifiedName: node.qualifiedName, startLine: node.startLine, endLine: node.endLine }; +} + +/** + * `reader.declarationsInSource` over the content that `contents` returns, each path parsed at most + * once. `undefined` for a path whose language CodeGraph has no parser for, or that it cannot parse. + */ +export function memoizedSourceDeclarations(reader: CodeGraphSymbolReader, contents: (path: string) => string): (path: string) => CodeGraphSymbolDeclaration[] | undefined { + const parsed = new Map(); + return (path) => { + if (!parsed.has(path)) parsed.set(path, reader.supportsPath(path) ? reader.declarationsInSource(path, contents(path)) : undefined); + return parsed.get(path); + }; +} + +/** + * Measures import facts and exact node-v2 entrypoint→sink selectors. CodeGraph proves only the + * declared binding: it never chooses seed symbols, labels, order, branches or outcomes. + * + * With no index present this returns empty evidence. The semantic compiler then reports + * `unprovable`; it never degrades into a path-based diagram. + */ +export function loadCapabilityCodeGraphProjectionInputs( + root: string, + model: NativeModel, + options: { + /** The Git-visible files (`listGitVisibleFiles`); read once from `root` when omitted. */ + sourceFiles?: readonly string[]; + /** Footprints resolved over the Git-visible files; resolved from `root` when omitted. */ + footprints?: readonly CapabilitySourceFootprint[]; + binary?: string; + /** Safety cap on the import nodes read; `CODEGRAPH_IMPORT_NODE_READ_LIMIT` when omitted. */ + importNodeLimit?: number; + /** + * Import nodes in the index, from the `codegraph status` the caller asserted. The read is sized + * from it. `null` or omitted is an unknown count: the read then asks for the limit. + */ + importNodeCount?: number | null; + selectorIndexFactory?: (root: string, options: { readOnly: boolean }) => CodeGraphSelectorIndex; + /** Read consumers open selectors read-only and prohibit the import-query shim from downloading. */ + readOnly?: boolean; + /** `false` skips the import-graph query; `flows check` reads selector evidence only. */ + importGraphs?: boolean; + } = {} +): CapabilityCodeGraphProjectionInputs { + let visibleFiles: readonly string[] | undefined = options.sourceFiles; + const sourceFiles = () => visibleFiles ??= listGitVisibleFiles(root); + const declaresFootprint = options.importGraphs !== false + && model.nodes.some((node) => (nativeNodeSource(node)?.include ?? []).length > 0); + const footprints = declaresFootprint + ? options.footprints ?? loadCapabilitySourceFootprints(root, model, sourceFiles()) + : []; + const entrypointNodes = model.nodes + .map((node) => ({ nodeId: node.id, entrypoints: nativeNodeSource(node)?.entrypoints ?? [] })) + .filter((entry) => entry.entrypoints.length > 0) + .sort((left, right) => left.nodeId.localeCompare(right.nodeId)); + if (footprints.length === 0 && entrypointNodes.length === 0) return { importGraphs: [], selectorEvidence: [] }; + if (!codeGraphIndexAvailable(root)) return { importGraphs: [], selectorEvidence: [] }; + + disableCodeGraphTelemetryByDefault(); + const binary = options.binary ?? DEFAULT_CODEGRAPH_BINARY; + const importTargets = new Set(sourceFiles()); + const imports = footprints.length > 0 + ? capabilityImportGraphs(root, binary, importTargets, footprints, options.importNodeLimit ?? CODEGRAPH_IMPORT_NODE_READ_LIMIT, options.importNodeCount ?? null, + options.readOnly ? CODEGRAPH_NO_DOWNLOAD_ENV : undefined) + : undefined; + const importGraphs = imports?.importGraphs ?? []; + const selectorIndex = entrypointNodes.length > 0 + ? (options.selectorIndexFactory ?? ((path, openOptions) => codeGraphSdk().CodeGraph.openSync(path, openOptions)))(root, { readOnly: options.readOnly === true }) + : undefined; + let selectorEvidence: ArchitectureSelectorEvidenceV1[]; + try { + selectorEvidence = entrypointNodes.flatMap((entry) => entry.entrypoints.flatMap((entrypoint) => + entrypoint.symbols.flatMap((source) => source.sinks.map((sink) => exactSelectorEvidence(importTargets, selectorIndex!, { + nodeId: entry.nodeId, + entrypointId: entrypoint.id, + sourcePath: entrypoint.path, + sourceSymbol: source.name, + sinkId: sink.id, + sinkPath: sink.path, + sinkSymbol: sink.symbol + }))))); + } finally { + selectorIndex?.close(); + } + return { importGraphs, selectorEvidence, ...(imports ? { importPopulation: imports.importPopulation } : {}) }; +} + +/** + * Runtime built-in names a call can carry without any repository import: the named exports of + * `node:path`, `node:fs`, `node:child_process`, `node:os`, `node:url` and `node:crypto` that code + * imports by name, plus globals. CodeGraph resolves a call by name, so `resolve()` from + * `node:path` can appear as a `calls` edge into a repository file that defines `resolve` (#300). + * The list is explicit, not read from `node:module` at run time, so a proof does not change with + * the Node or Bun version that computes it. Add a name only when it is such a built-in export. + */ +export const RUNTIME_BUILTIN_CALL_NAMES: ReadonlySet = new Set([ + // node:path + "basename", "dirname", "extname", "format", "isAbsolute", "join", "normalize", "parse", "relative", "resolve", "toNamespacedPath", + // node:fs + "access", "accessSync", "appendFile", "appendFileSync", "chmod", "chmodSync", "close", "closeSync", "copyFile", "copyFileSync", + "cp", "cpSync", "createReadStream", "createWriteStream", "exists", "existsSync", "fsync", "fsyncSync", "lstat", "lstatSync", + "mkdir", "mkdirSync", "mkdtemp", "mkdtempSync", "open", "openSync", "opendir", "opendirSync", "read", "readFile", "readFileSync", + "readSync", "readdir", "readdirSync", "readlink", "readlinkSync", "realpath", "realpathSync", "rename", "renameSync", "rm", + "rmSync", "rmdir", "rmdirSync", "stat", "statSync", "symlink", "symlinkSync", "unlink", "unlinkSync", "watch", "write", + "writeFile", "writeFileSync", "writeSync", + // node:child_process + "exec", "execFile", "execFileSync", "execSync", "fork", "spawn", "spawnSync", + // node:os, node:url, node:crypto + "homedir", "hostname", "platform", "tmpdir", "fileURLToPath", "pathToFileURL", "createHash", "randomBytes", "randomUUID", + // globals + "clearInterval", "clearTimeout", "fetch", "queueMicrotask", "setInterval", "setTimeout", "structuredClone" +]); + +function exactSelectorEvidence( + gitVisibleFiles: ReadonlySet, + index: CodeGraphSelectorIndex, + selector: Omit +): ArchitectureSelectorEvidenceV1 { + const sources = indexedSymbolsAt(index, selector.sourceSymbol, selector.sourcePath); + // A selector names one symbol. Two indexed declarations of the sink name in the sink file (for + // example a function and a constant) make the sink ambiguous even when only one is called. + const sinkDeclarations = indexedSymbolsAt(index, selector.sinkSymbol, selector.sinkPath); + const exactEdges = sources.flatMap((source) => index.getOutgoingEdges(source.id) + .filter((edge) => edge.kind === "calls" || edge.kind === "references") + .flatMap((edge) => { + const target = index.getNode(edge.target); + return target + && target.name === selector.sinkSymbol + && normalizedIndexPath(target.filePath) === normalizedIndexPath(selector.sinkPath) + ? [{ edge, target }] + : []; + })); + const calls = exactEdges.filter(({ edge }) => edge.kind === "calls"); + const falseBuiltinCalls = calls.length > 0 && isFalseBuiltinCall(gitVisibleFiles, index, sources[0]!.filePath, selector); + const exactCalls = falseBuiltinCalls ? [] : calls; + const references = exactCalls.length === 0 ? exactEdges.filter(({ edge }) => edge.kind === "references") : []; + const sinkIdentities = new Set(exactCalls.map(({ target }) => target.id)); + const ambiguous = sources.length > 1 || sinkDeclarations.length > 1 || sinkIdentities.size > 1; + return { + ...selector, + matched: !ambiguous && sinkIdentities.size === 1, + ambiguous, + // The structured SDK queries above are uncapped index lookups (name index plus the full + // outgoing-edge set of each source), so this producer enumerates the whole population and + // can never drop a match; only the CLI-backed import query has a limit to report. + truncated: false, + callSites: edgeSites(selector.sourcePath, exactCalls), + ...(falseBuiltinCalls ? { rejectedBuiltinCallSites: edgeSites(selector.sourcePath, calls) } : {}), + ...(references.length > 0 ? { referenceSites: edgeSites(selector.sourcePath, references) } : {}) + }; +} + +function indexedSymbolsAt(index: CodeGraphSelectorIndex, name: string, path: string): CodeGraphNode[] { + return index.getNodesByName(name).filter((node) => normalizedIndexPath(node.filePath) === normalizedIndexPath(path)); +} + +function edgeSites(sourcePath: string, edges: Array<{ edge: CodeGraphEdge }>): Array<{ path: string; line?: number }> { + return [...new Map(edges.map(({ edge }) => { + const site = { path: sourcePath, ...(edge.line === undefined ? {} : { line: edge.line }) }; + return [`${site.path}\0${site.line ?? ""}`, site] as const; + })).values()].sort((left, right) => left.path.localeCompare(right.path) || (left.line ?? 0) - (right.line ?? 0)); +} + +/** + * A call to a runtime built-in name proves nothing unless the source file imports the sink file: + * otherwise CodeGraph may have matched a built-in (`resolve` from `node:path`) to a same-named + * repository function. A call inside the sink file needs no import. Only relative specifiers are + * resolved, so a call through a package alias or a re-exporting barrel is not proven this way. + */ +function isFalseBuiltinCall( + gitVisibleFiles: ReadonlySet, + index: CodeGraphSelectorIndex, + indexedSourcePath: string, + selector: { sourcePath: string; sinkPath: string; sinkSymbol: string } +): boolean { + if (!RUNTIME_BUILTIN_CALL_NAMES.has(selector.sinkSymbol)) return false; + const sourcePath = normalizedIndexPath(selector.sourcePath); + const sinkPath = normalizedIndexPath(selector.sinkPath); + if (sourcePath === sinkPath) return false; + return !index.getNodesInFile(indexedSourcePath).some((node) => node.kind === "import" + // A TypeScript ESM specifier names the emitted `.js` file; try the extensionless form too. + && [node.name, node.name.replace(/\.[cm]?js$/, "")].some((specifier) => resolveImportTarget(gitVisibleFiles, sourcePath, specifier) === sinkPath)); +} + +export function normalizedIndexPath(path: string): string { + return path.replaceAll("\\", "/").replace(/^\.\//, ""); +} diff --git a/packages/local-runtime/codegraph-adapter/src/snapshot.ts b/packages/local-runtime/codegraph-adapter/src/snapshot.ts new file mode 100644 index 00000000..da0814a5 --- /dev/null +++ b/packages/local-runtime/codegraph-adapter/src/snapshot.ts @@ -0,0 +1,298 @@ +import { FailureError } from "@archcontext/contracts"; +import { execFileSync, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, isAbsolute, join, relative } from "node:path"; +import { REQUIRED_CODEGRAPH_VERSION, codeGraphBundle, pinnedCodeGraphProvider } from "./read-session"; +import { readSnapshotQueries, type SnapshotQueryRequest, type SnapshotQueryReader } from "./snapshot-query"; +import { listGitVisibleFiles } from "@archcontext/local-runtime/git-adapter"; +import { nativeNodeSource, type NativeModel } from "@archcontext/core/projection-engine"; +import { CODEGRAPH_OUTPUT_MAX_BYTES, isExecutablePath } from "./cli-provider"; +import { inspectRepositoryCodeFacts } from "./repository-code-facts"; +import { normalizedIndexPath } from "./selector-index"; + +/** + * The SDK and its Node executable must come from the same provider-selected platform bundle. + * Only the private snapshot reader uses it; S4 of #574 removes both. + */ +export function codeGraphSnapshotRuntime(): { node: string; sdk: string } { + let bundle: ReturnType; + let version: string; + try { + bundle = codeGraphBundle(); + version = pinnedCodeGraphProvider().version; + } catch { + throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable"); + } + if (version !== REQUIRED_CODEGRAPH_VERSION) throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable"); + const node = join(bundle.platformRoot, process.platform === "win32" ? "node.exe" : "node"); + if (!isExecutablePath(node)) throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable"); + return { node, sdk: join(bundle.dist, "index.js") }; +} + +function codeGraphSourceScanner(): (root: string) => string[] { + try { return pinnedCodeGraphProvider().scanSourceFiles; } + catch { throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable"); } +} + +/** Bytes bound the copied source/config, Git metadata, database and WAL; entries also count the uncopied Git objects. */ +export const CODEGRAPH_SNAPSHOT_MAX_BYTES = 512 * 1024 * 1024; +export const CODEGRAPH_SNAPSHOT_MAX_ENTRIES = 100_000; + +export function codeGraphSnapshotQueries(nodes: NativeModel["nodes"], purpose: "flow" | "ref" = "flow"): SnapshotQueryRequest { + const names = new Set(); + const sources = new Map(); + for (const node of nodes) for (const entry of nativeNodeSource(node)?.entrypoints ?? []) { + for (const symbol of entry.symbols) for (const sink of symbol.sinks) { + names.add(symbol.name); names.add(sink.symbol); + const path = normalizedIndexPath(entry.path); + sources.set(`${path}\0${symbol.name}`, { path, name: symbol.name }); + } + } + return { names: [...names].sort(), sources: purpose === "flow" ? [...sources.values()] : [], includeFiles: purpose === "ref" }; +} + +/** + * Runs synchronous readers against a private, ready CodeGraph snapshot. Provider status opens + * writable and SQLite readOnly can create WAL/SHM files, so neither ever sees the original root. + * The provider-enumerated filesystem mirror preserves source discovery and configuration rather + * than approximating its source extensions, ignores or content hashes. Nothing is re-indexed. + */ +export function withExistingCodeGraphSnapshot( + root: string, + queries: SnapshotQueryRequest, + callback: (graph: SnapshotQueryReader) => T +): T { + const originalRoot = realpathSync.native(root); + const redirectedGitInputs = ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CONFIG", "GIT_CONFIG_SYSTEM", "GIT_CONFIG_GLOBAL", "GIT_CONFIG_COUNT", "GIT_CONFIG_PARAMETERS"]; + if (redirectedGitInputs.some(key => process.env[key]) || process.env.CODEGRAPH_DIR) { + throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); + } + const indexDir = join(originalRoot, ".codegraph"); + if (!existsSync(join(indexDir, "codegraph.db"))) throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "index-missing"); + if (lstatSync(indexDir).isSymbolicLink()) throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); + const gitPath = (flag: string) => realpathSync.native(execFileSync("git", ["rev-parse", "--path-format=absolute", flag], { + cwd: originalRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 5_000 + }).trim()); + const gitDir = gitPath("--git-dir"); + const commonDir = gitPath("--git-common-dir"); + assertSnapshotGitReadSafety(originalRoot); + const temporaryParent = realpathSync.native(tmpdir()); + const temporaryRelative = relative(originalRoot, temporaryParent); + if (!temporaryRelative || (temporaryRelative !== ".." && !temporaryRelative.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) && !isAbsolute(temporaryRelative))) { + throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); + } + const temporaryRoot = mkdtempSync(join(temporaryParent, "archctx-codegraph-snapshot-")); + try { + const snapshotRoot = join(temporaryRoot, "repository"); + mkdirSync(snapshotRoot); + const before = mirrorCodeGraphInputs(originalRoot, gitDir, commonDir, snapshotRoot); + if (before !== mirrorCodeGraphInputs(originalRoot, gitDir, commonDir)) { + throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-changed"); + } + const scan = codeGraphSourceScanner(); + if (JSON.stringify([...scan(originalRoot)].sort()) !== JSON.stringify([...scan(snapshotRoot)].sort())) { + throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); + } + const ready = inspectRepositoryCodeFacts(snapshotRoot); + if (ready.state !== "ready") { + // C2: an unopenable index keeps its own reason; any other unavailable answer is code-facts-unavailable. + if (ready.state === "unavailable") throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", ready.reasonCode === "index-open-failed" ? "index-open-failed" : "code-facts-unavailable"); + throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-read-not-ready", { issues: ready.issues }); + } + // Readiness above opened the copied index in-process, through the read session, and closed it. + // The queries below still run in the provider's Node child, whose exit releases its SQLite + // handles before the copy is removed. S4 of #574 removes this path. + const graph = readSnapshotQueries(codeGraphSnapshotRuntime(), snapshotRoot, queries); + const result = callback(graph); + if (result !== null && typeof result === "object" && "then" in result) { + throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); + } + if (before !== mirrorCodeGraphInputs(originalRoot, gitDir, commonDir)) { + throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-changed"); + } + return result; + } catch (error) { + if (error instanceof FailureError) throw error; + throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable"); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + +function assertSnapshotGitReadSafety(root: string): void { + const options = { cwd: root, stdio: "pipe" as const, timeout: 5_000, maxBuffer: CODEGRAPH_OUTPUT_MAX_BYTES }; + const config = spawnSync("git", ["config", "--get-regexp", "^(core\\.(worktree|fsmonitor)|include\\.|include[iI]f\\.)"], { ...options, encoding: "utf8" }); + if ((config.status !== 0 && config.status !== 1) || config.stdout.trim()) { + throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); + } + // Provider readiness runs git status, which may execute clean/process filters on tracked + // content. Configuration alone (e.g. a system LFS installation) does not select a filter. + // Git owns attribute evaluation, including macros, nested attributes and info/attributes. + let attributes: string[]; + let ambiguousDrivers: Set; + try { + const tracked = execFileSync("git", ["ls-files", "--cached", "-z"], options); + if (tracked.length === 0) return; + const output = execFileSync("git", ["check-attr", "-z", "--stdin", "filter"], { ...options, input: tracked }).toString("utf8"); + attributes = output.split("\0"); + if (attributes.pop() !== "" || attributes.length % 3 !== 0) throw new Error("Invalid attribute response"); + // check-attr prints literal driver names "unset"/"unspecified" identically to its + // sentinels. A configured driver with either name makes that response ambiguous. + const drivers = spawnSync("git", ["config", "--name-only", "--get-regexp", "^filter\\.(unspecified|unset)\\.(clean|process)$"], { ...options, encoding: "utf8" }); + if (drivers.status !== 0 && drivers.status !== 1) throw new Error("Cannot inspect filter drivers"); + ambiguousDrivers = new Set(drivers.stdout.trim().split(/\r?\n/).filter(Boolean).map(key => key.split(".")[1]!)); + } catch { + throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-unavailable"); + } + for (let index = 0; index < attributes.length; index += 3) { + if (attributes[index + 1] !== "filter" || !["unspecified", "unset"].includes(attributes[index + 2]!) || ambiguousDrivers.has(attributes[index + 2]!)) { + throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); + } + } +} + +/** + * A repository-local core.hooksPath replaces any copied, global or system value, so provider Git + * operations (an index refresh runs post-index-change) cannot execute repository or user hooks. + * A higher-precedence source such as config.worktree would beat the pin, so the value Git will + * actually use must be exactly the pinned directory. + */ +function pinSnapshotHooks(snapshotRepository: string): void { + const gitDir = join(snapshotRepository, ".git"); + const hooks = join(gitDir, "archctx-empty-hooks"); + mkdirSync(hooks); + const path = process.platform === "win32" ? hooks.replaceAll("\\", "/") : hooks; + const options = { cwd: snapshotRepository, encoding: "utf8" as const, stdio: "pipe" as const, timeout: 5_000 }; + const pinned = spawnSync("git", ["config", "--file", join(gitDir, "config"), "--replace-all", "core.hooksPath", path], options); + const effective = spawnSync("git", ["config", "--get", "core.hooksPath"], options); + if (pinned.status !== 0 || effective.status !== 0 || effective.stdout.replace(/\r?\n$/, "") !== path) { + throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); + } +} + +function mirrorCodeGraphInputs(root: string, gitDir: string, commonDir: string, destination?: string): string { + const hash = createHash("sha256"); + const measured = { sourceBytes: 0, sourceEntries: 0, gitBytes: 0, gitEntries: 0, gitObjectEntries: 0, indexBytes: 0, indexEntries: 0 }; + let bytes = 0; + let entries = 0; + // After the first cap trips nothing more is read or copied; the walk only completes the counts. + let limit: "bytes" | "entries" | undefined; + const fingerprint = (source: string, target: string) => { + const stat = lstatSync(source, { bigint: true }); + if (++entries > CODEGRAPH_SNAPSHOT_MAX_ENTRIES) limit ??= "entries"; + hash.update(JSON.stringify([source, target, String(stat.ino), String(stat.size), String(stat.mtimeNs), String(stat.ctimeNs)])); + return stat; + }; + const visit = (part: "source" | "git" | "index", source: string, target: string, skip: ReadonlySet = new Set()) => { + const stat = fingerprint(source, target); + measured[`${part}Entries`]++; + if (stat.isDirectory()) { + if (destination && !limit) mkdirSync(join(destination, target), { recursive: true }); + for (const name of readdirSync(source).sort()) { + if (!skip.has(name)) visit(part, join(source, name), join(target, name)); + } + } else if (stat.isFile()) { + if (basename(target) === ".git") throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); + measured[`${part}Bytes`] += Number(stat.size); + bytes += Number(stat.size); + if (bytes > CODEGRAPH_SNAPSHOT_MAX_BYTES) limit ??= "bytes"; + if (limit) return; + // Bound the actual read as well as the prior stat: a growing file must not bypass the cap. + const data = Buffer.alloc(Number(stat.size)); + const fd = openSync(source, "r"); + try { + let offset = 0; + while (offset < data.length) { + const count = readSync(fd, data, offset, data.length - offset, offset); + if (count === 0) throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-changed"); + offset += count; + } + if (readSync(fd, Buffer.alloc(1), 0, 1, offset) !== 0) throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-changed"); + } finally { closeSync(fd); } + hash.update(data); + if (destination) { + mkdirSync(dirname(join(destination, target)), { recursive: true }); + writeFileSync(join(destination, target), data, { mode: Number(stat.mode) & 0o777 }); + } + } else { + // Following a symlink can silently omit an external source/config population or let a + // provider write outside the mirror. Such layouts require a separate snapshot contract. + throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); + } + }; + // Objects are fingerprinted, never read or copied: a write to the original store changes the + // fingerprint, and the copy grows with what the read needs rather than with history. + const visitObjects = (source: string, target: string) => { + const stat = fingerprint(source, target); + measured.gitObjectEntries++; + if (stat.isDirectory()) { + for (const name of readdirSync(source).sort()) visitObjects(join(source, name), join(target, name)); + } else if (!stat.isFile()) { + throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); + } + }; + // The snapshot reads the original objects read-only through a snapshot-local alternate. + // An original alternates chain, or a path Git cannot store in that file, is a separate layout. + // Repository hooks are not part of the read; the snapshot pins its own empty hooks directory. + const visitRepository = (source: string, target: string, skip: ReadonlySet) => { + const objects = join(source, "objects"); + if (existsSync(join(objects, "info/alternates"))) throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); + visit("git", source, target, new Set([...skip, "objects", "hooks", "archctx-empty-hooks"])); + visitObjects(objects, join(target, "objects")); + const original = realpathSync.native(objects); + const alternate = process.platform === "win32" ? original.replaceAll("\\", "/") : original; + if (/[\r\n]/.test(alternate)) throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); + if (destination && !limit) { + mkdirSync(join(destination, target, "objects/pack"), { recursive: true }); + mkdirSync(join(destination, target, "objects/info"), { recursive: true }); + writeFileSync(join(destination, target, "objects/info/alternates"), `${alternate}\n`); + } + }; + const sourceFiles = codeGraphSourceScanner()(root); + const files = new Set([...sourceFiles, ...listGitVisibleFiles(root)]); + const directories = new Set(["."]); + const repositories = new Set(["."]); + for (const path of files) { + if (isAbsolute(path) || path.split(/[\\/]/).includes("..")) throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); + let directory = dirname(path); + while (directory !== ".") { directories.add(directory); directory = dirname(directory); } + } + for (const directory of directories) { + for (const name of [".gitignore", ".gitattributes", "codegraph.json"]) { + const path = join(directory, name); + if (existsSync(join(root, path))) files.add(path); + } + if (directory !== "." && existsSync(join(root, directory, ".git"))) { + visitRepository(join(root, directory, ".git"), join(directory, ".git"), new Set()); + assertSnapshotGitReadSafety(join(root, directory)); + repositories.add(directory); + } + } + hash.update(JSON.stringify([...sourceFiles].sort())); + for (const file of [...files].sort()) { + if (file.split(/[\\/]/).some(part => part === ".git" || part === ".codegraph")) { + throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-layout-unsupported"); + } + visit("source", join(root, file), file); + } + visitRepository(commonDir, ".git", new Set(["worktrees"])); + if (gitDir !== commonDir) visit("git", gitDir, ".git", new Set(["commondir", "gitdir"])); + for (const suffix of ["", "-wal"]) { + const source = join(root, ".codegraph", `codegraph.db${suffix}`); + hash.update(JSON.stringify([suffix, existsSync(source)])); + if (existsSync(source)) visit("index", source, join(".codegraph", `codegraph.db${suffix}`)); + } + if (limit) { + throw new FailureError("AC_CODE_FACTS_UNAVAILABLE", "code-facts-snapshot-limit-exceeded", { + limit, maxBytes: CODEGRAPH_SNAPSHOT_MAX_BYTES, maxEntries: CODEGRAPH_SNAPSHOT_MAX_ENTRIES, ...measured + }); + } + // Recheck the exact copied attribute population before any provider status operation. + if (destination) for (const repository of repositories) { + pinSnapshotHooks(join(destination, repository)); + assertSnapshotGitReadSafety(join(destination, repository)); + } + return hash.digest("hex"); +}