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
4 changes: 2 additions & 2 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export async function analyze(opts: AnalysisOptions): Promise<AnalysisResult> {
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
Expand Down Expand Up @@ -102,7 +102,7 @@ export async function analyze(opts: AnalysisOptions): Promise<AnalysisResult> {

// 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();
Expand Down
38 changes: 31 additions & 7 deletions src/syntactic_analysis/symbolTable.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 {
Expand All @@ -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<string, string>;
}

/** Is `file` inside `dir` (or is `dir` the file's own directory)? */
Expand All @@ -46,7 +52,7 @@ function ownerProgram(absPath: string, programs: ProgramSpec[]): ProgramSpec {
export function buildSymbolTable(
opts: AnalysisOptions,
mat: Materialization,
cached: Record<string, TSModule> | null,
cached: CacheData | null,
log: Logger,
): SymbolTableResult {
const root = opts.input;
Expand All @@ -65,6 +71,7 @@ export function buildSymbolTable(
for (const f of allProjectFiles) assignment.get(ownerProgram(f.absPath, specs))!.push(f);

const projectOf = new Map<ProgramSpec, Project>();
const contextOf = new Map<ProgramSpec, { key: string; hash: string }>();
const programs: BuiltProgram[] = [];
for (const s of specs) {
const project = createProject(s.configPath);
Expand All @@ -78,21 +85,27 @@ export function buildSymbolTable(
log.warn(`failed to load ${f.fileKey}: ${(e as Error).message}`);
}
}
const contextKey = s.configPath ? relPosix(root, s.configPath) : "<default>";
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)`);
}

const symbol_table: Record<string, TSModule> = {};
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);
Expand All @@ -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). */
Expand Down
9 changes: 8 additions & 1 deletion src/utils/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ANALYZER_VERSION } from "./version";

export interface CacheData {
analyzer_version?: string;
program_contexts?: Record<string, string>;
symbol_table: Record<string, TSModule>;
}

Expand All @@ -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 */
}
Expand Down
106 changes: 106 additions & 0 deletions test/l1-body-cache-shape.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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 });
}
});
});