From 5e615c86562132ab6176eeb399bdf330d3624a17 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 20 Aug 2026 14:56:53 -0500 Subject: [PATCH 1/5] Prefetch checker work by lowering phase - Batch program structure and reachable body waves without querying dead bodies. - Preserve memoization and panic fencing with focused checker coverage. --- .../compiler/src/frontend/lowering/lowerer.ts | 233 ++++++++++++------ packages/compiler/src/frontend/program.ts | 26 +- packages/compiler/src/frontend/ts7/checker.ts | 148 +++++++++-- packages/compiler/test/ts7/facade.test.ts | 157 +++++++++++- 4 files changed, 452 insertions(+), 112 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 0f34c32b..ede9e375 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -2599,6 +2599,10 @@ export class Lowerer { * themselves. */ emitReachable(extraRoots?: readonly string[]): { reachable: Set; result: LowerResult } { const parts = this.splitFiles(); + // Direct lowering callers do not necessarily run program preflight. + // Establish the same managed header/top-level batch here before + // collection, while the production path simply finds warm memos. + this.checker.prefetchSourceFileStructures(parts.map((fp) => fp.sf)); this.collectProgram(parts); // Decorated classes analyze post-collection here too: the %init seeds // lower the decoration calls, whose edges (decorator bodies, construct @@ -2609,7 +2613,27 @@ export class Lowerer { // Every lowerable body, by emitted-function name. The names double as // retained-function keys and are deterministic by construction // (qualified declaration names). - const units = new Map IrFunction | null }>(); + const units = new Map IrFunction | null; + }>(); + const bodyRoots = (...roots: (ts.Node | undefined | null)[]): ts.Node[] => + roots.filter((root): root is ts.Node => root !== undefined && root !== null); + const functionRoots = (decl: ts.FunctionLikeDeclaration): ts.Node[] => bodyRoots( + ...decl.parameters.map((param) => param.initializer), + decl.body, + ); + const classCtorRoots = (info: ClassInfo): ts.Node[] => bodyRoots( + ...(info.ctor?.parameters ?? []).map((param) => param.initializer), + info.ctor?.body, + ...info.fieldOrder.map((field) => field.initializer), + ); + const classMemberRoots = (info: ClassInfo): ts.Node[] => [ + ...classCtorRoots(info), + ...[...this.classMethodMembers(info)].flatMap(({ member }) => functionRoots(member)), + ...[...(info.staticMethods?.values() ?? [])].flatMap(({ member }) => functionRoots(member)), + ]; let unitOrder = 0; for (const fp of parts) { for (const decl of fp.fnDecls) { @@ -2620,7 +2644,13 @@ export class Lowerer { const declSymbol = declSymbolOf(this, decl); if (!declSymbol || this.genericFnsBySymbol.has(declSymbol)) continue; const sig = this.fnSigsBySymbol.get(declSymbol); - if (sig) units.set(sig.name, { order: unitOrder++, lower: () => this.lowerFunction(decl) }); + if (sig) { + units.set(sig.name, { + order: unitOrder++, + roots: functionRoots(decl), + lower: () => this.lowerFunction(decl), + }); + } } } for (const info of this.classes.values()) { @@ -2633,16 +2663,32 @@ export class Lowerer { // A FAMILY has no constructor function and no instance members — // only its statics are units. if (!info.generic) { - units.set(`%${cName}.constructor`, { order: unitOrder++, lower: () => this.lowerClassCtor(info) }); + units.set(`%${cName}.constructor`, { + order: unitOrder++, + roots: classCtorRoots(info), + lower: () => this.lowerClassCtor(info), + }); for (const { mName, member } of this.classMethodMembers(info)) { - units.set(`%${cName}.${mName}`, { order: unitOrder++, lower: () => this.lowerClassMethodMember(info, member) }); + units.set(`%${cName}.${mName}`, { + order: unitOrder++, + roots: functionRoots(member), + lower: () => this.lowerClassMethodMember(info, member), + }); } for (const prop of info.throwingSetters) { - units.set(`%${cName}.set:${prop}`, { order: unitOrder++, lower: () => this.throwingSetterFn(info, prop) }); + units.set(`%${cName}.set:${prop}`, { + order: unitOrder++, + roots: [], + lower: () => this.throwingSetterFn(info, prop), + }); } } - for (const name of info.staticMethods?.keys() ?? []) { - units.set(`%${cName}.static:${name}`, { order: unitOrder++, lower: () => lowerStaticMethod(this, info, name) }); + for (const [name, entry] of info.staticMethods ?? []) { + units.set(`%${cName}.static:${name}`, { + order: unitOrder++, + roots: functionRoots(entry.member), + lower: () => lowerStaticMethod(this, info, name), + }); } } // The old emit pass visited each file's functions and then its classes, @@ -2706,19 +2752,31 @@ export class Lowerer { // edge to them can fire (references require the collected class). this.onExprClassCollected = (info: ClassInfo): void => { const cName = info.def.name; - const register = (name: string, lower: () => IrFunction | null): void => { - units.set(name, { order: unitOrder++, lower }); + const register = ( + name: string, + roots: readonly ts.Node[], + lower: () => IrFunction | null, + ): void => { + units.set(name, { order: unitOrder++, roots, lower }); metadataPriority.set(name, [3, expressionMetadataOrder++]); }; - register(`%${cName}.constructor`, () => this.lowerClassCtor(info)); + register(`%${cName}.constructor`, classCtorRoots(info), () => this.lowerClassCtor(info)); for (const { mName, member } of this.classMethodMembers(info)) { - register(`%${cName}.${mName}`, () => this.lowerClassMethodMember(info, member)); + register( + `%${cName}.${mName}`, + functionRoots(member), + () => this.lowerClassMethodMember(info, member), + ); } - for (const name of info.staticMethods?.keys() ?? []) { - register(`%${cName}.static:${name}`, () => lowerStaticMethod(this, info, name)); + for (const [name, entry] of info.staticMethods ?? []) { + register( + `%${cName}.static:${name}`, + functionRoots(entry.member), + () => lowerStaticMethod(this, info, name), + ); } for (const prop of info.throwingSetters) { - register(`%${cName}.set:${prop}`, () => this.throwingSetterFn(info, prop)); + register(`%${cName}.set:${prop}`, [], () => this.throwingSetterFn(info, prop)); } }; // Generic instances queued by the bodies above lower here (an instance @@ -2735,46 +2793,81 @@ export class Lowerer { specLowered < this.emitSpecQueue.length ) { while (clsInstLowered < this.genericClassInstances.length) { - const info = this.genericClassInstances[clsInstLowered++]!; - const owner = demandOwner(); - this.genericClassDemandOwner.set(info, owner); - const ref = this.genericClassDemandPriority.get(info) ?? { rank: [4, instanceMetadataOrder++] }; - this.genericClassDemandPriority.set(info, ref); - instanceFunctions.push(...this.withGenericDemandOwner(owner, () => - this.shapes.withDeclaredOrderPriority(ref, () => this.lowerClassMembers(info)))); + const waveEnd = this.genericClassInstances.length; + this.checker.prefetchRoots( + this.genericClassInstances.slice(clsInstLowered, waveEnd).flatMap(classMemberRoots), + ); + while (clsInstLowered < waveEnd) { + const info = this.genericClassInstances[clsInstLowered++]!; + const owner = demandOwner(); + this.genericClassDemandOwner.set(info, owner); + const ref = this.genericClassDemandPriority.get(info) ?? { rank: [4, instanceMetadataOrder++] }; + this.genericClassDemandPriority.set(info, ref); + instanceFunctions.push(...this.withGenericDemandOwner(owner, () => + this.shapes.withDeclaredOrderPriority(ref, () => this.lowerClassMembers(info)))); + } } while (instLowered < this.instantiationQueue.length) { - const { info, inst } = this.instantiationQueue[instLowered++]!; - const owner = demandOwner(); - this.genericFunctionDemandOwner.set(inst, owner); - const ref = this.genericDemandPriority.get(inst) ?? { rank: [4, instanceMetadataOrder++] }; - this.genericDemandPriority.set(inst, ref); - // Body-level poisons skip the instance after retaining the - // diagnostic and every edge fired before poisoning. - try { - instanceFunctions.push(this.withGenericDemandOwner(owner, () => - this.shapes.withDeclaredOrderPriority(ref, () => this.lowerGenericInstance(info, inst)))); - } catch (e) { - if (!(e instanceof PoisonError)) throw e; + const waveEnd = this.instantiationQueue.length; + this.checker.prefetchRoots( + this.instantiationQueue + .slice(instLowered, waveEnd) + .flatMap(({ info }) => functionRoots(info.decl)), + ); + while (instLowered < waveEnd) { + const { info, inst } = this.instantiationQueue[instLowered++]!; + const owner = demandOwner(); + this.genericFunctionDemandOwner.set(inst, owner); + const ref = this.genericDemandPriority.get(inst) ?? { rank: [4, instanceMetadataOrder++] }; + this.genericDemandPriority.set(inst, ref); + // Body-level poisons skip the instance after retaining the + // diagnostic and every edge fired before poisoning. + try { + instanceFunctions.push(this.withGenericDemandOwner(owner, () => + this.shapes.withDeclaredOrderPriority(ref, () => this.lowerGenericInstance(info, inst)))); + } catch (e) { + if (!(e instanceof PoisonError)) throw e; + } } } // Emit-override specialization bodies fire edges of their own // (the super-forward chain, closures, generic calls) — lower them // exactly like generic instances. while (specLowered < this.emitSpecQueue.length) { - try { - const fn = this.shapes.withDeclaredOrderPriority( - [4, instanceMetadataOrder++], - () => lowerEmitOverrideSpec(this, this.emitSpecQueue[specLowered++]!), - ); - if (fn) instanceFunctions.push(fn); - } catch (e) { - if (!(e instanceof PoisonError)) throw e; + const waveEnd = this.emitSpecQueue.length; + this.checker.prefetchRoots( + this.emitSpecQueue + .slice(specLowered, waveEnd) + .flatMap(({ info }) => + info.emitOverride ? functionRoots(info.emitOverride.decl) : []), + ); + while (specLowered < waveEnd) { + try { + const fn = this.shapes.withDeclaredOrderPriority( + [4, instanceMetadataOrder++], + () => lowerEmitOverrideSpec(this, this.emitSpecQueue[specLowered++]!), + ); + if (fn) instanceFunctions.push(fn); + } catch (e) { + if (!(e instanceof PoisonError)) throw e; + } } } } }; + // Module inits are the unconditional root wave. Structure prefetch + // already covered ordinary top-level expressions, but this full-root + // pass also picks up nested/lifted function bodies and the class + // declaration-time code that splitFiles hoists out of topStmts. + this.checker.prefetchRoots([ + ...parts.flatMap((fp) => fp.topStmts), + ...[...this.classes.values()].flatMap((info) => [ + ...info.staticFields.map((field) => field.initializer), + ...(info.staticBlocks ?? []), + ...(info.classDecorators?.nodes ?? []), + ]), + ]); parts.forEach((fp, index) => { const priority = [2, index] as const; const owner = demandOwner(priority); @@ -2793,25 +2886,31 @@ export class Lowerer { // seed the worklist beside the init bodies. Unknown names are inert // (the export-map resolution reports them as SC4002 later). for (const root of extraRoots ?? []) this.onEdge?.(root); - while (queue.length > 0) { - // A body-level poison outside the per-statement catches (a fenced - // constructor/method parameter default lowered by declareParams): - // every edge fired before poisoning remains retained; the diagnostic - // stays recorded and the member stays omitted. - try { - const name = queue.shift()!; - const unit = units.get(name)!; - const priority = metadataPriority.get(name) ?? [3, expressionMetadataOrder++]; - const owner = demandOwner(priority); - const fn = this.withGenericDemandOwner( - owner, - () => this.shapes.withDeclaredOrderPriority(priority, unit.lower), - ); - if (fn) loweredUnits.set(name, fn); - } catch (e) { - if (!(e instanceof PoisonError)) throw e; + const drainUnits = (): void => { + while (queue.length > 0) { + const wave = queue.splice(0); + this.checker.prefetchRoots(wave.flatMap((name) => units.get(name)!.roots)); + for (const name of wave) { + // A body-level poison outside the per-statement catches (a fenced + // constructor/method parameter default lowered by declareParams): + // every edge fired before poisoning remains retained; the diagnostic + // stays recorded and the member stays omitted. + try { + const unit = units.get(name)!; + const priority = metadataPriority.get(name) ?? [3, expressionMetadataOrder++]; + const owner = demandOwner(priority); + const fn = this.withGenericDemandOwner( + owner, + () => this.shapes.withDeclaredOrderPriority(priority, unit.lower), + ); + if (fn) loweredUnits.set(name, fn); + } catch (e) { + if (!(e instanceof PoisonError)) throw e; + } + } } - } + }; + drainUnits(); this.restoreGenericInstanceOrder(); this.restoreGenericClassInstanceOrder(); // Generic bodies can reach ordinary declarations, whose bodies can in @@ -2821,21 +2920,7 @@ export class Lowerer { for (;;) { drainInstances(); if (queue.length === 0) break; - while (queue.length > 0) { - try { - const name = queue.shift()!; - const unit = units.get(name)!; - const priority = metadataPriority.get(name) ?? [3, expressionMetadataOrder++]; - const owner = demandOwner(priority); - const fn = this.withGenericDemandOwner( - owner, - () => this.shapes.withDeclaredOrderPriority(priority, unit.lower), - ); - if (fn) loweredUnits.set(name, fn); - } catch (e) { - if (!(e instanceof PoisonError)) throw e; - } - } + drainUnits(); this.restoreGenericInstanceOrder(instLowered); this.restoreGenericClassInstanceOrder(clsInstLowered); } diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 8a4c41e1..44a80a44 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -1794,13 +1794,14 @@ function preflight7(load: LoadResult): { for (const d of errorsOf(load.projectWorld())) diags.push(toPassthrough(d)); } + // Preflight and lowering share this CheckerFacade. Claim executable + // source files after workspace discovery and the tsc gate but before + // structural preflight checker queries, so a miss cannot trigger the old + // whole-file sweep (and eagerly fetch every dead body's answers). The + // structure wave includes declaration headers and top-level code; + // reachable bodies are added by lowering's worklists. const ambient = ambientDtsPath(); - // node_modules JS that no --npm-static opt-in claims is NOT program - // source even when maxNodeModuleJsDepth pulled it into the checker's - // program (see nodeModulesJsSuppressed above): its execution home is the - // island, so preflight's statement walks skip it — no import fences, no - // module edges, no statement counts from files the lowering never lowers. - const userFiles = program + const programFiles = program .getSourceFiles() .filter( (sf) => @@ -1808,13 +1809,16 @@ function preflight7(load: LoadResult): { !sf.isDeclarationFile && !sf.fileName.endsWith(".json") && (!isNodeModulesPath(sf.fileName) || npmStaticPackageOfPath(sf.fileName) !== null) && - // Workspace-linked shipped JS (see islandJsFile): its execution - // home is the island exactly like node_modules JS, so no import - // fences, no module edges, no statement counts from it. Its .ts - // files (a workspace package imported bare AND reached relatively) - // stay program source — only the island-bound JS steps out. !islandJsFile(sf.fileName), ); + program.getTypeChecker().prefetchSourceFileStructures(programFiles); + + // node_modules JS that no --npm-static opt-in claims is NOT program + // source even when maxNodeModuleJsDepth pulled it into the checker's + // program (see nodeModulesJsSuppressed above): its execution home is the + // island, so preflight's statement walks skip it — no import fences, no + // module edges, no statement counts from files the lowering never lowers. + const userFiles = programFiles; // Node stops at the nearest package.json even when it is malformed, and // an explicit CommonJS scope (or .cjs/.cts extension) disables ambiguous- diff --git a/packages/compiler/src/frontend/ts7/checker.ts b/packages/compiler/src/frontend/ts7/checker.ts index b3c6258c..c75eddf8 100644 --- a/packages/compiler/src/frontend/ts7/checker.ts +++ b/packages/compiler/src/frontend/ts7/checker.ts @@ -13,15 +13,14 @@ * memoize (warm re-query of 21 nodes costs 2.5-3.8 ms; the survey's * finding), so this layer is where reuse lives. * - * 2. PER-FILE BATCH PREFETCH. The first getTypeAtLocation/getSymbolAtLocation - * miss in a source file walks the whole file client-side (free — the AST - * is local) and issues the ARRAY overloads for every node, chunked, then - * answers all later queries for that file from the memo. The lowering's - * walk touches most of a file anyway, so prefetching the file is the - * batching lever without changing a single call site. prefetchSourceFile() - * exposes the same hook explicitly. Symbol prefetch also batch-fetches - * getTypeOfSymbol over every symbol the file mentions (5,333 calls of the - * mock-gateway census ride that pattern). + * 2. PHASE-AWARE BATCH PREFETCH. Ordinary callers keep the whole-file + * first-miss fallback, but the compiler explicitly batches declaration + * headers, top-level code, and each newly reachable body wave. Managed + * files then use direct memoized misses instead of accidentally sweeping + * every unreachable body. prefetchSourceFile() retains the whole-file + * escape hatch. Symbol prefetch also batch-fetches getTypeOfSymbol over + * every symbol each batch surfaces (5,333 calls of the mock-gateway + * census ride that pattern). * * 3. CLIENT-SIDE FAST PATHS. getBaseTypeOfLiteralType — the census's single * hottest method (9,059 calls on mock-gateway) — is answered locally from @@ -104,17 +103,76 @@ const TYPE_PREFETCH_KINDS = new Set([ SyntaxKind.ConditionalExpression, ]); -/** Preorder sweep of the whole file, ITERATIVE (walkPreorder): the obvious +/** Function-like declarations whose body is deferred until reachability + * asks for it. Header prefetch walks their names, type parameters, params, + * and return types but leaves the body for a later explicit body wave. */ +const DEFERRED_BODY_OWNERS = new Set([ + SyntaxKind.FunctionDeclaration, + SyntaxKind.FunctionExpression, + SyntaxKind.ArrowFunction, + SyntaxKind.MethodDeclaration, + SyntaxKind.Constructor, + SyntaxKind.GetAccessor, + SyntaxKind.SetAccessor, +]); + +type PrefetchWalk = "all" | "structure" | "reachable"; + +function isClassLikeKind(kind: SyntaxKind): boolean { + return kind === SyntaxKind.ClassDeclaration || kind === SyntaxKind.ClassExpression; +} + +function isClassMember(node: Node | undefined): boolean { + return node?.parent !== undefined && isClassLikeKind(node.parent.kind); +} + +function isDeferredExecutableRoot(node: Node, walk: PrefetchWalk): boolean { + if (walk === "all") return false; + const parent = node.parent as (Node & { + body?: Node; + initializer?: Node; + modifiers?: readonly Node[]; + }) | undefined; + if (parent === undefined) return false; + if (DEFERRED_BODY_OWNERS.has(parent.kind) && parent.body === node) { + // Structure collection defers every function-like body. A reached + // outer body still eagerly lowers nested closures/functions, but class + // methods remain independent reachability units. + return walk === "structure" || isClassMember(parent); + } + // Parameter defaults execute on function entry, not while its signature + // is collected. Keep an unreachable declaration's default cold too. + if (parent.kind === SyntaxKind.Parameter && parent.initializer === node) { + return walk === "structure" || isClassMember(parent.parent); + } + // Instance field initializers execute in the constructor. Static fields + // remain declaration-time code and therefore stay in the structure wave. + return ( + parent.kind === SyntaxKind.PropertyDeclaration && + parent.initializer === node && + !parent.modifiers?.some((modifier) => modifier.kind === SyntaxKind.StaticKeyword) + ); +} + +/** Preorder sweep of one or more roots, ITERATIVE (walkPreorder): the obvious * recursive forEachChild walk overflowed the stack HERE, in the prefetch * sweep, on the binderBinaryExpressionStress chains — before lowering could - * answer with its SC1090 nesting fence. */ -function collectNodes(sf: Node): Node[] { + * answer with its SC1090 nesting fence. Overlapping roots are identity- + * deduped so a header/body wave never sends the same node twice. */ +function collectNodes(roots: readonly Node[], walk: PrefetchWalk = "all"): Node[] { const nodes: Node[] = []; - walkPreorder(sf, (n, depth) => { - nodes.push(n); - if (depth >= PREFETCH_MAX_DEPTH) return "skip"; - return undefined; - }); + const seen = new Set(); + for (const root of roots) { + walkPreorder(root, (n, depth) => { + if (n !== root && isDeferredExecutableRoot(n, walk)) return "skip"; + if (!seen.has(n)) { + seen.add(n); + nodes.push(n); + } + if (depth >= PREFETCH_MAX_DEPTH) return "skip"; + return undefined; + }); + } return nodes; } @@ -148,6 +206,11 @@ export class CheckerFacade { /** Files whose nodes have been batch-prefetched, per query kind. */ private readonly prefetchedTypes = new WeakSet(); private readonly prefetchedSymbols = new WeakSet(); + /** Files owned by explicit phase-aware prefetch. A miss in one of these + * files must stay a direct memoized query; falling back to whole-file + * prefetch would silently pull every unreachable body back into a build. */ + private readonly managedTypes = new WeakSet(); + private readonly managedSymbols = new WeakSet(); private unknownType: Type | null = null; /** Intrinsic singletons (string/number/bigint/boolean), fetched once. */ private readonly intrinsics = new Map(); @@ -254,14 +317,46 @@ export class CheckerFacade { this.prefetchSymbols(sf); } + /** Batches the non-body structure of many files as ONE logical wave. + * Top-level executable statements, class field initializers/static blocks, + * and every declaration header are included; function/method/constructor + * bodies wait for reachability. Calling this also opts the files out of + * accidental whole-file first-miss prefetch. */ + prefetchSourceFileStructures(files: readonly SourceFile[]): void { + this.markManaged(files); + this.prefetchNodes(collectNodes(files, "structure")); + } + + /** Batches all checker-hot nodes under many reached roots. Lowering uses + * this for all init bodies together and for each declaration/instance + * worklist wave. The roots may overlap; identity deduplication and the + * answer memos make warm repeats free. */ + prefetchRoots(roots: readonly Node[]): void { + this.markManaged(roots); + this.prefetchNodes(collectNodes(roots, "reachable")); + } + + private markManaged(roots: readonly Node[]): void { + for (const root of roots) { + const sf = root.getSourceFile(); + this.managedTypes.add(sf); + this.managedSymbols.add(sf); + } + } + + private prefetchNodes(nodes: readonly Node[]): void { + this.prefetchTypeNodes(nodes); + this.prefetchSymbolNodes(nodes); + } + private prefetchTypes(sf: SourceFile): void { if (this.prefetchedTypes.has(sf)) return; this.prefetchedTypes.add(sf); - this.prefetchTypesIn(sf); + this.prefetchTypeNodes(collectNodes([sf])); } - private prefetchTypesIn(root: Node): void { - const nodes = collectNodes(root).filter( + private prefetchTypeNodes(allNodes: readonly Node[]): void { + const nodes = allNodes.filter( (n) => TYPE_PREFETCH_KINDS.has(n.kind) && !this.typeAtLocation.has(n), ); const types = chunked(nodes, (chunk) => this.typesWithPanicFence(chunk)); @@ -277,11 +372,11 @@ export class CheckerFacade { private prefetchSymbols(sf: SourceFile): void { if (this.prefetchedSymbols.has(sf)) return; this.prefetchedSymbols.add(sf); - this.prefetchSymbolsIn(sf); + this.prefetchSymbolNodes(collectNodes([sf])); } - private prefetchSymbolsIn(root: Node): void { - const nodes = collectNodes(root).filter( + private prefetchSymbolNodes(allNodes: readonly Node[]): void { + const nodes = allNodes.filter( (n) => n.kind === SyntaxKind.Identifier && !this.symbolAtLocation.has(n), ); // The same bisecting panic fence as the type sweep: tsgo panics on @@ -305,8 +400,11 @@ export class CheckerFacade { private autoPrefetch(node: Node, kind: "types" | "symbols"): void { if (this.options.autoPrefetch === false) return; const sf = node.getSourceFile(); - if (kind === "types") this.prefetchTypes(sf); - else this.prefetchSymbols(sf); + if (kind === "types") { + if (!this.managedTypes.has(sf)) this.prefetchTypes(sf); + } else if (!this.managedSymbols.has(sf)) { + this.prefetchSymbols(sf); + } } getTypeAtLocation(node: Node): Type { diff --git a/packages/compiler/test/ts7/facade.test.ts b/packages/compiler/test/ts7/facade.test.ts index cbb7c4ab..84910532 100644 --- a/packages/compiler/test/ts7/facade.test.ts +++ b/packages/compiler/test/ts7/facade.test.ts @@ -18,21 +18,27 @@ afterAll(() => { host.close(); }); -function countingChecker(raw: Checker): { proxy: Checker; counts: Record } { +function countingChecker(raw: Checker): { + proxy: Checker; + counts: Record; + calls: Record; +} { const counts: Record = {}; + const calls: Record = {}; const proxy = new Proxy(raw, { get(target, prop, receiver) { const value = Reflect.get(target, prop, receiver); if (typeof value === "function" && typeof prop === "string") { return (...args: unknown[]) => { counts[prop] = (counts[prop] ?? 0) + 1; + (calls[prop] ??= []).push(args); return (value as (...a: unknown[]) => unknown).apply(target, args); }; } return value; }, }); - return { proxy, counts }; + return { proxy, counts, calls }; } function build(): { w: TwoWorlds; facade: CheckerFacade; counts: Record } { @@ -171,6 +177,153 @@ test("explicit prefetchSourceFile primes hot kinds and direct fallbacks memoize" expect(counts).toEqual(afterWalk); }); +test("managed structure and body waves batch across roots without touching deferred code", () => { + const w = buildTwoWorlds({ + "waves.ts": ` +export function reached(input: number = Math.random()): number { + const reachedLocal = { value: input }; + return reachedLocal.value; +} +export function dead(input: string): string { + const deadLocal = [input]; + return deadLocal[0]!; +} +export class Holder { + value = Math.random(); +} +export function withClass(): number { + class Nested { + method(): number { return Math.random(); } + } + return new Nested().method(); +} +const top = reached(1); +void top; +`, + }, host); + worlds.push(w); + const { proxy, counts, calls } = countingChecker(w.p7.project.checker); + const facade = new CheckerFacade(proxy); + const sf = w.p7.getSourceFile(w.files[0]!)!; + const functions = sf.statements.filter(ad.isFunctionDeclaration); + const cls = sf.statements.find(ad.isClassDeclaration)!; + const withClass = functions[2]!; + const nested = withClass.body!.statements.find(ad.isClassDeclaration)!; + const reachedBody = functions[0]!.body!; + const deadBody = functions[1]!.body!; + const nestedMethodBody = nested.members.find(ad.isMethodDeclaration)!.body!; + const defaultValue = functions[0]!.parameters[0]!.initializer!; + const fieldValue = cls.members.find(ad.isPropertyDeclaration)!.initializer!; + const inside = (node: Node, root: Node): boolean => + node.getStart() >= root.getStart() && node.end <= root.end; + + facade.prefetchSourceFileStructures([sf]); + const headerTypeNodes = calls["getTypeAtLocation"]?.[0]?.[0] as Node[]; + const headerSymbolNodes = calls["getSymbolAtLocation"]?.[0]?.[0] as Node[]; + expect(headerTypeNodes.length).toBeGreaterThan(0); + expect(headerSymbolNodes.length).toBeGreaterThan(0); + const deferred = [reachedBody, deadBody, defaultValue, fieldValue]; + expect(headerTypeNodes.every((node) => deferred.every((root) => !inside(node, root)))).toBe(true); + expect(headerSymbolNodes.every((node) => deferred.every((root) => !inside(node, root)))).toBe(true); + + const beforeBodies = { ...counts }; + facade.prefetchRoots([reachedBody, deadBody, defaultValue, fieldValue]); + expect(counts["getTypeAtLocation"]).toBe((beforeBodies["getTypeAtLocation"] ?? 0) + 1); + expect(counts["getSymbolAtLocation"]).toBe((beforeBodies["getSymbolAtLocation"] ?? 0) + 1); + const bodyTypeNodes = calls["getTypeAtLocation"]!.at(-1)![0] as Node[]; + expect(bodyTypeNodes.some((node) => inside(node, reachedBody))).toBe(true); + expect(bodyTypeNodes.some((node) => inside(node, deadBody))).toBe(true); + expect(bodyTypeNodes.some((node) => inside(node, defaultValue))).toBe(true); + expect(bodyTypeNodes.some((node) => inside(node, fieldValue))).toBe(true); + + const beforeOuterBody = { ...counts }; + facade.prefetchRoots([withClass.body!]); + expect(counts["getTypeAtLocation"]).toBe((beforeOuterBody["getTypeAtLocation"] ?? 0) + 1); + const outerTypeNodes = calls["getTypeAtLocation"]!.at(-1)![0] as Node[]; + expect(outerTypeNodes.some((node) => inside(node, withClass.body!))).toBe(true); + expect(outerTypeNodes.every((node) => !inside(node, nestedMethodBody))).toBe(true); + + const warm = { ...counts }; + facade.prefetchRoots([reachedBody, deadBody, defaultValue, fieldValue]); + expect(counts).toEqual(warm); +}); + +test("managed misses stay direct instead of falling back to whole-file prefetch", () => { + const w = buildTwoWorlds({ + "managed.ts": ` +export function dead(input: number): number { + const first = input + 1; + const second = first + 1; + return second; +} +`, + }, host); + worlds.push(w); + const { proxy, counts, calls } = countingChecker(w.p7.project.checker); + const facade = new CheckerFacade(proxy); + const sf = w.p7.getSourceFile(w.files[0]!)!; + const body = sf.statements.find(ad.isFunctionDeclaration)!.body!; + const identifiers: Node[] = []; + ad.walkPreorder(body, (node) => { + if (ad.isIdentifier(node)) identifiers.push(node); + }); + + facade.prefetchSourceFileStructures([sf]); + const before = counts["getTypeAtLocation"] ?? 0; + facade.getTypeAtLocation(identifiers[0]!); + facade.getTypeAtLocation(identifiers[1]!); + expect(counts["getTypeAtLocation"]).toBe(before + 2); + expect(Array.isArray(calls["getTypeAtLocation"]!.at(-1)![0])).toBe(false); +}); + +test("root prefetch panic-fences bad nodes and keeps healthy answers warm", () => { + const w = buildTwoWorlds({ + "panic.ts": ` +export function f(input: number): number { + const healthy = input + 1; + const poison = healthy + 1; + return poison; +} +`, + }, host); + worlds.push(w); + const sf = w.p7.getSourceFile(w.files[0]!)!; + const body = sf.statements.find(ad.isFunctionDeclaration)!.body!; + const identifiers: Node[] = []; + ad.walkPreorder(body, (node) => { + if (ad.isIdentifier(node)) identifiers.push(node); + }); + const poison = identifiers.find((node) => node.getText(sf) === "poison")!; + const healthy = identifiers.find((node) => node.getText(sf) === "healthy")!; + const raw = w.p7.project.checker; + let panics = 0; + const panicky = new Proxy(raw, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver); + if (prop === "getTypeAtLocation") { + return (nodes: Node | Node[]) => { + if (Array.isArray(nodes) && nodes.includes(poison)) { + panics++; + throw new Error("synthetic checker panic"); + } + return (value as (nodes: Node | Node[]) => unknown).call(target, nodes); + }; + } + return typeof value === "function" ? value.bind(target) : value; + }, + }) as Checker; + const facade = new CheckerFacade(panicky); + + facade.prefetchRoots([body]); + expect(panics).toBeGreaterThan(1); + expect(facade.getTypeAtLocation(healthy)).toBe(raw.getTypeAtLocation(healthy)); + expect(facade.getTypeAtLocation(poison)).toBe(raw.getAnyType()); + const warmPanics = panics; + facade.prefetchRoots([body]); + facade.getTypeAtLocation(poison); + expect(panics).toBe(warmPanics); +}); + test("autoPrefetch: false degrades to per-call queries (the escape hatch works)", () => { const w = buildTwoWorlds(RICH_TS, host); worlds.push(w); From d7b17cf1d50c18e1b64985ac6b36a6c729418af2 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 20 Aug 2026 15:36:22 -0500 Subject: [PATCH 2/5] Batch checker queries in coverage remainder --- .../compiler/src/frontend/lowering/lowerer.ts | 9 ++++ packages/compiler/test/ts7/facade.test.ts | 54 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index ede9e375..11aa02c8 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -2257,6 +2257,15 @@ export class Lowerer { run(): LowerResult { const parts = this.splitFiles(); + // The coverage remainder deliberately visits every body that reachable + // emit skipped. Those files are already phase-managed, so an ordinary + // checker miss would stay a one-node IPC query instead of falling back + // to the facade's whole-file batch. Coverage has no dead-body boundary + // to preserve: prime each complete file now, reusing the answers the + // reachable pass already memoized and batching only the cold remainder. + if (this.remainder) { + for (const fp of parts) this.checker.prefetchSourceFile(fp.sf); + } this.collectProgram(parts); // Decorated classes analyze AFTER the whole collection pass: a // decorator's return type may name a subclass declared below the diff --git a/packages/compiler/test/ts7/facade.test.ts b/packages/compiler/test/ts7/facade.test.ts index 84910532..fe04f55c 100644 --- a/packages/compiler/test/ts7/facade.test.ts +++ b/packages/compiler/test/ts7/facade.test.ts @@ -4,6 +4,7 @@ * the raw checker's answers on the same objects. */ import { afterAll, expect, test } from "vitest"; +import { lowerToIr } from "../../src/frontend/lowering/lowerer.js"; import { CheckerFacade } from "../../src/frontend/ts7/checker.js"; import type { Node } from "typescript/unstable/ast"; import type { Checker, Type } from "typescript/unstable/sync"; @@ -276,6 +277,59 @@ export function dead(input: number): number { expect(Array.isArray(calls["getTypeAtLocation"]!.at(-1)![0])).toBe(false); }); +test("coverage remainder batches checker queries for unreachable bodies", () => { + const w = buildTwoWorlds({ + "coverage-remainder.ts": ` +function reached(input: number): number { + return input + 1; +} +function dead(input: number): number { + const record = { value: input }; + const values = [record.value]; + return values[0]!; +} +console.log(reached(1)); +`, + }, host); + worlds.push(w); + const { proxy, calls } = countingChecker(w.p7.project.checker); + const facade = new CheckerFacade(proxy, { project: w.p7.project }); + // Ts7Program owns one shared facade; install the counting instance so + // lowering and this assertion observe the same memo/batch traffic. + (w.p7 as unknown as { checkerFacade: CheckerFacade | null }).checkerFacade = facade; + const sf = w.p7.getSourceFile(w.files[0]!)!; + const deadBody = sf.statements + .filter(ad.isFunctionDeclaration) + .find((decl) => decl.name?.text === "dead")!.body!; + const insideDeadBody = (node: Node): boolean => + node.getStart() >= deadBody.getStart() && node.end <= deadBody.end; + + lowerToIr(w.p7, sf, [sf], { coverage: true }); + + const hotTypeKinds = new Set([ + ad.SyntaxKind.Identifier, + ad.SyntaxKind.PropertyAccessExpression, + ad.SyntaxKind.ObjectLiteralExpression, + ad.SyntaxKind.ArrayLiteralExpression, + ad.SyntaxKind.ConditionalExpression, + ]); + const typeBodyCalls = calls["getTypeAtLocation"] ?? []; + expect(typeBodyCalls.some(([arg]) => + Array.isArray(arg) && (arg as Node[]).some(insideDeadBody), + )).toBe(true); + expect(typeBodyCalls.some(([arg]) => + !Array.isArray(arg) && insideDeadBody(arg as Node) && hotTypeKinds.has((arg as Node).kind), + )).toBe(false); + + const symbolBodyCalls = calls["getSymbolAtLocation"] ?? []; + expect(symbolBodyCalls.some(([arg]) => + Array.isArray(arg) && (arg as Node[]).some(insideDeadBody), + )).toBe(true); + expect(symbolBodyCalls.some(([arg]) => + !Array.isArray(arg) && insideDeadBody(arg as Node) && ad.isIdentifier(arg as Node), + )).toBe(false); +}); + test("root prefetch panic-fences bad nodes and keeps healthy answers warm", () => { const w = buildTwoWorlds({ "panic.ts": ` From b46f3b1875b6aa98214b80579810fe3d79755a62 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 20 Aug 2026 17:37:55 -0500 Subject: [PATCH 3/5] Batch JavaScript class collection queries --- .../compiler/src/frontend/lowering/lowerer.ts | 64 ++++++++++++++++++- packages/compiler/src/frontend/ts7/checker.ts | 29 ++++++++- packages/compiler/test/ts7/facade.test.ts | 41 ++++++++++++ 3 files changed, 131 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 11aa02c8..5a2c8172 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -93,7 +93,7 @@ import { CompoundOp, IslandFnEntry, boundaryIntoIslandMsg, boundaryOutOfIslandMs import { FileParts, splitFiles, collectProgram, collectNpmImports, collectJsonImports, moduleArtifacts, collectGlobals, declSymbolOf, defaultExportSymbolOf, lowerFileInit, lowerDefaultExport, buildMain, appendDynamicImportModules } from "./lower-modules.js"; import { ClassInfo, ClassIteratorInfo, GenericClassInfo, registerBuiltinErrorClasses, registerBuiltinEmitterClass, registerBuiltinStreamClasses, builtinErrorInfoOf, builtinEmitterInfoOf, builtinStreamInfoOf, analyzeClassDecoration, classIteratorDrainCall, classIteratorNextCall, classIteratorOf, classIteratorOpenCall, classIteratorRestDrainCall, classMemberNameOf, classValueRef, collectClassShape, exactClassOfReceiver, collectClassShapeInner, ctorAbiEquals, findMethodOn, findStaticOn, findGenericMethodOn, findGenericStaticOn, genericClassInstanceType, isSubclassOf, inHierarchy, overrideBelow, staticShadowBelow, upcastTo, lowerClassMembers, lowerClassCtor, lowerClassExpression, lowerClassExpressionInfo, lowerClassMethodMember, lowerClassValueProperty, lowerStaticMethod, throwingSetterFn, fieldInitStmts, lowerStaticFieldInits, lowerStaticFieldRead, lowerDerivedCtorBody, superCallStmt, lowerSuperMethodCall, superThisRef, lowerSuperAccessorRead, lowerSuperAccessorWrite, inheritsBuiltinErrorCtor, inheritsBuiltinEmitterCtor, errorMessageArg, lowerNew, accessorCall } from "./lower-classes.js"; import { MixinFnShape, mixinCallClassInfoOf, mixinIntersectionInstanceType } from "./lower-mixins.js"; -import { ParamShape, FnSig, GenericFnInfo, GenericInstance, bindingNeverReassigned, bodyReadsArguments, isThisParameter, paramShape, paramShapes, checkDefaultParamBodyType, completeArgs, wrappedUndefined, undefinedArgFor, requireExactArityValue, bodyReturnType, declaredReturnType, collectSignature, collectSignatureInner, collectGenericSignature, genericFnOf, lowerGenericCall, lowerGenericFnValue, inferTypeParamBindings, lowerGenericInstance, lowerCall, lowerFfiCall, lowerTimersMemberCall, lowerPromiseMethodCall, lowerFilterNarrowCall, isTopLevelFnSymbol, lowerNestedFunctionDecl, lambdaSignature, lowerLambda, lowerFunction, validateFfiImports } from "./lower-calls.js"; +import { ParamShape, FnSig, GenericFnInfo, GenericInstance, bindingNeverReassigned, bodyReadsArguments, implicitMonoFile, isThisParameter, paramShape, paramShapes, checkDefaultParamBodyType, completeArgs, wrappedUndefined, undefinedArgFor, requireExactArityValue, bodyReturnType, declaredReturnType, collectSignature, collectSignatureInner, collectGenericSignature, genericFnOf, lowerGenericCall, lowerGenericFnValue, inferTypeParamBindings, lowerGenericInstance, lowerCall, lowerFfiCall, lowerTimersMemberCall, lowerPromiseMethodCall, lowerFilterNarrowCall, isTopLevelFnSymbol, lowerNestedFunctionDecl, lambdaSignature, lowerLambda, lowerFunction, validateFfiImports } from "./lower-calls.js"; import { lowerArrayMethodCall, lowerBufferStaticCall, lowerBytesMethodCall, lowerBytesNew, lowerMapMethodCall, lowerMapForEachCall, buildMapForEachFn, lowerRecordOvfCaptureHelper, lowerEnvToPairsHelper, lowerSetMethodCall, lowerSetForEachCall, buildSetForEachFn, lowerRegexMethodCall, lowerStringMethodCall } from "./lower-containers.js"; import { lowerStreamModuleCall } from "./lower-stream.js"; import { lowerEmitOverrideSpec, type EmitSpecCtx, type EmitSpecRequest } from "./lower-emitter.js"; @@ -2612,6 +2612,11 @@ export class Lowerer { // Establish the same managed header/top-level batch here before // collection, while the production path simply finds warm memos. this.checker.prefetchSourceFileStructures(parts.map((fp) => fp.sf)); + // JavaScript class shapes are partly declared by constructor-body + // assignments. Batch those collection-time queries across declarations + // before collectProgram visits them one by one; reached body lowering + // will reuse the same answers later. + this.prefetchClassCollection(parts.flatMap((fp) => fp.classDecls)); this.collectProgram(parts); // Decorated classes analyze post-collection here too: the %init seeds // lower the decoration calls, whose edges (decorator bodies, construct @@ -6869,6 +6874,58 @@ export class Lowerer { /* ── classes ──────────────────────────────────────────────────────── */ + /** Checker work class SHAPE collection performs inside otherwise + * deferred JavaScript bodies. Constructor assignments declare fields, + * so collection asks for each `this.x` symbol and RHS type even when the + * constructor is unreachable. Keep that mandatory work batched without + * sweeping unrelated dead method bodies. */ + private prefetchClassCollection(decls: readonly ts.ClassLikeDeclaration[]): void { + const typeNodes: ts.Node[] = []; + const symbolRoots: ts.Node[] = []; + for (const decl of decls) { + if (!isJsSourceFile(decl.getSourceFile())) continue; + for (const member of decl.members) { + if (ts.isConstructorDeclaration(member)) { + for (const param of member.parameters) { + if (param.initializer) typeNodes.push(param.initializer); + } + for (const stmt of member.body?.statements ?? []) { + if (!ts.isExpressionStatement(stmt) || !ts.isBinaryExpression(stmt.expression)) continue; + if (stmt.expression.operatorToken.kind !== ts.SyntaxKind.EqualsToken) continue; + const lhs = stmt.expression.left; + if ( + (ts.isPropertyAccessExpression(lhs) || ts.isElementAccessExpression(lhs)) && + lhs.expression.kind === ts.SyntaxKind.ThisKeyword + ) { + symbolRoots.push(lhs); + // Field inference normally uses the symbol's type, but its + // panic/undefined fallback asks for the assignment node too. + typeNodes.push(lhs, stmt.expression.right); + } + } + continue; + } + if ( + ts.isMethodDeclaration(member) || + ts.isGetAccessor(member) || + ts.isSetAccessor(member) + ) { + for (const param of member.parameters) { + if (param.initializer) typeNodes.push(param.initializer); + } + // npm-static implicit-any classification resolves body parameter + // references while collecting the method's shape. + if (implicitMonoFile(decl.getSourceFile()) && member.body) { + symbolRoots.push(member.body); + } + } + } + } + if (typeNodes.length > 0 || symbolRoots.length > 0) { + this.checker.prefetchClassCollection(typeNodes, symbolRoots); + } + } + collectClassShape(decl: ts.ClassDeclaration): void { return collectClassShape(this, decl); } @@ -6876,6 +6933,11 @@ export class Lowerer { collectClassShapeInner(decl: ts.ClassLikeDeclaration, jsNameOverride?: string, inst?: { family: ClassInfo; name: string; bindings: Map; typeArgsText: string; ordinal: number }, mixin?: { base: ClassInfo; name: string; call: ts.CallExpression; bindings: Map; context: string; ordinal: number },): void { + // Late class expressions and mixin/generic instances do not participate + // in emitReachable's initial declaration wave. Prime their mandatory + // shape queries at the collection boundary; initial declarations are + // already warm and this is memo-free. + this.prefetchClassCollection([decl]); return collectClassShapeInner(this, decl, jsNameOverride, inst, mixin); } diff --git a/packages/compiler/src/frontend/ts7/checker.ts b/packages/compiler/src/frontend/ts7/checker.ts index c75eddf8..03ed7bc7 100644 --- a/packages/compiler/src/frontend/ts7/checker.ts +++ b/packages/compiler/src/frontend/ts7/checker.ts @@ -336,6 +336,25 @@ export class CheckerFacade { this.prefetchNodes(collectNodes(roots, "reachable")); } + /** Batches the exact body nodes class-shape collection reads before body + * reachability is known. JavaScript field inference asks for the RHS type + * and for a symbol on the `this.x` property access itself; ordinary + * prefetch intentionally covers neither uncommon RHS kinds nor symbols + * on non-identifiers. Descendants of symbol roots join because computed + * `this[key]` declarations resolve the key identifier too. */ + prefetchClassCollection( + typeNodes: readonly Node[], + symbolRoots: readonly Node[], + ): void { + this.markManaged([...typeNodes, ...symbolRoots]); + const distinctTypes = [...new Set(typeNodes)].filter( + (node) => !this.typeAtLocation.has(node), + ); + const types = chunked(distinctTypes, (chunk) => this.typesWithPanicFence(chunk)); + distinctTypes.forEach((node, index) => this.typeAtLocation.set(node, types[index])); + this.prefetchSymbolNodes(collectNodes(symbolRoots, "reachable"), true); + } + private markManaged(roots: readonly Node[]): void { for (const root of roots) { const sf = root.getSourceFile(); @@ -375,9 +394,15 @@ export class CheckerFacade { this.prefetchSymbolNodes(collectNodes([sf])); } - private prefetchSymbolNodes(allNodes: readonly Node[]): void { + private prefetchSymbolNodes( + allNodes: readonly Node[], + includePropertyAccess = false, + ): void { const nodes = allNodes.filter( - (n) => n.kind === SyntaxKind.Identifier && !this.symbolAtLocation.has(n), + (n) => + (n.kind === SyntaxKind.Identifier || + (includePropertyAccess && n.kind === SyntaxKind.PropertyAccessExpression)) && + !this.symbolAtLocation.has(n), ); // The same bisecting panic fence as the type sweep: tsgo panics on // SYMBOL queries too (observed: GetSymbolAtLocation over an diff --git a/packages/compiler/test/ts7/facade.test.ts b/packages/compiler/test/ts7/facade.test.ts index fe04f55c..76d7f962 100644 --- a/packages/compiler/test/ts7/facade.test.ts +++ b/packages/compiler/test/ts7/facade.test.ts @@ -277,6 +277,47 @@ export function dead(input: number): number { expect(Array.isArray(calls["getTypeAtLocation"]!.at(-1)![0])).toBe(false); }); +test("JavaScript class-shape collection batches constructor field queries", () => { + const fields = Array.from( + { length: 24 }, + (_, index) => ` this.value${index} = { nested: input };`, + ).join("\n"); + const w = buildTwoWorlds({ + "dead-class.js": ` +class Dead { + constructor(input) { +${fields} + } + method() { + return this.value0.nested; + } +} +console.log("ok"); +`, + }, host); + worlds.push(w); + const { proxy, calls } = countingChecker(w.p7.project.checker); + const facade = new CheckerFacade(proxy, { project: w.p7.project }); + (w.p7 as unknown as { checkerFacade: CheckerFacade | null }).checkerFacade = facade; + const sf = w.p7.getSourceFile(w.files[0]!)!; + const cls = sf.statements.find(ad.isClassDeclaration)!; + const ctor = cls.members.find(ad.isConstructorDeclaration)!; + const insideCtor = (node: Node): boolean => + node.getStart() >= ctor.getStart() && node.end <= ctor.end; + + lowerToIr(w.p7, sf, [sf]); + + for (const name of ["getTypeAtLocation", "getSymbolAtLocation"]) { + const checkerCalls = calls[name] ?? []; + expect(checkerCalls.some(([arg]) => + Array.isArray(arg) && (arg as Node[]).some(insideCtor), + )).toBe(true); + expect(checkerCalls.some(([arg]) => + !Array.isArray(arg) && insideCtor(arg as Node), + )).toBe(false); + } +}); + test("coverage remainder batches checker queries for unreachable bodies", () => { const w = buildTwoWorlds({ "coverage-remainder.ts": ` From a873826d43a8304b51ba074c132895ee6310784e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 20 Aug 2026 18:01:44 -0500 Subject: [PATCH 4/5] Batch managed checker analysis gaps --- .../compiler/src/frontend/lowering/lowerer.ts | 31 ++++++++++ packages/compiler/src/frontend/program.ts | 32 ++++++++++- packages/compiler/src/frontend/ts7/checker.ts | 34 ++++++++++- packages/compiler/test/ts7/facade.test.ts | 57 +++++++++++++++++++ packages/compiler/test/ts7/program.test.ts | 53 +++++++++++++++++ 5 files changed, 205 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 5a2c8172..abff80e1 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -2612,6 +2612,20 @@ export class Lowerer { // Establish the same managed header/top-level batch here before // collection, while the production path simply finds warm memos. this.checker.prefetchSourceFileStructures(parts.map((fp) => fp.sf)); + // Signature collection may read the initializer type of a default whose + // body type still admits undefined (for example `value = + // process.env.VALUE`). The expression executes only when reached, but + // that type query is mandatory now; batch hot defaults across ordinary + // declarations before collectProgram visits their signatures. + this.checker.prefetchCollectionTypes( + parts.flatMap((fp) => + fp.fnDecls + .filter((decl) => decl.body !== undefined && decl.typeParameters === undefined) + .flatMap((decl) => + decl.parameters.flatMap((param) => param.initializer ? [param.initializer] : []), + ), + ), + ); // JavaScript class shapes are partly declared by constructor-body // assignments. Batch those collection-time queries across declarations // before collectProgram visits them one by one; reached body lowering @@ -6880,9 +6894,23 @@ export class Lowerer { * constructor is unreachable. Keep that mandatory work batched without * sweeping unrelated dead method bodies. */ private prefetchClassCollection(decls: readonly ts.ClassLikeDeclaration[]): void { + const defaultTypeNodes: ts.Node[] = []; const typeNodes: ts.Node[] = []; const symbolRoots: ts.Node[] = []; for (const decl of decls) { + for (const member of decl.members) { + if ( + (ts.isConstructorDeclaration(member) || + ts.isMethodDeclaration(member) || + ts.isGetAccessor(member) || + ts.isSetAccessor(member)) && + (!ts.isMethodDeclaration(member) || member.typeParameters === undefined) + ) { + for (const param of member.parameters) { + if (param.initializer) defaultTypeNodes.push(param.initializer); + } + } + } if (!isJsSourceFile(decl.getSourceFile())) continue; for (const member of decl.members) { if (ts.isConstructorDeclaration(member)) { @@ -6921,6 +6949,9 @@ export class Lowerer { } } } + if (defaultTypeNodes.length > 0) { + this.checker.prefetchCollectionTypes(defaultTypeNodes); + } if (typeNodes.length > 0 || symbolRoots.length > 0) { this.checker.prefetchClassCollection(typeNodes, symbolRoots); } diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 44a80a44..67ccb28f 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -621,12 +621,22 @@ function requireTdzRisk7( return undefined; }); }; + // These are the exact roots the TDZ analysis scans eagerly. Their files + // are phase-managed already, so warm their deferred identifiers as one + // symbol-only batch instead of paying one IPC query per occurrence. + checker.prefetchSymbolRoots( + stmts.slice(0, k).filter((stmt) => !ts.isFunctionDeclaration(stmt)), + ); for (let i = 0; i < k && hit === null; i++) { const s = stmts[i]!; if (ts.isFunctionDeclaration(s)) continue; scan(s); } while (hit === null && work.length > 0) { + // A scanned reference can make a hoisted declaration's body reachable + // to this analysis. Batch every currently discovered root before + // retaining the historical LIFO traversal order. + checker.prefetchSymbolRoots(work); scan(work.pop()!); } return hit; @@ -920,6 +930,7 @@ function selfImportTdzFences7( const checkNamed = (bindingName: ts.Identifier): void => { const sym = checker.getSymbolAtLocation(bindingName); if (sym === undefined || !(sym.flags & ts.SymbolFlags.Alias)) return; + checker.prefetchSymbolNodesExact(identifierOccurrences7(sf, bindingName.text)); const end = tdzEndOf(checker.getAliasedSymbol(sym)); if (end === null) return; const visit = (node: ts.Node): void => { @@ -947,6 +958,7 @@ function selfImportTdzFences7( const nsName = clause.namedBindings.name; const nsSym = checker.getSymbolAtLocation(nsName); if (nsSym === undefined) return; + checker.prefetchSymbolNodesExact(identifierOccurrences7(sf, nsName.text)); const visit = (node: ts.Node): void => { if ( ts.isPropertyAccessExpression(node) && @@ -981,6 +993,7 @@ function nsBindingUsesAreBareStatements7( const checker = program.getTypeChecker(); const bindingSym = checker.getSymbolAtLocation(nsName); if (bindingSym === undefined) return false; + checker.prefetchSymbolNodesExact(identifierOccurrences7(sf, nsName.text)); let bareOnly = true; const visit = (node: ts.Node): void => { if (!bareOnly) return; @@ -1294,6 +1307,18 @@ function chainRoot7(e: ts.Expression): ts.Expression { return root; } +/** Identifier occurrences a binding-identity preflight scan will query. + * Gathered without checker traffic so managed deferred bodies can be + * warmed in one exact symbol batch before the semantic walk. */ +function identifierOccurrences7(root: ts.Node, text: string): ts.Identifier[] { + const out: ts.Identifier[] = []; + ts.walkPreorder(root, (node) => { + if (ts.isIdentifier(node) && node.text === text) out.push(node); + return undefined; + }); + return out; +} + /** The first use of a binding this cycle-closing import introduces that * sits OUTSIDE a deferred position (a read there can observe the * partially-initialized exporter), or null when every use defers. @@ -1317,6 +1342,7 @@ function backEdgeUseOffence7( for (const bindingName of bindingNames) { const sym = checker.getSymbolAtLocation(bindingName); if (sym === undefined) continue; + checker.prefetchSymbolNodesExact(identifierOccurrences7(sf, bindingName.text)); let offence: { name: string; node: ts.Node } | null = null; const visit = (node: ts.Node): void => { if (offence !== null || ts.isImportDeclaration(node)) return; @@ -2316,7 +2342,11 @@ function preflight7(load: LoadResult): { if (dep) deps.push({ dep }); } } - for (const call of nestedBareRequiresOf7(sf)) { + const nestedBareRequires = nestedBareRequiresOf7(sf); + program.getTypeChecker().prefetchSymbolNodesExact( + nestedBareRequires.flatMap((call) => ts.isIdentifier(call.expression) ? [call.expression] : []), + ); + for (const call of nestedBareRequires) { const spec = requireSpecOf7(call)!; const loc = { file: sf.fileName, start: call.getStart(sf), end: call.getEnd() }; if (isNodeEsmFile7(sf)) { diff --git a/packages/compiler/src/frontend/ts7/checker.ts b/packages/compiler/src/frontend/ts7/checker.ts index 03ed7bc7..1a8ea5c8 100644 --- a/packages/compiler/src/frontend/ts7/checker.ts +++ b/packages/compiler/src/frontend/ts7/checker.ts @@ -336,6 +336,32 @@ export class CheckerFacade { this.prefetchNodes(collectNodes(roots, "reachable")); } + /** Batches symbol queries for every identifier under roots without the + * usual companion getTypeOfSymbol batch. Preflight uses this for AST + * analyses that themselves inspect deferred bodies for binding identity: + * those scans need symbols, but do not consume the symbols' types. */ + prefetchSymbolRoots(roots: readonly Node[]): void { + this.markManaged(roots); + this.prefetchSymbolNodes(collectNodes(roots), false, false); + } + + /** Exact-node sibling of prefetchSymbolRoots for analyses that first + * narrow a large AST walk to the identifier spellings they compare. */ + prefetchSymbolNodesExact(nodes: readonly Node[]): void { + this.markManaged(nodes); + this.prefetchSymbolNodes([...new Set(nodes)], false, false); + } + + /** Batches the hot getTypeAtLocation nodes structure collection may read + * despite their runtime expressions being reachability-deferred. */ + prefetchCollectionTypes(nodes: readonly Node[]): void { + this.markManaged(nodes); + // Match ordinary whole-file prefetch's hot-kind boundary. Collection + // asks some defaults conditionally; uncommon cold expressions should + // remain direct misses only if collection actually consumes them. + this.prefetchTypeNodes([...new Set(nodes)]); + } + /** Batches the exact body nodes class-shape collection reads before body * reachability is known. JavaScript field inference asks for the RHS type * and for a symbol on the `this.x` property access itself; ordinary @@ -347,12 +373,16 @@ export class CheckerFacade { symbolRoots: readonly Node[], ): void { this.markManaged([...typeNodes, ...symbolRoots]); + this.prefetchExactTypeNodes(typeNodes); + this.prefetchSymbolNodes(collectNodes(symbolRoots, "reachable"), true); + } + + private prefetchExactTypeNodes(typeNodes: readonly Node[]): void { const distinctTypes = [...new Set(typeNodes)].filter( (node) => !this.typeAtLocation.has(node), ); const types = chunked(distinctTypes, (chunk) => this.typesWithPanicFence(chunk)); distinctTypes.forEach((node, index) => this.typeAtLocation.set(node, types[index])); - this.prefetchSymbolNodes(collectNodes(symbolRoots, "reachable"), true); } private markManaged(roots: readonly Node[]): void { @@ -397,6 +427,7 @@ export class CheckerFacade { private prefetchSymbolNodes( allNodes: readonly Node[], includePropertyAccess = false, + prefetchSymbolTypes = true, ): void { const nodes = allNodes.filter( (n) => @@ -412,6 +443,7 @@ export class CheckerFacade { withPanicFence(chunk, (c) => this.raw.getSymbolAtLocation(c)), ); nodes.forEach((n, i) => this.symbolAtLocation.set(n, symbols[i])); + if (!prefetchSymbolTypes) return; // The walk's companion query: types of the symbols the file mentions. const distinct = [...new Set(symbols.filter((s): s is Ts7Symbol => s !== undefined))].filter( (s) => !this.typeOfSymbol.has(s), diff --git a/packages/compiler/test/ts7/facade.test.ts b/packages/compiler/test/ts7/facade.test.ts index 76d7f962..b0ac45a0 100644 --- a/packages/compiler/test/ts7/facade.test.ts +++ b/packages/compiler/test/ts7/facade.test.ts @@ -318,6 +318,63 @@ console.log("ok"); } }); +test("signature collection batches exact types of deferred function defaults", () => { + const defaults = Array.from( + { length: 24 }, + (_, index) => + `function dead${index}(value = process.env.VALUE): string | undefined { return value; }`, + ).join("\n"); + const w = buildTwoWorlds({ "defaults.ts": `${defaults}\nconsole.log("ok");\n` }, host); + worlds.push(w); + const { proxy, calls } = countingChecker(w.p7.project.checker); + const facade = new CheckerFacade(proxy, { project: w.p7.project }); + (w.p7 as unknown as { checkerFacade: CheckerFacade | null }).checkerFacade = facade; + const sf = w.p7.getSourceFile(w.files[0]!)!; + const initializers = sf.statements + .filter(ad.isFunctionDeclaration) + .map((decl) => decl.parameters[0]!.initializer!); + + lowerToIr(w.p7, sf, [sf]); + + const typeCalls = calls["getTypeAtLocation"] ?? []; + expect(typeCalls.some(([arg]) => + Array.isArray(arg) && initializers.every((initializer) => (arg as Node[]).includes(initializer)), + )).toBe(true); + expect(typeCalls.some(([arg]) => + !Array.isArray(arg) && initializers.includes(arg as never), + )).toBe(false); +}); + +test("class-shape collection batches deferred method default types", () => { + const methods = Array.from( + { length: 24 }, + (_, index) => + ` dead${index}(value = process.env.VALUE): string | undefined { return value; }`, + ).join("\n"); + const w = buildTwoWorlds({ + "class-defaults.ts": `class Dead {\n${methods}\n}\nconsole.log("ok");\n`, + }, host); + worlds.push(w); + const { proxy, calls } = countingChecker(w.p7.project.checker); + const facade = new CheckerFacade(proxy, { project: w.p7.project }); + (w.p7 as unknown as { checkerFacade: CheckerFacade | null }).checkerFacade = facade; + const sf = w.p7.getSourceFile(w.files[0]!)!; + const cls = sf.statements.find(ad.isClassDeclaration)!; + const initializers = cls.members + .filter(ad.isMethodDeclaration) + .map((member) => member.parameters[0]!.initializer!); + + lowerToIr(w.p7, sf, [sf]); + + const typeCalls = calls["getTypeAtLocation"] ?? []; + expect(typeCalls.some(([arg]) => + Array.isArray(arg) && initializers.every((initializer) => (arg as Node[]).includes(initializer)), + )).toBe(true); + expect(typeCalls.some(([arg]) => + !Array.isArray(arg) && initializers.includes(arg as never), + )).toBe(false); +}); + test("coverage remainder batches checker queries for unreachable bodies", () => { const w = buildTwoWorlds({ "coverage-remainder.ts": ` diff --git a/packages/compiler/test/ts7/program.test.ts b/packages/compiler/test/ts7/program.test.ts index 6ceaadf6..c44a3863 100644 --- a/packages/compiler/test/ts7/program.test.ts +++ b/packages/compiler/test/ts7/program.test.ts @@ -1,5 +1,11 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, test } from "vitest"; +import { checkPreflight, loadProgram } from "../../src/frontend/program.js"; import { tsgoPath } from "../../src/frontend/shared.js"; +import { CheckerFacade } from "../../src/frontend/ts7/checker.js"; +import type { Checker, Project } from "typescript/unstable/sync"; describe("tsgo virtual filesystem paths", () => { test("matches slash-normalized Windows callback paths", () => { @@ -14,3 +20,50 @@ describe("tsgo virtual filesystem paths", () => { .toBe("/tmp/project\\name/tsconfig.json"); }); }); + +test("preflight batches symbols in deferred TDZ-analysis roots", () => { + const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp"; + const dir = mkdtempSync(join(tempRoot, "scriptc-preflight-batch-")); + const entry = join(dir, "entry.cjs"); + const locals = Array.from({ length: 24 }, (_, index) => ` const local${index} = ${index};`).join("\n"); + const uses = Array.from({ length: 24 }, (_, index) => ` value += local${index};`).join("\n"); + writeFileSync(entry, ` +function before() { + let value = 0; +${locals} +${uses} + return required.value + value; +} +before(); +const required = require("./dep.cjs"); +console.log(required.value); +`); + writeFileSync(join(dir, "dep.cjs"), "exports.value = 1;\n"); + + const load = loadProgram(entry); + try { + const program = load.program as unknown as { + project: Project; + checkerFacade: CheckerFacade | null; + }; + const calls: unknown[][] = []; + const proxy = new Proxy(program.project.checker, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver); + if (prop !== "getSymbolAtLocation" || typeof value !== "function") return value; + return (...args: unknown[]) => { + calls.push(args); + return (value as (...values: unknown[]) => unknown).apply(target, args); + }; + }, + }) as Checker; + program.checkerFacade = new CheckerFacade(proxy, { project: program.project }); + + expect(checkPreflight(load).map((diag) => diag.code)).toContain("SC1013"); + expect(calls.some(([arg]) => Array.isArray(arg) && arg.length > 24)).toBe(true); + expect(calls.some(([arg]) => !Array.isArray(arg))).toBe(false); + } finally { + load.dispose(); + rmSync(dir, { recursive: true, force: true }); + } +}); From 5dc7eac4a3697c2c1df5265eaf767b1ac2b69abb Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 20 Aug 2026 18:16:10 -0500 Subject: [PATCH 5/5] Batch types for previously prefetched symbols --- packages/compiler/src/frontend/ts7/checker.ts | 19 ++++++--- packages/compiler/test/ts7/facade.test.ts | 40 +++++++++++++++++++ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/packages/compiler/src/frontend/ts7/checker.ts b/packages/compiler/src/frontend/ts7/checker.ts index 1a8ea5c8..ef906b64 100644 --- a/packages/compiler/src/frontend/ts7/checker.ts +++ b/packages/compiler/src/frontend/ts7/checker.ts @@ -429,12 +429,12 @@ export class CheckerFacade { includePropertyAccess = false, prefetchSymbolTypes = true, ): void { - const nodes = allNodes.filter( + const symbolNodes = allNodes.filter( (n) => (n.kind === SyntaxKind.Identifier || - (includePropertyAccess && n.kind === SyntaxKind.PropertyAccessExpression)) && - !this.symbolAtLocation.has(n), + (includePropertyAccess && n.kind === SyntaxKind.PropertyAccessExpression)), ); + const nodes = symbolNodes.filter((n) => !this.symbolAtLocation.has(n)); // The same bisecting panic fence as the type sweep: tsgo panics on // SYMBOL queries too (observed: GetSymbolAtLocation over an // `import.defer(...)` callee — the sweep's batch must not turn one @@ -445,9 +445,16 @@ export class CheckerFacade { nodes.forEach((n, i) => this.symbolAtLocation.set(n, symbols[i])); if (!prefetchSymbolTypes) return; // The walk's companion query: types of the symbols the file mentions. - const distinct = [...new Set(symbols.filter((s): s is Ts7Symbol => s !== undefined))].filter( - (s) => !this.typeOfSymbol.has(s), - ); + // Include warm node answers too: a preceding symbol-only analysis may + // have populated symbolAtLocation without fetching symbol types, and a + // later reachable-body wave must still batch those missing types. + const distinct = [ + ...new Set( + symbolNodes + .map((node) => this.symbolAtLocation.get(node)) + .filter((symbol): symbol is Ts7Symbol => symbol !== undefined), + ), + ].filter((symbol) => !this.typeOfSymbol.has(symbol)); const symbolTypes = chunked(distinct, (chunk) => withPanicFence(chunk, (c) => this.raw.getTypeOfSymbol(c)), ); diff --git a/packages/compiler/test/ts7/facade.test.ts b/packages/compiler/test/ts7/facade.test.ts index b0ac45a0..2a579bc9 100644 --- a/packages/compiler/test/ts7/facade.test.ts +++ b/packages/compiler/test/ts7/facade.test.ts @@ -277,6 +277,46 @@ export function dead(input: number): number { expect(Array.isArray(calls["getTypeAtLocation"]!.at(-1)![0])).toBe(false); }); +test("reachable waves batch symbol types after symbol-only analysis", () => { + const locals = Array.from( + { length: 24 }, + (_, index) => ` const local${index} = input + ${index};`, + ).join("\n"); + const w = buildTwoWorlds({ + "symbol-type-handoff.ts": ` +export function reached(input: number): number { +${locals} + return local23; +} +`, + }, host); + worlds.push(w); + const { proxy, counts, calls } = countingChecker(w.p7.project.checker); + const facade = new CheckerFacade(proxy); + const sf = w.p7.getSourceFile(w.files[0]!)!; + const body = sf.statements.find(ad.isFunctionDeclaration)!.body!; + const identifiers: Node[] = []; + ad.walkPreorder(body, (node) => { + if (ad.isIdentifier(node)) identifiers.push(node); + }); + + facade.prefetchSymbolRoots([body]); + expect(counts["getSymbolAtLocation"]).toBe(1); + expect(counts["getTypeOfSymbol"] ?? 0).toBe(0); + + facade.prefetchRoots([body]); + expect(counts["getSymbolAtLocation"]).toBe(1); + expect(counts["getTypeOfSymbol"]).toBe(1); + expect(Array.isArray(calls["getTypeOfSymbol"]![0]![0])).toBe(true); + + const warm = { ...counts }; + for (const node of identifiers) { + const symbol = facade.getSymbolAtLocation(node); + if (symbol) facade.getTypeOfSymbol(symbol); + } + expect(counts).toEqual(warm); +}); + test("JavaScript class-shape collection batches constructor field queries", () => { const fields = Array.from( { length: 24 },