From 435e446851c08fd0d70831c3ceea9b78a83bf417 Mon Sep 17 00:00:00 2001 From: Bonobo Date: Wed, 2 Sep 2026 14:01:36 +0200 Subject: [PATCH] fix(cache): validate semantic program context --- src/core.ts | 4 +- src/syntactic_analysis/symbolTable.ts | 38 +++++++-- src/utils/cache.ts | 9 ++- test/l1-body-cache-shape.test.ts | 106 ++++++++++++++++++++++++++ 4 files changed, 147 insertions(+), 10 deletions(-) diff --git a/src/core.ts b/src/core.ts index 48aec15..b678346 100644 --- a/src/core.ts +++ b/src/core.ts @@ -29,7 +29,7 @@ export async function analyze(opts: AnalysisOptions): Promise { for (const note of mat.notes) log.debug(note); const cached = opts.eager ? null : loadCache(cacheDir); - const { project, symbol_table, programs } = buildSymbolTable(opts, mat, cached?.symbol_table ?? null, log); + const { project, symbol_table, programs, programContexts } = buildSymbolTable(opts, mat, cached, log); // Level 3: post stage-1–4 graph extraction to the worker pool BEFORE the call-graph solve — // extraction doesn't need callee resolution, so the two run concurrently (the contract's @@ -102,7 +102,7 @@ export async function analyze(opts: AnalysisOptions): Promise { // Cache the id-free base (ids/body/heritage are per-run layers stamped by finalizeAnalysis; // the cached tree must stay --app-name-free). - saveCache(cacheDir, { symbol_table }); + saveCache(cacheDir, { symbol_table, program_contexts: programContexts }); // Never let "some edges are missing" look like "there were no edges": a node the checker could // not resolve is skipped (see schema/checker.ts), and the count is said out loud. const skipped = checkerFailures(); diff --git a/src/syntactic_analysis/symbolTable.ts b/src/syntactic_analysis/symbolTable.ts index a0a2dc9..b6b8209 100644 --- a/src/syntactic_analysis/symbolTable.ts +++ b/src/syntactic_analysis/symbolTable.ts @@ -1,7 +1,8 @@ import * as path from "node:path"; import { Project, ts } from "ts-morph"; import { buildModule } from "./builders"; -import { fileMeta, fileUnchanged } from "../utils"; +import { fileMeta, fileUnchanged, relPosix, sha256 } from "../utils"; +import type { CacheData } from "../utils/cache"; import { discoverSourceFiles, resolveTargetFiles, type DiscoveredFile } from "./discovery"; import type { Materialization, ProgramSpec } from "../build"; import type { AnalysisOptions } from "../options"; @@ -16,6 +17,9 @@ export interface BuiltProgram { // The tsconfig this program was built from (null = default options) — the owning config for // every file in `fileKeys`. This is the file→program config map the L3 workers would thread. configPath: string | null; + /** Stable cache key and hash of the complete TypeScript compiler program. */ + contextKey: string; + contextHash: string; } export interface SymbolTableResult { @@ -25,6 +29,8 @@ export interface SymbolTableResult { files: DiscoveredFile[]; // One entry per discovered program (deepest scope first, root last). programs: BuiltProgram[]; + /** Context hashes persisted with cached modules and validated on the next run. */ + programContexts: Record; } /** Is `file` inside `dir` (or is `dir` the file's own directory)? */ @@ -46,7 +52,7 @@ function ownerProgram(absPath: string, programs: ProgramSpec[]): ProgramSpec { export function buildSymbolTable( opts: AnalysisOptions, mat: Materialization, - cached: Record | null, + cached: CacheData | null, log: Logger, ): SymbolTableResult { const root = opts.input; @@ -65,6 +71,7 @@ export function buildSymbolTable( for (const f of allProjectFiles) assignment.get(ownerProgram(f.absPath, specs))!.push(f); const projectOf = new Map(); + const contextOf = new Map(); const programs: BuiltProgram[] = []; for (const s of specs) { const project = createProject(s.configPath); @@ -78,8 +85,11 @@ export function buildSymbolTable( log.warn(`failed to load ${f.fileKey}: ${(e as Error).message}`); } } + const contextKey = s.configPath ? relPosix(root, s.configPath) : ""; + const contextHash = programContextHash(project); projectOf.set(s, project); - programs.push({ project, fileKeys, configPath: s.configPath }); + contextOf.set(s, { key: contextKey, hash: contextHash }); + programs.push({ project, fileKeys, configPath: s.configPath, contextKey, contextHash }); log.info(`program: ${s.configPath ? path.relative(root, s.configPath) : "default"} (${files.length} files)`); } @@ -87,12 +97,15 @@ export function buildSymbolTable( let built = 0; let fromCache = 0; for (const f of buildFiles) { - if (cached && !opts.eager && cached[f.fileKey] && fileUnchanged(f.absPath, cached[f.fileKey])) { - symbol_table[f.fileKey] = cached[f.fileKey]; + const owner = ownerProgram(f.absPath, specs); + const context = contextOf.get(owner); + const contextMatches = context && cached?.program_contexts?.[context.key] === context.hash; + if (contextMatches && !opts.eager && cached?.symbol_table[f.fileKey] && fileUnchanged(f.absPath, cached.symbol_table[f.fileKey])) { + symbol_table[f.fileKey] = cached.symbol_table[f.fileKey]; fromCache++; continue; } - const sf = projectOf.get(ownerProgram(f.absPath, specs))!.getSourceFile(f.absPath); + const sf = projectOf.get(owner)!.getSourceFile(f.absPath); if (!sf) continue; const mod = buildModule(sf as unknown as Node, root); const meta = fileMeta(f.absPath); @@ -106,7 +119,18 @@ export function buildSymbolTable( // The root program is always last; its Project is the one legacy single-program consumers expect. const rootProject = projectOf.get(specs[specs.length - 1]!)!; - return { project: rootProject, symbol_table, files: buildFiles, programs }; + const programContexts = Object.fromEntries( + programs.map((program) => [program.contextKey, program.contextHash]), + ); + return { project: rootProject, symbol_table, files: buildFiles, programs, programContexts }; +} + +/** Hash every source file and compiler option that TypeScript admitted to this program. */ +function programContextHash(project: Project): string { + const files = project.getProgram().compilerObject.getSourceFiles() + .map((source) => `${source.fileName}\0${sha256(source.text)}`) + .sort(); + return sha256(JSON.stringify({ compilerOptions: project.getCompilerOptions(), files })); } /** The fallback compiler options when the target has no tsconfig (shared with graph workers). */ diff --git a/src/utils/cache.ts b/src/utils/cache.ts index 452c935..7df2bfd 100644 --- a/src/utils/cache.ts +++ b/src/utils/cache.ts @@ -6,6 +6,7 @@ import { ANALYZER_VERSION } from "./version"; export interface CacheData { analyzer_version?: string; + program_contexts?: Record; symbol_table: Record; } @@ -30,7 +31,13 @@ export function loadCache(cacheDir: string): CacheData | null { export function saveCache(cacheDir: string, data: CacheData): void { try { fs.mkdirSync(cacheDir, { recursive: true }); - fs.writeFileSync(cacheFilePath(cacheDir), JSON.stringify({ analyzer_version: ANALYZER_VERSION, ...data })); + fs.writeFileSync( + cacheFilePath(cacheDir), + JSON.stringify( + { analyzer_version: ANALYZER_VERSION, ...data }, + (key, value: unknown) => key === "callee_signature" ? undefined : value, + ), + ); } catch { /* caching is best-effort */ } diff --git a/test/l1-body-cache-shape.test.ts b/test/l1-body-cache-shape.test.ts index f27cb90..a5c71e7 100644 --- a/test/l1-body-cache-shape.test.ts +++ b/test/l1-body-cache-shape.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { analyze } from "../src/core"; +import type { AnalysisOptions } from "../src/options"; import { populateL1Body } from "../src/schema/l1Body"; import type { AnalysisInternal, TSCallable, TSModule } from "../src/schema"; +import { cacheFilePath, saveCache } from "../src/utils/cache"; describe("l1Body tolerates a stale-cache callable (#101 fix round 1)", () => { test("callable missing config_accesses (call_sites present) does not throw", () => { @@ -29,3 +35,103 @@ describe("l1Body tolerates a stale-cache callable (#101 fix round 1)", () => { expect(kinds).not.toContain("config_access"); // nothing to materialize; no crash either }); }); + +describe("semantic cache validity", () => { + test("save strips per-run callee resolution provenance", () => { + const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "cants-cache-shape-")); + try { + const module = { + functions: { + caller: { + call_sites: [{ callee_signature: "stale.target" }], + }, + }, + } as unknown as TSModule; + saveCache(cacheDir, { symbol_table: { "src/main.ts": module }, program_contexts: { "tsconfig.json": "hash" } }); + expect(fs.readFileSync(cacheFilePath(cacheDir), "utf8")).not.toContain("callee_signature"); + } finally { + fs.rmSync(cacheDir, { recursive: true, force: true }); + } + }); + + test("warm output matches eager output after source and compiler-context changes", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "cants-cache-context-")); + const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "cants-cache-data-")); + const sourceDir = path.join(root, "src"); + fs.mkdirSync(sourceDir); + const mainPath = path.join(sourceDir, "main.ts"); + const targetPath = path.join(sourceDir, "target.ts"); + const configPath = path.join(root, "tsconfig.json"); + const dependencyDir = path.join(root, "node_modules", "dep"); + const declarationPath = path.join(dependencyDir, "index.d.ts"); + fs.mkdirSync(dependencyDir, { recursive: true }); + fs.writeFileSync( + mainPath, + 'import { target } from "@target";\nimport { dep } from "dep";\n' + + "export function current() { return target(); }\nexport function fromPackage() { return dep(); }\n", + ); + fs.writeFileSync(targetPath, "export function target() { return 1; }\n"); + fs.writeFileSync(path.join(dependencyDir, "package.json"), '{"name":"dep","types":"index.d.ts"}'); + fs.writeFileSync(declarationPath, "export declare function dep(): number;\n"); + + const writeConfig = (withAlias: boolean): void => { + fs.writeFileSync(configPath, JSON.stringify({ + compilerOptions: { + target: "ES2022", + module: "ESNext", + moduleResolution: "bundler", + strict: true, + ...(withAlias ? { baseUrl: ".", paths: { "@target": ["src/target.ts"] } } : {}), + }, + include: ["src/**/*.ts"], + })); + }; + const options = (eager: boolean): AnalysisOptions => ({ + input: root, + output: null, + emit: "json", + appName: "cache-context", + neo4jUri: null, + neo4jUser: "neo4j", + neo4jPassword: "", + neo4jDatabase: null, + analysisLevel: 2, + graphs: [], + graphFieldDepth: 3, + jobs: 1, + targetFiles: null, + skipTests: true, + eager, + noBuild: true, + phantoms: true, + cacheDir, + verbosity: 0, + }); + + try { + writeConfig(true); + await analyze(options(false)); + + fs.writeFileSync(targetPath, 'export function target() { return "changed"; }\n'); + const warmSource = await analyze(options(false)); + const eagerSource = await analyze(options(true)); + expect(warmSource.application).toEqual(eagerSource.application); + expect(warmSource.internal.symbol_table["src/main.ts"]?.functions.current?.return_type).toBe("string"); + + fs.writeFileSync(declarationPath, "export declare function dep(): boolean;\n"); + const warmDeclaration = await analyze(options(false)); + const eagerDeclaration = await analyze(options(true)); + expect(warmDeclaration.application).toEqual(eagerDeclaration.application); + expect(warmDeclaration.internal.symbol_table["src/main.ts"]?.functions.fromPackage?.return_type).toBe("boolean"); + + writeConfig(false); + const warmConfig = await analyze(options(false)); + const eagerConfig = await analyze(options(true)); + expect(warmConfig.application).toEqual(eagerConfig.application); + expect(warmConfig.internal.symbol_table["src/main.ts"]?.functions.current?.return_type).toBe("any"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(cacheDir, { recursive: true, force: true }); + } + }); +});