diff --git a/src/core.ts b/src/core.ts index 48aec15..5f35119 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,5 +1,5 @@ import * as path from "node:path"; -import { buildProgramGraphs, startExtraction } from "./dataflow"; +import { type ExtractionHandle, buildProgramGraphs, startExtraction } from "./dataflow"; import { type LinkerResolutions, mergeCallGraphs, runDefuseLinker, tscProvider } from "./semantic_analysis"; import { loadCache, saveCache } from "./utils"; import { materialize } from "./build"; @@ -43,7 +43,11 @@ export async function analyze(opts: AnalysisOptions): Promise { if (opts.analysisLevel >= 3 && programs.length > 1) { log.warn(`L3 dataflow uses the root tsconfig for all ${programs.length} programs; nested-program files may under-resolve (see #56)`); } - const extraction = opts.analysisLevel >= 3 ? startExtraction(project, symbol_table, mat.tsConfigFilePath, opts, log) : null; + let extraction: ExtractionHandle | null = null; + if (opts.analysisLevel >= 3) { + if (!project) throw new Error("compiler project missing for dataflow analysis"); + extraction = startExtraction(project, symbol_table, mat.tsConfigFilePath, opts, log); + } // Call graph: the tsc resolver, per program (each with its own Project + its slice of callables // via `only`), merged across programs. Only worth running at level >= 2: finalizeAnalysis diff --git a/src/syntactic_analysis/symbolTable.ts b/src/syntactic_analysis/symbolTable.ts index a0a2dc9..ad82b9b 100644 --- a/src/syntactic_analysis/symbolTable.ts +++ b/src/syntactic_analysis/symbolTable.ts @@ -19,11 +19,11 @@ export interface BuiltProgram { } export interface SymbolTableResult { - // The ROOT program's Project — single-program consumers keep working unchanged. - project: Project; + // The root Project is absent only on a complete warm Level-1 hit; Levels 2–4 always receive it. + project: Project | undefined; symbol_table: Record; files: DiscoveredFile[]; - // One entry per discovered program (deepest scope first, root last). + // Deepest scope first and root last; empty only with the Project-free Level-1 fast path. programs: BuiltProgram[]; } @@ -57,6 +57,16 @@ export function buildSymbolTable( // The set of files to BUILD (targets in -t mode, else all). const buildFiles = targets ?? allProjectFiles; + const reusable = new Map(); + if (cached && !opts.eager) { + for (const file of buildFiles) { + const cachedModule = cached[file.fileKey]; + if (cachedModule && fileUnchanged(file.absPath, cachedModule)) { + reusable.set(file.fileKey, cachedModule); + } + } + } + // Assign every discovered file to exactly one program (deepest scope wins), then construct one // Project per program from ONLY its files — so each file resolves under the tsconfig that governs // it (module resolution, `paths` aliases, lib) instead of a single root program swallowing all. @@ -64,6 +74,24 @@ export function buildSymbolTable( for (const s of specs) assignment.set(s, []); for (const f of allProjectFiles) assignment.get(ownerProgram(f.absPath, specs))!.push(f); + if ( + opts.analysisLevel === 1 && + cached !== null && + !opts.eager && + reusable.size === buildFiles.length + ) { + const symbol_table: Record = {}; + for (const file of buildFiles) symbol_table[file.fileKey] = reusable.get(file.fileKey)!; + for (const spec of specs) { + const files = assignment.get(spec)!; + log.info( + `program: ${spec.configPath ? path.relative(root, spec.configPath) : "default"} (${files.length} files)`, + ); + } + log.info(`symbol table: 0 built, ${buildFiles.length} cached, ${buildFiles.length} modules`); + return { project: undefined, symbol_table, files: buildFiles, programs: [] }; + } + const projectOf = new Map(); const programs: BuiltProgram[] = []; for (const s of specs) { @@ -87,8 +115,9 @@ 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 cachedModule = reusable.get(f.fileKey); + if (cachedModule) { + symbol_table[f.fileKey] = cachedModule; fromCache++; continue; } diff --git a/test/l1-body-cache-shape.test.ts b/test/l1-body-cache-shape.test.ts index f27cb90..08f5264 100644 --- a/test/l1-body-cache-shape.test.ts +++ b/test/l1-body-cache-shape.test.ts @@ -1,6 +1,16 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; import { describe, expect, test } from "bun:test"; +import { materialize } from "../src/build"; +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 { buildSymbolTable } from "../src/syntactic_analysis"; +import { loadCache, Logger } from "../src/utils"; + +const FIXTURE = path.resolve(import.meta.dir, "fixtures/sample-app"); describe("l1Body tolerates a stale-cache callable (#101 fix round 1)", () => { test("callable missing config_accesses (call_sites present) does not throw", () => { @@ -29,3 +39,44 @@ describe("l1Body tolerates a stale-cache callable (#101 fix round 1)", () => { expect(kinds).not.toContain("config_access"); // nothing to materialize; no crash either }); }); + +test("complete warm Level-1 cache hits skip projects and preserve output", async () => { + const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "cants-l1-cache-")); + const opts: AnalysisOptions = { + input: FIXTURE, + output: null, + emit: "json", + appName: "sample-app", + neo4jUri: null, + neo4jUser: "neo4j", + neo4jPassword: "", + neo4jDatabase: null, + analysisLevel: 1, + graphs: [], + graphFieldDepth: 3, + jobs: 1, + targetFiles: null, + skipTests: true, + eager: false, + noBuild: true, + phantoms: true, + cacheDir, + verbosity: 0, + }; + + try { + const cold = await analyze(opts); + const cached = loadCache(cacheDir); + expect(cached, "cold analysis cache").not.toBeNull(); + + const log = new Logger(0); + const result = buildSymbolTable(opts, materialize(opts, log), cached!.symbol_table, log); + expect(result.project).toBeUndefined(); + expect(result.programs).toHaveLength(0); + + const warm = await analyze(opts); + expect(warm.application).toEqual(cold.application); + } finally { + fs.rmSync(cacheDir, { recursive: true, force: true }); + } +});