diff --git a/src/graph/cache.ts b/src/graph/cache.ts index f01018a98..cccf6e976 100644 --- a/src/graph/cache.ts +++ b/src/graph/cache.ts @@ -235,5 +235,16 @@ function rewriteSourceFile(cached: FileExtraction, newPath: string): FileExtract })), edges: cached.edges.map((e) => ({ ...e, source: swap(e.source), target: swap(e.target) })), parse_errors: cached.parse_errors.map((p) => ({ ...p, source_file: newPath })), + // Optional Phase 1.5 cross-file inputs. Dropping them on a relocated hit + // silently lost every cross-file `calls` edge from the moved file. + // caller_id is a node id, so it gets the same swap as nodes/edges. + // Binding specifiers are raw (e.g. "./b") and are resolved relative to + // the extraction's source_file at snapshot time, so they stay verbatim. + ...(cached.raw_calls !== undefined + ? { raw_calls: cached.raw_calls.map((rc) => ({ ...rc, caller_id: swap(rc.caller_id) })) } + : {}), + ...(cached.import_bindings !== undefined + ? { import_bindings: cached.import_bindings.map((b) => ({ ...b })) } + : {}), }; } diff --git a/tests/shared/graph/cache.test.ts b/tests/shared/graph/cache.test.ts index cbd74a989..9541cea57 100644 --- a/tests/shared/graph/cache.test.ts +++ b/tests/shared/graph/cache.test.ts @@ -11,7 +11,9 @@ import { readCache, writeCache, } from "../../../src/graph/cache.js"; -import type { FileExtraction } from "../../../src/graph/types.js"; +import { extractTypeScript } from "../../../src/graph/extract/typescript.js"; +import { buildSnapshot } from "../../../src/graph/snapshot.js"; +import type { FileExtraction, GraphMetadata, GraphObservation } from "../../../src/graph/types.js"; function makeExtraction(sourceFile: string): FileExtraction { return { @@ -244,4 +246,112 @@ describe("cache — read/write roundtrip", () => { // Original arrays preserved (no mutation overhead path) expect(got!.nodes).toEqual(ex.nodes); }); + + it("relocated hit preserves raw_calls (caller_id rewritten) and import_bindings verbatim", () => { + const sha = "deadbeef"; + const ex: FileExtraction = { + ...makeExtraction("src/foo.ts"), + raw_calls: [ + { caller_id: "src/foo.ts:foo:function", callee_name: "greet" }, + { caller_id: "src/foo.ts::module", callee_name: "greet", receiver: "ns" }, + ], + import_bindings: [ + { local_name: "greet", imported_name: "hello", kind: "named", specifier: "./bar" }, + { local_name: "ns", imported_name: "*", kind: "namespace", specifier: "../util" }, + { local_name: "Shape", imported_name: "Shape", kind: "named", specifier: "./bar", type_only: true }, + ], + }; + writeCache(baseDir, sha, ex); + const got = readCache(baseDir, sha, "lib/deep/renamed.ts"); + expect(got).not.toBeNull(); + expect(got!.raw_calls).toEqual([ + { caller_id: "lib/deep/renamed.ts:foo:function", callee_name: "greet" }, + { caller_id: "lib/deep/renamed.ts::module", callee_name: "greet", receiver: "ns" }, + ]); + // Specifiers stay raw; the resolver interprets them relative to source_file. + expect(got!.import_bindings).toEqual(ex.import_bindings); + }); + + it("relocated hit keeps optional cross-file fields absent when the entry omits them", () => { + const sha = "deadbeef"; + writeCache(baseDir, sha, makeExtraction("src/foo.ts")); + const got = readCache(baseDir, sha, "src/renamed.ts"); + expect(got).not.toBeNull(); + expect("raw_calls" in got!).toBe(false); + expect("import_bindings" in got!).toBe(false); + }); +}); + +describe("cache — relocated content with real TypeScript extraction", () => { + let baseDir: string; + + beforeEach(() => { + baseDir = mkdtempSync(join(tmpdir(), "graph-cache-reloc-")); + }); + afterEach(() => { + rmSync(baseDir, { recursive: true, force: true }); + }); + + function meta(): GraphMetadata { + return { schema_version: 1, generator: "hivemind-graph", commit_sha: "c", repo_key: "k" }; + } + function obs(): GraphObservation { + return { + ts: "2026-06-03T00:00:00Z", branch: "main", worktree_path: "/t", repo_project: "t", + generator_version: "0.0.0-test", source_files_extracted: 0, source_files_skipped: 0, + }; + } + + const CALLER_SRC = + `import { greet } from "./b";\n` + + `export function run() { return greet(); }\n` + + `export class Runner { go() { return greet(); } }\n`; + const CALLEE_SRC = `export function greet() { return "hi"; }\n`; + + it("extract → cache → read under new path → buildSnapshot resolves cross-file calls from the new path", () => { + const sha = fileContentHash(CALLER_SRC); + const original = extractTypeScript(CALLER_SRC, "src/a.ts"); + // Sanity: the real extractor emits the Phase 1.5 inputs this test relies on. + expect(original.raw_calls!.length).toBeGreaterThan(0); + expect(original.import_bindings!.length).toBeGreaterThan(0); + writeCache(baseDir, sha, original); + const entryBytes = readFileSync(cachePath(baseDir, sha), "utf8"); + + const moved = readCache(baseDir, sha, "lib/a.ts"); + expect(moved).not.toBeNull(); + + // Relocated hit must match a fresh extraction at the new path. + const fresh = extractTypeScript(CALLER_SRC, "lib/a.ts"); + expect(moved!.raw_calls).toEqual(fresh.raw_calls); + expect(moved!.import_bindings).toEqual(fresh.import_bindings); + for (const rc of moved!.raw_calls!) { + expect(rc.caller_id.startsWith("lib/a.ts:")).toBe(true); + } + + // Both src/b.ts and lib/b.ts exist; "./b" must resolve relative to lib/a.ts. + const srcB = extractTypeScript(CALLEE_SRC, "src/b.ts"); + const libB = extractTypeScript(CALLEE_SRC, "lib/b.ts"); + const snap = buildSnapshot([moved!, srcB, libB], meta(), obs()); + const calls = snap.links.filter((e) => e.relation === "calls"); + expect(calls.some((e) => e.source === "lib/a.ts:run:function" && e.target === "lib/b.ts:greet:function")).toBe(true); + const methodCaller = fresh.raw_calls!.find((rc) => !rc.caller_id.endsWith(":run:function"))!.caller_id; + expect(calls.some((e) => e.source === methodCaller && e.target === "lib/b.ts:greet:function")).toBe(true); + expect(calls.some((e) => e.target === "src/b.ts:greet:function")).toBe(false); + expect(snap.links.some((e) => e.source.startsWith("src/a.ts"))).toBe(false); + + // The cached-path snapshot is identical to one built from fresh extractions. + const freshSnap = buildSnapshot([fresh, srcB, libB], meta(), obs()); + expect(snap.links).toEqual(freshSnap.links); + expect(snap.nodes).toEqual(freshSnap.nodes); + + // The stored entry is untouched, and a read under the original path still + // yields the original (non-relocated) extraction. + expect(readFileSync(cachePath(baseDir, sha), "utf8")).toBe(entryBytes); + const again = readCache(baseDir, sha, "src/a.ts"); + expect(again!.raw_calls).toEqual(original.raw_calls); + expect(again!.import_bindings).toEqual(original.import_bindings); + for (const rc of again!.raw_calls!) { + expect(rc.caller_id.startsWith("src/a.ts:")).toBe(true); + } + }); });