diff --git a/packages/compiler/src/frontend/ts7/lib-diagnostics.test.ts b/packages/compiler/src/frontend/ts7/lib-diagnostics.test.ts index f041a6cb5..e87db5f1a 100644 --- a/packages/compiler/src/frontend/ts7/lib-diagnostics.test.ts +++ b/packages/compiler/src/frontend/ts7/lib-diagnostics.test.ts @@ -219,3 +219,53 @@ test("a supplied file that changes is a different entry", () => { expect(store.entries.size).toBe(2); writeFileSync(fallback, fallbackText); }); + +/** The program's files, recording which ones were read. */ +function recording(texts: Record): ((name: string) => SourceFile | undefined) & { + read: string[]; +} { + const inner = files(texts); + const read: string[] = []; + return Object.assign((name: string) => (read.push(name), inner(name)), { read }); +} + +test("a program of modules stores its answer without reading the supplied files", () => { + const store = memoryStore(); + setLibDiagnosticsStore(store, "checker"); + const sources = recording({ [main]: "export const n: number = 'x';\n" }); + reusedLibDiagnostics( + sessionProgram({ semantic: new Map([[main, []]]) }), + names, + supplied, + sources, + ); + expect(sources.read).toEqual([main]); + const [text] = [...store.entries.values()]; + expect(JSON.parse(text!).globals).toBeNull(); + // A later program of modules reuses it, again without reading them. + const again = recording({ [main]: "export const n: number = 'x';\n" }); + const program = sessionProgram({ semantic: new Map([[main, []]]) }); + expect(reusedLibDiagnostics(program, names, supplied, again)).not.toBeNull(); + expect(program.asked).toEqual([`checked ${main}`]); + expect(again.read).toEqual([main]); +}); + +test("a script that uses an answer stored without global names collects and stores them", () => { + const store = memoryStore(); + setLibDiagnosticsStore(store, "checker"); + const semantic = new Map([[main, []]]); + reusedLibDiagnostics(sessionProgram({ semantic }), names, supplied, moduleFile); + const script = recording({ [main]: "const total = 1;\n" }); + const program = sessionProgram({ semantic }); + expect(reusedLibDiagnostics(program, names, supplied, script)).not.toBeNull(); + expect(program.asked).toEqual([`checked ${main}`]); + expect(script.read).toEqual([main, lib, fallback]); + const [text] = [...store.entries.values()]; + expect(new Set(JSON.parse(text!).globals)).toEqual(new Set(["NaN", "Array", "process"])); + // A script that declares one of them runs the full check, and the entry keeps its names. + const clash = sessionProgram({ semantic }); + reusedLibDiagnostics(clash, names, supplied, files({ [main]: "declare var NaN: number;\n" })); + expect(clash.asked[0]).toBe("getGlobalDiagnostics"); + expect(store.entries.size).toBe(1); + expect(JSON.parse([...store.entries.values()][0]!).globals).not.toBeNull(); +}); diff --git a/packages/compiler/src/frontend/ts7/lib-diagnostics.ts b/packages/compiler/src/frontend/ts7/lib-diagnostics.ts index 827b337bd..30c6e81f0 100644 --- a/packages/compiler/src/frontend/ts7/lib-diagnostics.ts +++ b/packages/compiler/src/frontend/ts7/lib-diagnostics.ts @@ -10,7 +10,14 @@ * (getDiagnosticsOfCheckedFiles), and the semantic diagnostics merge in the * order the full check sorts them, by file name. An answer is stored only * when the full check reports no global diagnostics: the global diagnostics - * of checking the other files are then the full check's. */ + * of checking the other files are then the full check's. + * + * The global names the supplied files declare matter only to programs with + * scripts, and collecting them reads every supplied file's syntax tree, + * which a program of modules does not otherwise read (about 4 MB at the + * peak of a small build). An answer stored by such a program leaves them + * out; the first program with scripts that uses the answer collects them + * and stores them with it. */ import { createHash } from "node:crypto"; import { statSync } from "node:fs"; import type { Node, SourceFile } from "./ast-types.js"; @@ -25,7 +32,7 @@ export interface LibDiagnosticsStore { write(key: string, text: string): void; } -const SCHEMA = "scriptc.lib-diagnostics.v1"; +const SCHEMA = "scriptc.lib-diagnostics.v2"; let configured: { store: LibDiagnosticsStore; checker: string } | null = null; @@ -89,7 +96,14 @@ export function reusedLibDiagnostics( const key = entryKey(checker, options, libs); if (key === null) return null; const stored = readEntry(store, key, libs); - if (stored !== null && !collides(stored.globals)) { + // An answer stored without the supplied files' global names: a program + // with scripts collects them and stores them with the answer. + let globals = stored?.globals ?? null; + if (stored !== null && globals === null && scripts.length > 0) { + globals = suppliedGlobalNames(libs, sourceFile); + if (globals !== null) writeEntry(store, key, globals, Object.fromEntries(stored.files)); + } + if (stored !== null && (scripts.length === 0 || (globals !== null && !collides(globals)))) { const checked = program.checkedFileDiagnosticData(own); if (checked === null) return null; const byFile = new Map(stored.files); @@ -104,20 +118,35 @@ export function reusedLibDiagnostics( const semantic = program.diagnosticData("getSemanticDiagnostics"); // A server that cannot check only some files could never use the answer. if (global.length === 0 && stored === null && program.checkedFileDiagnosticData([]) !== null) { - const globals = suppliedGlobalNames(libs, sourceFile); - if (globals !== null && !collides(globals)) { + const names = scripts.length === 0 ? null : suppliedGlobalNames(libs, sourceFile); + if (scripts.length === 0 || (names !== null && !collides(names))) { const files: Record = {}; for (const name of libs) files[name] = program.diagnosticData("getSemanticDiagnostics", name); - try { - store.write(key, JSON.stringify({ schema: SCHEMA, globals: [...globals], files })); - } catch { - // Best effort: a failed write is a later miss. - } + writeEntry(store, key, names, files); } } return { semantic, global }; } +/** Stores an answer: the supplied files' diagnostics, and their global names + * when they were collected (null otherwise). Best effort: a failed write is + * a later miss. */ +function writeEntry( + store: LibDiagnosticsStore, + key: string, + globals: ReadonlySet | null, + files: Record, +): void { + try { + store.write( + key, + JSON.stringify({ schema: SCHEMA, globals: globals === null ? null : [...globals], files }), + ); + } catch { + // A failed write is a later miss. + } +} + /** Whether a file cannot add to or change the global declarations the * supplied files declare: a module with no `declare global`, no module * augmentation and no UMD `export as namespace` declaration. */ @@ -276,7 +305,7 @@ function readEntry( store: LibDiagnosticsStore, key: string, libs: readonly string[], -): { files: Map; globals: Set } | null { +): { files: Map; globals: Set | null } | null { let text: string | null; try { text = store.read(key); @@ -290,7 +319,11 @@ function readEntry( } catch { return null; } - if (entry.schema !== SCHEMA || entry.files === undefined || !Array.isArray(entry.globals)) + if ( + entry.schema !== SCHEMA || + entry.files === undefined || + (entry.globals !== null && !Array.isArray(entry.globals)) + ) return null; const files = new Map(); for (const name of libs) { @@ -298,7 +331,10 @@ function readEntry( if (!Array.isArray(list)) return null; files.set(name, list); } - return { files, globals: new Set(entry.globals.filter((name) => typeof name === "string")) }; + const globals = Array.isArray(entry.globals) + ? new Set(entry.globals.filter((name): name is string => typeof name === "string")) + : null; + return { files, globals }; } /** Each file's diagnostics (already in the checker's order) in the order the