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
8 changes: 6 additions & 2 deletions src/core.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -43,7 +43,11 @@ export async function analyze(opts: AnalysisOptions): Promise<AnalysisResult> {
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
Expand Down
39 changes: 34 additions & 5 deletions src/syntactic_analysis/symbolTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, TSModule>;
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[];
}

Expand Down Expand Up @@ -57,13 +57,41 @@ export function buildSymbolTable(
// The set of files to BUILD (targets in -t mode, else all).
const buildFiles = targets ?? allProjectFiles;

const reusable = new Map<string, TSModule>();
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.
const assignment = new Map<ProgramSpec, DiscoveredFile[]>();
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<string, TSModule> = {};
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<ProgramSpec, Project>();
const programs: BuiltProgram[] = [];
for (const s of specs) {
Expand All @@ -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;
}
Expand Down
51 changes: 51 additions & 0 deletions test/l1-body-cache-shape.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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 });
}
});