Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions src/graph/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
100 changes: 100 additions & 0 deletions tests/shared/graph/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 <div className=\"x\" />;",
"}",
"",
].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);
});
});