diff --git a/src/graph/cache.ts b/src/graph/cache.ts index f01018a98..4a2fe1d2d 100644 --- a/src/graph/cache.ts +++ b/src/graph/cache.ts @@ -23,7 +23,7 @@ import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { dirname, extname, join } from "node:path"; import type { FileExtraction } from "./types.js"; @@ -62,7 +62,8 @@ export function cachePath(baseDir: string, contentSha256: string): string { /** * Look up the cached extraction for a given content hash. Returns null on - * cache miss, malformed entries, or schema-version mismatch. Errors during + * cache miss, malformed entries, schema-version mismatch, or when the entry + * was extracted under a different file extension. Errors during * read are swallowed — a corrupt cache entry must not block the build. * * The returned extraction's `source_file` is REWRITTEN to the supplied @@ -115,6 +116,16 @@ export function readCache( if (!validateItems(cached)) { return null; } + // The key is content-only, but extractor dispatch and grammar choice depend + // on the file extension (.js vs .ts, and .ts vs .tsx pick different + // tree-sitter grammars). Identical bytes under a different extension must + // re-extract rather than inherit the other language/dialect's result. + // Compared case-sensitively: the TS extractor's grammar pick is itself + // case-sensitive (.TSX parses with the plain TS grammar), so folding case + // here could serve a tsx-grammar entry for a .TSX file. + if (extname(cached.source_file) !== extname(relativePath)) { + return null; + } // Rewrite source_file on every node, every edge id reference, and every // parse_error to the caller's path. Wrapped in try/catch as belt-and- // suspenders against any unforeseen item shape that slipped past diff --git a/tests/shared/graph/cache.test.ts b/tests/shared/graph/cache.test.ts index cbd74a989..d340d4815 100644 --- a/tests/shared/graph/cache.test.ts +++ b/tests/shared/graph/cache.test.ts @@ -11,6 +11,7 @@ import { readCache, writeCache, } from "../../../src/graph/cache.js"; +import { extractFile } from "../../../src/graph/extract/index.js"; import type { FileExtraction } from "../../../src/graph/types.js"; function makeExtraction(sourceFile: string): FileExtraction { @@ -245,3 +246,102 @@ describe("cache — read/write roundtrip", () => { expect(got!.nodes).toEqual(ex.nodes); }); }); + +describe("cache — source extension compatibility", () => { + let baseDir: string; + + beforeEach(() => { + baseDir = mkdtempSync(join(tmpdir(), "graph-cache-ext-")); + }); + afterEach(() => { + rmSync(baseDir, { recursive: true, force: true }); + }); + + // Plain JS that is equally valid TypeScript, so both extensions parse cleanly + // and only the dispatched extractor (and hence `language`) differs. + const JS_TS_SOURCE = [ + "export function greet(name) {", + " return helper(name);", + "}", + "function helper(n) {", + " return n;", + "}", + "", + ].join("\n"); + + // Valid TSX; the plain .ts grammar rejects JSX, so the dialect matters. + const TSX_SOURCE = [ + "export function View() {", + " return
;", + "}", + "", + ].join("\n"); + + function populate(content: string, relativePath: string): { sha: string; written: FileExtraction } { + const sha = fileContentHash(content); + const written = extractFile(content, relativePath); + writeCache(baseDir, sha, written); + return { sha, written }; + } + + it("does not serve a .js extraction to a .ts file with identical bytes", () => { + const { sha, written } = populate(JS_TS_SOURCE, "src/greet.js"); + expect(written.language).toBe("javascript"); + const fresh = extractFile(JS_TS_SOURCE, "src/greet.ts"); + expect(fresh.language).toBe("typescript"); + expect(readCache(baseDir, sha, "src/greet.ts")).toBeNull(); + }); + + it("does not serve a .ts extraction to a .js file with identical bytes", () => { + const { sha, written } = populate(JS_TS_SOURCE, "src/greet.ts"); + expect(written.language).toBe("typescript"); + const fresh = extractFile(JS_TS_SOURCE, "src/greet.js"); + expect(fresh.language).toBe("javascript"); + expect(readCache(baseDir, sha, "src/greet.js")).toBeNull(); + }); + + it("does not serve a .tsx extraction to a .ts file with identical bytes", () => { + const { sha, written } = populate(TSX_SOURCE, "src/View.tsx"); + expect(written.parse_errors).toEqual([]); + // Same bytes under .ts use the non-JSX grammar and extract differently. + const fresh = extractFile(TSX_SOURCE, "src/View.ts"); + expect(fresh.parse_errors.length).toBeGreaterThan(0); + expect(readCache(baseDir, sha, "src/View.ts")).toBeNull(); + }); + + it("does not serve a .ts extraction to a .tsx file with identical bytes", () => { + const { sha, written } = populate(TSX_SOURCE, "src/View.ts"); + expect(written.parse_errors.length).toBeGreaterThan(0); + const fresh = extractFile(TSX_SOURCE, "src/View.tsx"); + expect(fresh.parse_errors).toEqual([]); + expect(readCache(baseDir, sha, "src/View.tsx")).toBeNull(); + }); + + it("still hits on a same-extension rename/copy (.ts)", () => { + const { sha } = populate(JS_TS_SOURCE, "src/greet.ts"); + const got = readCache(baseDir, sha, "lib/copy.ts"); + expect(got).not.toBeNull(); + const fresh = extractFile(JS_TS_SOURCE, "lib/copy.ts"); + expect(got!.language).toBe("typescript"); + expect(got!.nodes).toEqual(fresh.nodes); + expect(got!.edges).toEqual(fresh.edges); + expect(got!.parse_errors).toEqual(fresh.parse_errors); + }); + + it("still hits on a same-extension rename/copy (.js)", () => { + const { sha } = populate(JS_TS_SOURCE, "src/greet.js"); + const got = readCache(baseDir, sha, "lib/copy.js"); + expect(got).not.toBeNull(); + expect(got!.language).toBe("javascript"); + expect(got!.source_file).toBe("lib/copy.js"); + }); + + it("still hits on a same-extension rename/copy (.tsx)", () => { + const { sha } = populate(TSX_SOURCE, "src/View.tsx"); + const got = readCache(baseDir, sha, "src/components/Renamed.tsx"); + expect(got).not.toBeNull(); + expect(got!.parse_errors).toEqual([]); + expect(got!.source_file).toBe("src/components/Renamed.tsx"); + expect(got!.nodes.every((n) => n.source_file === "src/components/Renamed.tsx")).toBe(true); + }); +});