From 3039af2a661dd24e10690d78fb4eaf6c48a5fee4 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 07:10:52 -0700 Subject: [PATCH 1/7] Keep unloaded packages out of import() graphs and reject those imports The project's "scriptc": { "packages": { name: "unloaded" } } map covered require() and static imports of installed packages. Two shapes escaped it: - A workspace package marked unloaded still resolved to its sources, so an import() of it compiled the package and its whole dependency graph. The workspace resolver now treats it as missing, and the import() rejects with Node's ERR_MODULE_NOT_FOUND. - A module only import() loads whose static import names a package Node cannot resolve (unloaded or absent) failed the build with SC1010. Node resolves that graph when the import() runs and rejects its promise before any module of it evaluates. Preflight now walks each such root's static graph; a refused graph becomes that rejection, with Node's message and code, and its modules never join the compiled graph or the npm selection. Co-Authored-By: Claude Opus 5.5 --- .../src/frontend/lowering/lower-modules.ts | 37 +++++++++- .../src/frontend/lowering/lower-stdlib.ts | 72 ++++++++++++++++++- .../compiler/src/frontend/lowering/lowerer.ts | 21 +++++- packages/compiler/src/frontend/pipeline.ts | 3 + packages/compiler/src/frontend/program.ts | 66 +++++++++++++++-- packages/compiler/src/frontend/resolve.ts | 5 +- .../npm/cases/unloaded-dynamic-import/lazy.ts | 7 ++ .../npm/cases/unloaded-dynamic-import/main.ts | 32 +++++++++ .../unloaded-dynamic-import/package.json | 10 +++ .../cases/unloaded-dynamic-import/types.d.ts | 8 +++ tests/harness/package-shims.test.ts | 55 +++++++++++++- 11 files changed, 304 insertions(+), 12 deletions(-) create mode 100644 tests/fixtures/npm/cases/unloaded-dynamic-import/lazy.ts create mode 100644 tests/fixtures/npm/cases/unloaded-dynamic-import/main.ts create mode 100644 tests/fixtures/npm/cases/unloaded-dynamic-import/package.json create mode 100644 tests/fixtures/npm/cases/unloaded-dynamic-import/types.d.ts diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index 16dc0a2ca2..a1b83900f4 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -29,11 +29,13 @@ import { locOf, makeCycleAdmission, orderedImportsOf, + npmStaticDepSf7, requireSpecOf, resolveImport, resolveNpmImport, } from "../program.js"; -import type { CycleEdge } from "../program.js"; +import type { CycleEdge, StartupCrash } from "../program.js"; +import { isUnloadedPackage } from "../package-shims.js"; import { markCycleEarlyBindings, withClassInitFlag, @@ -162,6 +164,8 @@ export interface FileParts { export interface AppendGraph { edges?: ReadonlyMap | undefined; inlineCycleCalls?: Set; + /** LoadResult.dynamicImportRejections: import() roots that never load. */ + rejected?: ReadonlyMap | undefined; } /** An appended module's edges: its import and export declarations, then @@ -408,7 +412,8 @@ export function appendDynamicImportModules( if (scan.length === 0) break; for (const sf of scan) { scanned.add(sf); - for (const dep of dynamicProgramImportsOf(program, sf)) visit(dep); + for (const dep of dynamicProgramImportsOf(program, sf)) + if (graph?.rejected?.has(dep) !== true) visit(dep); } } if (added.length > 0) order.splice(order.length - 1, 0, ...added); @@ -684,6 +689,27 @@ export function collectNpmImports(lowerer: Lowerer, parts: FileParts[]): void { * specifiers are skipped — the lowering owns that fence (the module * graph is a build-time artifact; there is nothing to embed for a * runtime-computed name). */ +/** The refusal a literal import() rejects with, or null when it loads: + * Node's ERR_MODULE_NOT_FOUND for a package the project marks never loaded + * (package-shims.ts), or the first refusal in the static graph of a module + * only import() loads (LoadResult.dynamicImportRejections). */ +function dynamicImportRejectionOf( + lowerer: Lowerer, + sf: ts.SourceFile, + spec: string, + call: ts.CallExpression, +): StartupCrash | null { + if (isUnloadedPackage(spec)) { + const refusal = probeNodeImportRefusal(sf.fileName, spec); + if (refusal === null) return null; + return { message: refusal.message, className: "%Error", code: refusal.code, loc: locOf(call) }; + } + if (lowerer.dynamicImportRejections.size === 0) return null; + const root = + resolveImport(lowerer.program, sf, spec) ?? npmStaticDepSf7(lowerer.program, sf, spec); + return root === null ? null : (lowerer.dynamicImportRejections.get(root) ?? null); +} + function collectDynamicImports(lowerer: Lowerer, sf: ts.SourceFile): void { ts.walkPreorder(sf, (node) => { if ( @@ -704,6 +730,13 @@ function collectDynamicImports(lowerer: Lowerer, sf: ts.SourceFile): void { // module has no runtime namespace object — the per-site fence // says so. Only non-declaration source files count; a sibling // .d.mts typing shipped JS is the EMBED case, not this one. + // A package the project marks never loaded, or a module whose + // static graph Node's resolution refuses: the import() rejects. + const rejection = dynamicImportRejectionOf(lowerer, sf, spec, node); + if (rejection !== null) { + lowerer.dynImports.set(mapKey, { kind: "rejected", crash: rejection }); + return undefined; + } const modSym = lowerer.checker.getSymbolAtLocation(node.arguments.nodes[0]); const ownModule = modSym !== undefined && diff --git a/packages/compiler/src/frontend/lowering/lower-stdlib.ts b/packages/compiler/src/frontend/lowering/lower-stdlib.ts index 2f0e1c66f9..1e8f65cb6d 100644 --- a/packages/compiler/src/frontend/lowering/lower-stdlib.ts +++ b/packages/compiler/src/frontend/lowering/lower-stdlib.ts @@ -30,7 +30,13 @@ import { unsupportedNativeApiDiag, unsupportedPackageValueDiag, } from "../../diagnostics/diagnostic.js"; -import { esmNamedImportLinkCrash, isCjsJsFile, locOf, npmPackageNameOf } from "../program.js"; +import { + esmNamedImportLinkCrash, + isCjsJsFile, + locOf, + npmPackageNameOf, + type StartupCrash, +} from "../program.js"; import { foldedStringKeyOf, lowerDynObjectLiteral } from "./expressions/object-literals.js"; import { noteEventListeners } from "./lower-event-target.js"; import { PoisonError, newFnCtx, own } from "./lowerer.js"; @@ -2169,6 +2175,7 @@ export function lowerDynamicImportCall(lowerer: Lowerer, call: ts.CallExpression } const res = lowerer.dynImports.get(`${call.getSourceFile().fileName}\u0000${arg.text}`); if (res === undefined) lowerer.refuseUnsupportedApi("'import()'", call); + if (res.kind === "rejected") return lowerRejectedImport(lowerer, call, res.crash); if (res.kind === "program-module") { return lowerOwnStaticModuleImport(lowerer, call, arg); } @@ -2178,6 +2185,69 @@ export function lowerDynamicImportCall(lowerer: Lowerer, call: ts.CallExpression }); } +/** An import() Node rejects before any module of its graph evaluates + * (a package marked never loaded, a refused edge in the module's static + * graph): the promise rejects on a microtask with Node's error, and the + * module never joins the compiled graph. Its namespace is never produced, + * so the result is typed dynamically. */ +function lowerRejectedImport( + lowerer: Lowerer, + call: ts.CallExpression, + crash: StartupCrash, +): IrExpr { + const loc = locOf(call); + const resultT: IrType & { kind: "promise" } = { kind: "promise", inner: DYN }; + const fnName = `%fn${lowerer.lambdaCounter++}_rejectedImport`; + const fnType: IrType & { kind: "func" } = { kind: "func", params: [], ret: resultT }; + const fnCtx = newFnCtx(true, null, fnType, DYN); + fnCtx.isAsync = true; + lowerer.fnStack.push(fnCtx); + try { + const hop: IrExpr = { + kind: "awaitExpr", + value: { + kind: "intrinsic", + name: "promise.resolve", + args: [], + type: { kind: "promise", inner: VOID }, + loc, + }, + type: VOID, + loc, + }; + const errorKind = crash.className === "%TypeError" ? 1 : 0; + const body: IrStmt[] = [ + { kind: "exprStmt", expr: hop, loc }, + { + kind: "exprStmt", + expr: nodeThrowExpr(errorKind, crash.code ?? "", crash.message, VOID, loc), + loc, + }, + { kind: "return", value: dynUndefinedExpr(loc), loc }, + ]; + const ctx = lowerer.ctx; + lowerer.liftedFns.push({ + name: fnName, + params: [], + returnType: DYN, + locals: ctx.locals, + captures: ctx.captures ?? [], + body, + async: true, + loc, + }); + } finally { + lowerer.fnStack.pop(); + } + return { + kind: "callValue", + callee: { kind: "closure", fnName, captures: [], type: fnType, loc }, + args: [], + type: resultT, + loc, + }; +} + /** The checker-resolved program source behind one literal import(). */ function programImportTarget(lowerer: Lowerer, arg: ts.StringLiteralLike): ts.SourceFile | null { const modSym = lowerer.checker.getSymbolAtLocation(arg); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 8ae3771f7b..8ecfbff1f3 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -787,6 +787,8 @@ export interface LowerOptions { inlineRequireCycleCalls?: ReadonlySet | undefined; /** LoadResult.moduleEdges. */ moduleEdges?: ReadonlyMap | undefined; + /** LoadResult.dynamicImportRejections. */ + dynamicImportRejections?: ReadonlyMap | undefined; /** LIBRARY mode's reachability roots: the profile-mapped exports of the * entry module. Executable builds root at the entry's top level alone * (an unreferenced export dead-strips); a library's exports are called from @@ -877,6 +879,8 @@ export interface LowererMode { frontendServices?: FrontendServices | undefined; /** LoadResult.inlineRequireCycleCalls. */ inlineRequireCycleCalls?: ReadonlySet | undefined; + /** LoadResult.dynamicImportRejections. */ + dynamicImportRejections?: ReadonlyMap | undefined; /** LowerOptions.libraryCallbacks (see there). */ libraryCallbacks?: boolean; projectCheckJs?: boolean; @@ -978,7 +982,11 @@ export function lowerToIr( // Inline require() calls closing a cycle inside the appended subgraphs // join preflight's set (the same run-time refusal by name). const inlineRequireCycleCalls = new Set(options.inlineRequireCycleCalls ?? []); - const graph = { edges: options.moduleEdges, inlineCycleCalls: inlineRequireCycleCalls }; + const graph = { + edges: options.moduleEdges, + inlineCycleCalls: inlineRequireCycleCalls, + rejected: options.dynamicImportRejections, + }; for (let pass = 0; pass < 32; pass++) { const before = moduleOrder.length; appendDynamicImportModules(program, moduleOrder, onExtraCycle, graph); @@ -999,6 +1007,7 @@ export function lowerToIr( targetPlatform, startupCrash, inlineRequireCycleCalls, + dynamicImportRejections: options.dynamicImportRejections, ffiImports, libraryCallbacks, forkTargets, @@ -1023,6 +1032,7 @@ export function lowerToIr( targetPlatform, startupCrash, inlineRequireCycleCalls, + dynamicImportRejections: options.dynamicImportRejections, ffiImports, libraryCallbacks, forkTargets, @@ -1054,6 +1064,7 @@ export function lowerToIr( targetPlatform, startupCrash, inlineRequireCycleCalls, + dynamicImportRejections: options.dynamicImportRejections, ffiImports, libraryCallbacks, forkTargets, @@ -1113,6 +1124,7 @@ export function lowerToIr( remainder: true, alreadyFlushed: resultLowerer.flushedSymbols, inlineRequireCycleCalls, + dynamicImportRejections: options.dynamicImportRejections, targetPlatform, ffiImports, libraryCallbacks, @@ -1701,6 +1713,8 @@ export class Lowerer { /** Inline require() calls whose module edge closes a require cycle * (LoadResult.inlineRequireCycleCalls). */ readonly inlineRequireCycleCalls: ReadonlySet; + /** LoadResult.dynamicImportRejections. */ + readonly dynamicImportRejections: ReadonlyMap; /** Immutable callable aliases and compiler-generated adapters. The map * is symbol-keyed, so shadowing and cross-module imports keep the same * exact binding discipline as globals/functions. */ @@ -2667,7 +2681,9 @@ export class Lowerer { * collection; lowerDynamicImportCall reads it per site). */ readonly dynImports = new Map< string, - { kind: "program-module" } | { kind: "static-builtin"; module: string } + | { kind: "program-module" } + | { kind: "static-builtin"; module: string } + | { kind: "rejected"; crash: StartupCrash } >(); readonly createRequireImports = new Map< @@ -3020,6 +3036,7 @@ export class Lowerer { this.forkTargets = mode.forkTargets ?? []; this.frontendServices = mode.frontendServices; this.inlineRequireCycleCalls = mode.inlineRequireCycleCalls ?? new Set(); + this.dynamicImportRejections = mode.dynamicImportRejections ?? new Map(); this.libraryCallbacks = mode.libraryCallbacks ?? false; this.projectCheckJs = mode.projectCheckJs ?? false; this.reachable = mode.reachable ?? null; diff --git a/packages/compiler/src/frontend/pipeline.ts b/packages/compiler/src/frontend/pipeline.ts index 82f3d2d902..92c62c24b0 100644 --- a/packages/compiler/src/frontend/pipeline.ts +++ b/packages/compiler/src/frontend/pipeline.ts @@ -168,6 +168,8 @@ function addDynamicImportGraph(load: LoadResult, scanned: Set): void npmStaticDepSf7(load.program, sf, spec, kind); if (dep === null || scanned.has(dep)) continue; if (dep.fileName.includes("/node_modules/") && !isJsSourceFileName(dep.fileName)) continue; + // An import() whose graph Node refuses rejects: nothing in it loads. + if (kind === "import" && load.dynamicImportRejections?.has(dep)) continue; scanned.add(dep); added.add(dep); queue.push(dep); @@ -688,6 +690,7 @@ function loadFrontend( startupCrash: finalLoad.startupCrash ?? null, inlineRequireCycleCalls: finalLoad.inlineRequireCycleCalls, moduleEdges: finalLoad.moduleEdges, + dynamicImportRejections: finalLoad.dynamicImportRejections, projectCheckJs: finalLoad.typeWorld?.projectCheckJs ?? false, externalTypes: finalLoad.externalTypes, externalTypeSpecifiersByFile: finalLoad.externalTypeSpecifiersByFile, diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index a78c68477c..b513e5c7b0 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -96,6 +96,7 @@ import { isPackageShimFile, isPackageShimName, isShimmedImport, + isUnloadedPackage, } from "./package-shims.js"; import { provenanceEntryFor, provenancePaths } from "./provenance-registry.js"; import { cjsLexedExportsOfFile, cjsVisibleNames } from "./cjs-syntax.js"; @@ -359,6 +360,11 @@ export interface LoadResult { * literal import(), fork() or Worker reaches with them. Filled by * checkPreflight. */ moduleEdges?: ReadonlyMap | undefined; + /** Program modules only a literal import() loads whose static graph + * Node's resolution refuses: each such import() rejects with the refusal + * instead of failing the build, and the module never joins the compiled + * graph. Filled by checkPreflight. */ + dynamicImportRejections?: ReadonlyMap | undefined; /** Node's startup refusal, when the graph carries an import edge Node * rejects before ANY module evaluates — a RESOLUTION refusal (a bare * specifier nothing installed resolves, an exports/imports map that @@ -503,6 +509,8 @@ export interface StartupCrash { message: string; className: "%Error" | "%TypeError" | "%SyntaxError"; loc: { file: string; start: number; end: number }; + /** Node's error code, for the refusals that carry one. */ + code?: string; } /** True when a coverage mapping names one exact bare host module. TypeScript @@ -1057,12 +1065,19 @@ export function loadProgram( * preflight diagnostics. Preserve the caller's complete load shape when * mutating it, including its owned services and disposal callback. */ export function checkPreflight(load: T): ScrDiagnostic[] { - const { diags, moduleOrder, startupCrash, inlineRequireCycleCalls, moduleEdges } = - preflight7(load); + const { + diags, + moduleOrder, + startupCrash, + inlineRequireCycleCalls, + moduleEdges, + dynamicImportRejections, + } = preflight7(load); load.moduleOrder = moduleOrder; load.startupCrash = startupCrash; load.inlineRequireCycleCalls = inlineRequireCycleCalls; load.moduleEdges = moduleEdges; + load.dynamicImportRejections = dynamicImportRejections; return diags; } @@ -4401,6 +4416,7 @@ function preflight7(load: LoadResult): { startupCrash: StartupCrash | null; inlineRequireCycleCalls: ReadonlySet; moduleEdges?: ReadonlyMap; + dynamicImportRejections?: ReadonlyMap; } { const { program, entry } = load; const diags: ScrDiagnostic[] = [...load.configDiags]; @@ -4908,9 +4924,10 @@ function preflight7(load: LoadResult): { message: string, className: StartupCrash["className"], fallbackFeature: string, + code?: string, ): void => { refusals.get(sf)!.push({ - crash: { message, className, loc: locOf7(stmt) }, + crash: { message, className, loc: locOf7(stmt), ...(code !== undefined ? { code } : {}) }, fallback: unsupportedDiag("SC1010", locOf7(stmt), fallbackFeature), pos: stmt.getStart(sf), }); @@ -4953,6 +4970,7 @@ function preflight7(load: LoadResult): { refusal.message, "%Error", `the '${spec}' package (its runtime resolution fails: ${refusal.message})`, + refusal.code, ); } continue; @@ -4990,6 +5008,7 @@ function preflight7(load: LoadResult): { `Invalid module "#" is not a valid internal imports specifier name imported from ${sf.fileName}`, "%TypeError", `the '#' import (Node rejects the bare '#' specifier as invalid)`, + "ERR_INVALID_MODULE_SPECIFIER", ); continue; } @@ -5010,6 +5029,7 @@ function preflight7(load: LoadResult): { `Package import specifier "${spec}" is not defined${pkgJson !== null ? ` in package ${pkgJson}` : ""} imported from ${sf.fileName}`, "%TypeError", `the '${spec}' import (no package.json imports-field entry maps it)`, + "ERR_PACKAGE_IMPORT_NOT_DEFINED", ); continue; } @@ -5018,7 +5038,7 @@ function preflight7(load: LoadResult): { const ambientNote = ambientDeclared(spec) ? `the '${spec}' module (it exists only as an ambient 'declare module' type surface — there is no runtime module to import)` : `the '${spec}' package (nothing installed resolves it)`; - refuse(refusal.message, "%Error", ambientNote); + refuse(refusal.message, "%Error", ambientNote, refusal.code); continue; } // Runtime-resolvable (or a shape the probe stays conservative @@ -5463,6 +5483,7 @@ function preflight7(load: LoadResult): { // them). A found resolution crash wins over the CJS link check below: // Node's fetch/resolve phase runs before instantiate. let resolveCrash: StartupCrash | null = null; + const dynamicImportRejections = new Map(); { const seen = new Set(); const walk = (sf: ts.SourceFile): StartupCrash | null => { @@ -5482,6 +5503,39 @@ function preflight7(load: LoadResult): { return null; }; resolveCrash = walk(entry); + // A module only a literal import() loads resolves its static graph when + // that import runs: Node rejects the import's promise with the first + // refusal it meets, before any module of that graph evaluates. Such a + // module never joins the compiled graph, and the refusals inside its + // graph are that rejection, not build failures. + const reach = (from: ts.SourceFile, into: Set): void => { + const stack = [from]; + while (stack.length > 0) { + const sf = stack.pop()!; + if (into.has(sf)) continue; + into.add(sf); + for (const { dep } of depPositions.get(sf) ?? []) stack.push(dep); + } + }; + const startup = new Set(); + reach(entry, startup); + const covered = new Set(); + for (const sf of userFiles) { + if (!sf.text.includes("import")) continue; + for (const call of moduleSourceCandidates(program, sf).calls) { + const arg = call.arguments.nodes[0]; + if (call.expression.kind !== ts.SyntaxKind.ImportKeyword || arg === undefined) continue; + if (!ts.isStringLiteralLike(arg) || isUnloadedPackage(arg.text)) continue; + const root = resolveImport7(program, sf, arg.text) ?? npmStaticDepSf7(program, sf, arg.text); + if (root === null || root.isDeclarationFile || startup.has(root)) continue; + if (dynamicImportRejections.has(root) || covered.has(root)) continue; + seen.clear(); + const crash = walk(root); + if (crash === null) continue; + dynamicImportRejections.set(root, crash); + reach(root, covered); + } + } // The crash channel engages only for programs that OTHERWISE pass // preflight: when any other diagnostic already fails the build, every // refusal keeps its compile-fence fallback instead — the report names @@ -5489,7 +5543,8 @@ function preflight7(load: LoadResult): { // get a diagnostic. (No reachable refusal → same: fallbacks only.) if (resolveCrash === null || diags.length > 0) { resolveCrash = null; - for (const cands of refusals.values()) { + for (const [sf, cands] of refusals) { + if (covered.has(sf) && !startup.has(sf)) continue; for (const c of cands) diags.push(c.fallback); } } @@ -5510,6 +5565,7 @@ function preflight7(load: LoadResult): { startupCrash: resolveCrash ?? linkCrash, inlineRequireCycleCalls, moduleEdges: edges, + dynamicImportRejections, }; } diff --git a/packages/compiler/src/frontend/resolve.ts b/packages/compiler/src/frontend/resolve.ts index 3d3d135b48..88aa5ef98b 100644 --- a/packages/compiler/src/frontend/resolve.ts +++ b/packages/compiler/src/frontend/resolve.ts @@ -22,7 +22,7 @@ import { npmStaticPackageOfPath, npmStaticTransformPkgJson, } from "./npm-static.js"; -import { clearPackageShimCaches, packageShimFor } from "./package-shims.js"; +import { clearPackageShimCaches, isUnloadedPackage, packageShimFor } from "./package-shims.js"; import { provenanceEntryFor } from "./provenance-registry.js"; import { trackedAccessibleEntries, @@ -1009,6 +1009,9 @@ export function resolveWorkspaceSourceModule( ) { return null; } + // A workspace package the project marks never loaded (package-shims.ts) + // resolves like a missing one. + if (isUnloadedPackage(specifier)) return null; const source = resolveBareModule(fromFile, specifier, "runtime-source"); if ( source?.workspaceDir === undefined || diff --git a/tests/fixtures/npm/cases/unloaded-dynamic-import/lazy.ts b/tests/fixtures/npm/cases/unloaded-dynamic-import/lazy.ts new file mode 100644 index 0000000000..df940c7940 --- /dev/null +++ b/tests/fixtures/npm/cases/unloaded-dynamic-import/lazy.ts @@ -0,0 +1,7 @@ +import { heavy } from "gtunloadedheavy"; + +console.log("lazy evaluated"); + +export function run(text: string): string { + return heavy(text); +} diff --git a/tests/fixtures/npm/cases/unloaded-dynamic-import/main.ts b/tests/fixtures/npm/cases/unloaded-dynamic-import/main.ts new file mode 100644 index 0000000000..0750d8c4bb --- /dev/null +++ b/tests/fixtures/npm/cases/unloaded-dynamic-import/main.ts @@ -0,0 +1,32 @@ +/// +// Packages the project marks never loaded +// (package.json: "scriptc": { "packages": { ...: "unloaded" } }) behind +// import(): a workspace package imported on demand, and a module only +// import() loads whose static import names such a package. Each import() +// rejects with Node's ERR_MODULE_NOT_FOUND, exactly as when the package is +// not installed, and no module of the refused graph evaluates. +// tests/harness/package-shims.test.ts compiles this program with both +// packages installed and checks the output is unchanged. +function report(what: string, err: unknown): void { + const e = err as { code?: string; message: string }; + const [reason, importer] = e.message.split(" imported from "); + console.log(what, e.code, reason, importer?.slice(importer.lastIndexOf("/") + 1)); +} + +async function main(): Promise { + console.log("start"); + try { + const { parse } = await import("wsunloadedparser"); + console.log("parsed", parse("a")); + } catch (err) { + report("parser:", err); + } + try { + const lazy = await import("./lazy.ts"); + console.log(lazy.run("b")); + } catch (err) { + report("lazy:", err); + } + console.log("end"); +} +main(); diff --git a/tests/fixtures/npm/cases/unloaded-dynamic-import/package.json b/tests/fixtures/npm/cases/unloaded-dynamic-import/package.json new file mode 100644 index 0000000000..8dae8adc4d --- /dev/null +++ b/tests/fixtures/npm/cases/unloaded-dynamic-import/package.json @@ -0,0 +1,10 @@ +{ + "private": true, + "type": "module", + "scriptc": { + "packages": { + "wsunloadedparser": "unloaded", + "gtunloadedheavy": "unloaded" + } + } +} diff --git a/tests/fixtures/npm/cases/unloaded-dynamic-import/types.d.ts b/tests/fixtures/npm/cases/unloaded-dynamic-import/types.d.ts new file mode 100644 index 0000000000..67102c5bde --- /dev/null +++ b/tests/fixtures/npm/cases/unloaded-dynamic-import/types.d.ts @@ -0,0 +1,8 @@ +// Types for the two packages the project marks never loaded. Neither is +// installed here; tests/harness/package-shims.test.ts installs both. +declare module "wsunloadedparser" { + export function parse(text: string): string; +} +declare module "gtunloadedheavy" { + export function heavy(text: string): string; +} diff --git a/tests/harness/package-shims.test.ts b/tests/harness/package-shims.test.ts index 50750b59c3..38ab787f5f 100644 --- a/tests/harness/package-shims.test.ts +++ b/tests/harness/package-shims.test.ts @@ -4,6 +4,9 @@ * - a package the project marks unloaded stays unloaded when it IS * installed: the native program prints what Node prints without the * package, and the package's code never runs; + * - the same behind import(): an unloaded workspace package, and a module + * only import() loads that imports an unloaded package, reject with + * Node's ERR_MODULE_NOT_FOUND; * - a polyfill package with a builtin alias that the project marks unloaded * stays unloaded: its require throws MODULE_NOT_FOUND; * - a shimmed package that is not installed, or whose major version has no @@ -11,7 +14,7 @@ * uncompiled package). */ import { execFile } from "node:child_process"; -import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { cpSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -72,6 +75,56 @@ describe("package shims", () => { expect(await run(result.binaryPath, [], dir)).toBe(expected); }, 180_000); + test("unloaded packages behind import() stay unloaded, even when installed", async () => { + const original = join(casesRoot, "unloaded-dynamic-import"); + const expected = await run(process.execPath, [join(original, "main.ts")], original); + expect(expected).toContain("parser: ERR_MODULE_NOT_FOUND"); + expect(expected).toContain("lazy: ERR_MODULE_NOT_FOUND"); + + const dir = copyCase("unloaded-dynamic-import", "unloaded-dynamic-installed"); + const write = (path: string, text: string) => { + mkdirSync(join(dir, path, ".."), { recursive: true }); + writeFileSync(join(dir, path), text); + }; + // A workspace package (linked into node_modules from outside it) and an + // installed ES module package. + write( + "workspace/parser/package.json", + '{ "name": "wsunloadedparser", "version": "1.0.0", "type": "module", "main": "./index.ts" }\n', + ); + write( + "workspace/parser/index.ts", + 'console.log("parser loaded");\nexport function parse(text: string): string {\n return `parsed:${text}`;\n}\n', + ); + mkdirSync(join(dir, "node_modules"), { recursive: true }); + symlinkSync("../workspace/parser", join(dir, "node_modules/wsunloadedparser")); + write( + "node_modules/gtunloadedheavy/package.json", + '{ "name": "gtunloadedheavy", "version": "1.0.0", "type": "module", "main": "index.js", "types": "index.d.ts" }\n', + ); + write( + "node_modules/gtunloadedheavy/index.js", + 'console.log("heavy loaded");\nexport function heavy(text) {\n return `heavy:${text}`;\n}\n', + ); + write( + "node_modules/gtunloadedheavy/index.d.ts", + "export declare function heavy(text: string): string;\n", + ); + // Control: Node loads both. + const loaded = await run(process.execPath, [join(dir, "main.ts")], dir); + expect(loaded).toContain("parsed parsed:a"); + expect(loaded).toContain("heavy:b"); + + const out = join(dir, ".scriptc"); + const result = await compile(join(dir, "main.ts"), { + outPath: join(out, "program"), + outDir: out, + sanitize, + }); + if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n")); + expect(await run(result.binaryPath, [], dir)).toBe(expected); + }, 180_000); + test("an unloaded polyfill package is not compiled as its builtin", async () => { const dir = join(scratch, "unloaded-alias"); const write = (path: string, text: string) => { From 7a3d6fd1d89c4cb7307d6f362923fd4bec1a767c Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 07:22:28 -0700 Subject: [PATCH 2/7] Format and record the unloaded import() case's order baseline Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/frontend/program.ts | 3 ++- .../fixtures/npm/cases/unloaded-dynamic-import/main.ts.json | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/unloaded-dynamic-import/main.ts.json diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index b513e5c7b0..9bd54c9188 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -5526,7 +5526,8 @@ function preflight7(load: LoadResult): { const arg = call.arguments.nodes[0]; if (call.expression.kind !== ts.SyntaxKind.ImportKeyword || arg === undefined) continue; if (!ts.isStringLiteralLike(arg) || isUnloadedPackage(arg.text)) continue; - const root = resolveImport7(program, sf, arg.text) ?? npmStaticDepSf7(program, sf, arg.text); + const root = + resolveImport7(program, sf, arg.text) ?? npmStaticDepSf7(program, sf, arg.text); if (root === null || root.isDeclarationFile || startup.has(root)) continue; if (dynamicImportRejections.has(root) || covered.has(root)) continue; seen.clear(); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/unloaded-dynamic-import/main.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/unloaded-dynamic-import/main.ts.json new file mode 100644 index 0000000000..2470af846f --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/fixtures/npm/cases/unloaded-dynamic-import/main.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/fixtures/npm/cases/unloaded-dynamic-import/main.ts" + ], + "diags": [] +} From ee63d4a60751e27e27fb8c76bff5c7d9bb404d78 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 25 Oct 2026 10:57:54 -0700 Subject: [PATCH 3/7] Distribute conditional members over a generic body's bound parameter A generic body reading a member typed by a distributive conditional over its type parameter (`opts?: T extends "alpha" ? AlphaOpts : T extends "beta" ? BetaOpts : never`) refused it with SC2009 when the instance bound T to a union of literals (`"alpha" | "gamma"`, `"a" | (string & {})`), and mapped it as `number | undefined` (never's placeholder) when the binding selected only never, while the concrete instantiation maps that member as `null | undefined`; values crossing between the two failed with SC2003. The conditional now distributes over the binding as the checker does: each member evaluates, the non-never results form the union, an open `string & {}` member checks as `string`, and an all-never result leaves an optional member that holds only undefined. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/frontend/type-mapper.ts | 99 ++++++++++++++++- ...eneric-conditional-member-bindings.ts.json | 6 ++ .../generic-conditional-member-bindings.ts | 100 ++++++++++++++++++ ...eneric-conditional-member-bindings.ts.json | 1 + 4 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-conditional-member-bindings.ts.json create mode 100644 tests/corpus/generic-conditional-member-bindings.ts create mode 100644 tests/harness/corpus-costs/generic-conditional-member-bindings.ts.json diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 17f9d0d110..7124db1b59 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -2057,6 +2057,11 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { return viaConditional; } } + const viaDistribution = mapDistributedConditional(type, ctx); + if (viaDistribution !== null) { + contextResolutions++; + return viaDistribution; + } } } const widened = checker.getBaseTypeOfLiteralType(type); @@ -5011,11 +5016,19 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { !checker.isNeverType(part) && !isEmptyObjectLiteralType(part, ctx), ); + let neverConditionalPart = false; + let undefinedPart: ts.Type | undefined; for (const part of parts) { if (hasArrayPart && emptyTuple(part)) continue; // TypeScript's client can retain impossible intersections in a // distributed union. They have no inhabitants and no runtime tag. if (checker.isNeverType(part)) continue; + // A conditional over a bound parameter whose every member selects + // `never` contributes no arm either. + if (distributedConditionalParts(part, ctx)?.length === 0) { + neverConditionalPart = true; + continue; + } // A `void` PART is inhabited only by undefined (`Promise | // void` return types, `string | void`): it becomes the undefinedT // unit arm, exactly like an undefined part — the value either @@ -5023,6 +5036,7 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // void stays VOID (the return-position mapping above). if (part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) { byKey.set(typeKey(UNDEFINED_T), UNDEFINED_T); + undefinedPart ??= part; continue; } if (part.flags & ts.TypeFlags.Null) { @@ -5065,6 +5079,11 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { } const arms = [...byKey.values()]; if (arms.length === 0) return F64; // same unreachable placeholder as standalone never + // `Cond | undefined` whose conditional selects never for the + // instance's binding is the plain `undefined` the concrete + // instantiation's optional member has. + if (neverConditionalPart && arms.length === 1 && undefinedPart !== undefined) + return mapType(undefinedPart, ctx); // A single surviving UNIT arm cannot stand alone (degenerate — the // checker collapsed everything else away); anything else single is // just that type. A single arm UNDER A MINTED PLACEHOLDER cannot @@ -6136,6 +6155,81 @@ function mapGenericIndexedAccess(type: ts.Type, ctx: TypeMapperCtx): IrType | nu return { kind: "union", unionId: ctx.unions.intern(arms) }; } +/** A distributive conditional (`T extends "a" ? A : T extends "b" ? B : + * never`, its check type a naked type parameter) over a parameter the + * instance binds: evaluated once per member of the binding (`"a" | "b"`, + * or the open `"a" | (string & {})` spelling), as the checker distributes + * it. Answers the non-never results (empty when every member selects + * never, so an optional member of that type holds only undefined, as in + * the concrete instantiation), or null when the conditional is another + * shape or a member does not evaluate. */ +function distributedConditionalParts(type: ts.Type, ctx: TypeMapperCtx): ts.Type[] | null { + const { checker, resolveTypeParamTs } = ctx; + if (!resolveTypeParamTs || !type.isConditionalType()) return null; + const checkType = type.getCheckType(); + if (!checkType || !(checkType.flags & ts.TypeFlags.TypeParameter)) return null; + const symbol = checkType.getSymbol(); + const bound = resolveTypeParamTs(checkType); + if (!symbol || !bound) return null; + const parts: ts.Type[] = []; + for (const member of bound.isUnionType() ? ts.constituentTypes(bound) : [bound]) { + // `string & {}` (the open-union spelling) extends exactly what `string` + // extends: the empty object adds nothing to a primitive. + const emptyObject = (part: ts.Type): boolean => + (part.flags & ts.TypeFlags.Object) !== 0 && + checker.getPropertiesOfType(part).length === 0 && + checker.getIndexInfosOfType(part).length === 0 && + checker.getCallSignatures(part).length === 0 && + checker.getConstructSignatures(part).length === 0; + const primitive = member.isIntersectionType() + ? ts.constituentTypes(member).filter((part) => !emptyObject(part)) + : [member]; + if (primitive.length !== 1) return null; + const evaluated = evalMappedTemplate(type, new Map(), new Map([[symbol, primitive[0]!]]), ctx); + if (!evaluated) return null; + if (checker.isNeverType(evaluated)) continue; + if (!parts.includes(evaluated)) parts.push(evaluated); + } + return parts; +} + +/** A unit-only type once its conditionals over bound parameters are + * distributed: `Cond | undefined` where every member of T's binding + * selects never (the optional member's type in a generic body). */ +function neverConditionalUnitOnly(t: ts.Type, ctx: TypeMapperCtx): boolean { + const UNIT = ts.TypeFlags.Undefined | ts.TypeFlags.Void | ts.TypeFlags.Null; + if (!t.isUnionType()) return false; + let conditional = false; + for (const part of ts.constituentTypes(t)) { + if ((part.flags & UNIT) !== 0) continue; + if (distributedConditionalParts(part, ctx)?.length !== 0) return false; + conditional = true; + } + return conditional; +} + +/** mapType of a distributed conditional (distributedConditionalParts): the + * union of its members' results. Null when it does not distribute, when a + * result does not map, or when every member selects never (the enclosing + * union drops such a part). */ +function mapDistributedConditional(type: ts.Type, ctx: TypeMapperCtx): IrType | null { + const parts = distributedConditionalParts(type, ctx); + if (!parts || parts.length === 0) return null; + const arms: IrType[] = []; + for (const part of parts) { + const mapped = mapType(part, ctx); + if (!mapped) return null; + const members = + mapped.kind === "union" ? (ctx.unions.get(mapped.unionId)?.arms ?? null) : [mapped]; + if (!members) return null; + for (const arm of members) if (!arms.some((a) => typeEquals(a, arm))) arms.push(arm); + } + if (arms.length === 1) return arms[0]!; + if (!unionContainerArmsOk(arms)) return null; + arms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); + return { kind: "union", unionId: ctx.unions.intern(arms) }; +} + /** `keyof T extends never ? A : B` over a bound record T (the lib's * Object.entries element spelling): decided by whether the binding has any * key. */ @@ -8242,7 +8336,10 @@ function mapRecordTypeInner( // absent-field idiom — and `{ p: undefined }` spellings): the // unit-only union. The runtime value is the interned unit, JSON // omits the undefined arm exactly like an omitted optional. - if (pt?.kind === "void" && isUnitOnlyTsType(fieldTs, ctx.resolveTypeParam)) + if ( + pt?.kind === "void" && + (isUnitOnlyTsType(fieldTs, ctx.resolveTypeParam) || neverConditionalUnitOnly(fieldTs, ctx)) + ) pt = unitOnlyUnion(ctx.unions); if (pt !== null && isUnitOnlyIr(pt, ctx) && jsObjectLiteralProperty(p)) pt = DYN; if (pt?.kind === "array" && jsNeverArrayProp(p, fieldTs, checker)) pt = DYN; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-conditional-member-bindings.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-conditional-member-bindings.ts.json new file mode 100644 index 0000000000..398cc39e9a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-conditional-member-bindings.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/generic-conditional-member-bindings.ts" + ], + "diags": [] +} diff --git a/tests/corpus/generic-conditional-member-bindings.ts b/tests/corpus/generic-conditional-member-bindings.ts new file mode 100644 index 0000000000..60631e191c --- /dev/null +++ b/tests/corpus/generic-conditional-member-bindings.ts @@ -0,0 +1,100 @@ +// Members typed by a distributive conditional over a generic parameter +// (`opts?: T extends "alpha" ? AlphaOpts : ...`), read inside the generic +// body: the instance's binding decides the layout, member by member of a +// literal union, and an all-never binding leaves an optional member that +// holds only undefined, as the concrete instantiation's member does. + +type KnownApi = "alpha" | "beta" | "gamma"; +type Api = KnownApi | (string & {}); + +interface AlphaOpts { + fast?: boolean; +} +interface BetaOpts { + depth?: number; +} + +interface Base { + id: string; + api: TApi; +} + +interface Item extends Base { + kind?: "item"; + opts?: TApi extends "alpha" ? AlphaOpts : TApi extends "beta" ? BetaOpts : never; +} + +interface Other extends Base { + kind: "other"; + size: number; +} + +type AnyItem = Item | Other; + +interface Options { + name: string; + items: readonly AnyItem[]; +} + +interface Built { + name: string; + all: () => readonly AnyItem[]; + chat: () => Item[]; + add: (item: AnyItem) => void; +} + +function describe(item: Item): string { + return `${item.id}:${item.api}:${item.opts === undefined ? "-" : JSON.stringify(item.opts)}`; +} + +function build(input: Options): Built { + const baseline = input.items; + let dynamic: readonly AnyItem[] = []; + const current = (): readonly AnyItem[] => { + const merged = [...baseline]; + for (const item of dynamic) merged.push(item); + return merged; + }; + return { + name: input.name, + all: current, + chat: () => current().filter((item): item is Item => item.kind !== "other"), + add: (item) => { + dynamic = [...dynamic, item]; + }, + }; +} + +function report(built: Built): void { + console.log(`${built.name}: ${built.all().length} items, ${built.chat().length} chat`); + for (const item of built.chat()) console.log(` ${describe(item)}`); +} + +const mixed: Built<"alpha" | "gamma"> = build({ + name: "mixed", + items: [ + { id: "a1", api: "alpha", opts: { fast: true } }, + { id: "g1", api: "gamma" }, + ], +}); +mixed.add({ id: "a2", api: "alpha" }); +report(mixed); + +const beta: Built<"beta"> = build({ + name: "beta", + items: [ + { id: "b1", api: "beta", opts: { depth: 2 } }, + { id: "o1", api: "other", kind: "other", size: 1 }, + ], +}); +beta.add({ id: "o2", api: "other", kind: "other", size: 3 }); +report(beta); + +const custom = build({ name: "custom", items: [{ id: "c1", api: "custom" }] }); +custom.add({ id: "c2", api: "custom" }); +report(custom); + +const open = build({ name: "open", items: [{ id: "x1", api: "external" }] }); +report(open); +const first = open.chat()[0]; +console.log(first === undefined ? "none" : first.opts === undefined ? "no opts" : "opts"); diff --git a/tests/harness/corpus-costs/generic-conditional-member-bindings.ts.json b/tests/harness/corpus-costs/generic-conditional-member-bindings.ts.json new file mode 100644 index 0000000000..f46d03f09a --- /dev/null +++ b/tests/harness/corpus-costs/generic-conditional-member-bindings.ts.json @@ -0,0 +1 @@ +{ "plain": 14775, "san": 16991 } From a0c1358f0e7f59fdda32c8d4820347e6104b49ab Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 25 Oct 2026 16:42:11 -0500 Subject: [PATCH 4/7] Map distributed conditional members as the concrete union does Three fixes to distributing a conditional member over a generic body's bound parameter: - Consulting the binding counts as a context resolution, so a mapping that drops an all-never part is no longer cached as context-free and reused by a recursive shape for a binding whose member is inhabited. - An `unknown` or `any` result absorbs the other results, as the checker's union does, so the member is checked-dynamic. Before, the union kept a `dyn` arm, and the build stopped with SC9001. - `undefined` and `null` results become the union's unit arms, as in the concrete instantiation. Before, `undefined` mapped on its own to a `void` arm, which also failed with SC9001. Corpus generic-conditional-member-units covers the `unknown` and the `undefined`/`null` members. --- packages/compiler/src/frontend/type-mapper.ts | 23 +++++++-- .../generic-conditional-member-units.ts.json | 6 +++ .../generic-conditional-member-units.ts | 51 +++++++++++++++++++ .../generic-conditional-member-units.ts.json | 1 + 4 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-conditional-member-units.ts.json create mode 100644 tests/corpus/generic-conditional-member-units.ts create mode 100644 tests/harness/corpus-costs/generic-conditional-member-units.ts.json diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 7124db1b59..af24233512 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -6171,6 +6171,9 @@ function distributedConditionalParts(type: ts.Type, ctx: TypeMapperCtx): ts.Type const symbol = checkType.getSymbol(); const bound = resolveTypeParamTs(checkType); if (!symbol || !bound) return null; + // The answer depends on the instance's binding: a mapping that consults + // it, even one that drops an all-never part, is not context-free. + contextResolutions++; const parts: ts.Type[] = []; for (const member of bound.isUnionType() ? ts.constituentTypes(bound) : [bound]) { // `string & {}` (the open-union spelling) extends exactly what `string` @@ -6209,14 +6212,27 @@ function neverConditionalUnitOnly(t: ts.Type, ctx: TypeMapperCtx): boolean { } /** mapType of a distributed conditional (distributedConditionalParts): the - * union of its members' results. Null when it does not distribute, when a - * result does not map, or when every member selects never (the enclosing - * union drops such a part). */ + * union of its members' results, as the concrete conditional's union maps + * them. An `unknown` or `any` result absorbs the rest (the checker's union + * is that type), and `undefined` and `null` results are the unit arms. Null + * when it does not distribute, when a result does not map, when every + * member selects never (the enclosing union drops such a part), or when + * only units remain. */ function mapDistributedConditional(type: ts.Type, ctx: TypeMapperCtx): IrType | null { const parts = distributedConditionalParts(type, ctx); if (!parts || parts.length === 0) return null; + const top = parts.find((part) => (part.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0); + if (top !== undefined) return mapType(top, ctx); const arms: IrType[] = []; for (const part of parts) { + if (part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Void)) { + if (!arms.some((a) => typeEquals(a, UNDEFINED_T))) arms.push(UNDEFINED_T); + continue; + } + if (part.flags & ts.TypeFlags.Null) { + if (!arms.some((a) => typeEquals(a, NULL_T))) arms.push(NULL_T); + continue; + } const mapped = mapType(part, ctx); if (!mapped) return null; const members = @@ -6224,6 +6240,7 @@ function mapDistributedConditional(type: ts.Type, ctx: TypeMapperCtx): IrType | if (!members) return null; for (const arm of members) if (!arms.some((a) => typeEquals(a, arm))) arms.push(arm); } + if (arms.every(isUnitType)) return null; if (arms.length === 1) return arms[0]!; if (!unionContainerArmsOk(arms)) return null; arms.sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1)); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-conditional-member-units.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-conditional-member-units.ts.json new file mode 100644 index 0000000000..35b3ba808a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-conditional-member-units.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/generic-conditional-member-units.ts" + ], + "diags": [] +} diff --git a/tests/corpus/generic-conditional-member-units.ts b/tests/corpus/generic-conditional-member-units.ts new file mode 100644 index 0000000000..02c9cdea9e --- /dev/null +++ b/tests/corpus/generic-conditional-member-units.ts @@ -0,0 +1,51 @@ +// Members typed by a distributive conditional over a generic parameter whose +// binding selects `unknown`, `undefined` or `null` for some members, read +// inside the generic body. The checker's union of the results decides the +// layout: `unknown` absorbs the other results, and `undefined` and `null` +// are the union's unit arms, as in the concrete instantiation. + +type Kind = "alpha" | "beta" | "gamma" | (string & {}); + +interface BetaOpts { + depth: number; +} + +interface Loose { + kind: K; + meta: K extends "alpha" ? unknown : K extends "beta" ? BetaOpts : never; +} + +interface Units { + kind: K; + meta: K extends "alpha" ? undefined : K extends "beta" ? BetaOpts : K extends "gamma" ? null : never; +} + +function looseMetas(items: readonly Loose[]): string { + let out = ""; + for (const item of items) out += `${item.kind}:${JSON.stringify(item.meta)} `; + return out; +} + +function unitMetas(items: readonly Units[]): string { + let out = ""; + for (const item of items) { + const meta = item.meta; + out += `${item.kind}:${meta === undefined ? "u" : meta === null ? "n" : meta.depth} `; + } + return out; +} + +const loose: Loose<"alpha" | "beta">[] = [ + { kind: "alpha", meta: "free" }, + { kind: "alpha", meta: [1, 2] }, + { kind: "beta", meta: { depth: 2 } }, +]; +console.log(looseMetas(loose)); + +const units: Units<"alpha" | "beta" | "gamma">[] = [ + { kind: "alpha", meta: undefined }, + { kind: "beta", meta: { depth: 3 } }, + { kind: "gamma", meta: null }, +]; +console.log(unitMetas(units)); +console.log(JSON.stringify(units)); diff --git a/tests/harness/corpus-costs/generic-conditional-member-units.ts.json b/tests/harness/corpus-costs/generic-conditional-member-units.ts.json new file mode 100644 index 0000000000..2336c02d92 --- /dev/null +++ b/tests/harness/corpus-costs/generic-conditional-member-units.ts.json @@ -0,0 +1 @@ +{ "plain": 3600, "san": 5500 } From b2acdc90a57a50c8072d62846b1d1702a08c216f Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 27 Oct 2026 20:48:22 -0500 Subject: [PATCH 5/7] Check the lowerer's dynamic import rejections like its other load inputs The lowerer keeps LoadResult.dynamicImportRejections, the import() roots that never load. Unit records now check its size, as they check inlineRequireCycleCalls, so the unit journal covers it. --- packages/compiler/src/frontend/lowering/lowerer.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 41f6bf73dd..2790c4332a 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -4020,6 +4020,7 @@ export class Lowerer { checkedTable("fnSigsBySymbol", () => this.fnSigsBySymbol.size), checkedTable("cjsExportsAssignments", () => this.cjsExportsAssignments.size), checkedTable("inlineRequireCycleCalls", () => this.inlineRequireCycleCalls.size), + checkedTable("dynamicImportRejections", () => this.dynamicImportRejections.size), checkedTable("staticCallables", () => this.staticCallables.size), checkedTable("genericFnsBySymbol", () => this.genericFnsBySymbol.size), checkedTable("objLitGenericFns", () => this.objLitGenericFns.size), From 9931a36bba779df7671abfc8c604028ef531b572 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 19:04:58 -0700 Subject: [PATCH 6/7] Absorb a top-type result in a distributed conditional member The checker's union of `{}` (or `object`, `Object`) with a concrete result is the top type itself, but mapDistributedConditional mapped the parts separately and interned a union with a `dyn` arm. Emitting the member then failed validation with SC9001 ("union ...: arm 0 is dyn"). Return the checked-dynamic type as soon as a part maps to it, like the existing `unknown`/`any` absorption. Cover it in the units corpus: an `Empty` member holds its mapping in a local so the emitted union is actually exercised. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/frontend/type-mapper.ts | 14 +++++--- .../generic-conditional-member-units.ts | 32 ++++++++++++++++--- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index ec780e1436..6d2421af7b 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -6517,10 +6517,12 @@ function neverConditionalUnitOnly(t: ts.Type, ctx: TypeMapperCtx): boolean { /** mapType of a distributed conditional (distributedConditionalParts): the * union of its members' results, as the concrete conditional's union maps * them. An `unknown` or `any` result absorbs the rest (the checker's union - * is that type), and `undefined` and `null` results are the unit arms. Null - * when it does not distribute, when a result does not map, when every - * member selects never (the enclosing union drops such a part), or when - * only units remain. */ + * is that type), as does a result that maps to the checked-dynamic value + * (`{}`, `object`, `Object`: the checker's union of one with a concrete + * result is the top type), and `undefined` and `null` results are the unit + * arms. Null when it does not distribute, when a result does not map, when + * every member selects never (the enclosing union drops such a part), or + * when only units remain. */ function mapDistributedConditional(type: ts.Type, ctx: TypeMapperCtx): IrType | null { const parts = distributedConditionalParts(type, ctx); if (!parts || parts.length === 0) return null; @@ -6538,6 +6540,10 @@ function mapDistributedConditional(type: ts.Type, ctx: TypeMapperCtx): IrType | } const mapped = mapType(part, ctx); if (!mapped) return null; + // A top-type result (`{}`, `object`, `Object`): the checker's union with + // a concrete result is the top type itself, so the member is + // checked-dynamic. A `dyn` arm would be invalid union IR (SC9001). + if (mapped.kind === "dyn") return DYN; const members = mapped.kind === "union" ? (ctx.unions.get(mapped.unionId)?.arms ?? null) : [mapped]; if (!members) return null; diff --git a/tests/corpus/generic-conditional-member-units.ts b/tests/corpus/generic-conditional-member-units.ts index 02c9cdea9e..5899f6d35e 100644 --- a/tests/corpus/generic-conditional-member-units.ts +++ b/tests/corpus/generic-conditional-member-units.ts @@ -1,8 +1,8 @@ // Members typed by a distributive conditional over a generic parameter whose -// binding selects `unknown`, `undefined` or `null` for some members, read -// inside the generic body. The checker's union of the results decides the -// layout: `unknown` absorbs the other results, and `undefined` and `null` -// are the union's unit arms, as in the concrete instantiation. +// binding selects `unknown`, `{}`, `undefined` or `null` for some members, +// read inside the generic body. The checker's union of the results decides the +// layout: `unknown` and `{}` absorb the other results, and `undefined` and +// `null` are the union's unit arms, as in the concrete instantiation. type Kind = "alpha" | "beta" | "gamma" | (string & {}); @@ -15,6 +15,11 @@ interface Loose { meta: K extends "alpha" ? unknown : K extends "beta" ? BetaOpts : never; } +interface Empty { + kind: K; + meta: K extends "alpha" ? {} : K extends "beta" ? BetaOpts : never; +} + interface Units { kind: K; meta: K extends "alpha" ? undefined : K extends "beta" ? BetaOpts : K extends "gamma" ? null : never; @@ -26,6 +31,17 @@ function looseMetas(items: readonly Loose[]): string { return out; } +function emptyMetas(items: readonly Empty[]): string { + let out = ""; + for (const item of items) { + // The local holds the member's type, so the `{}` arm's mapping is + // emitted (a bare read would convert to dyn at the use site). + const meta = item.meta; + out += `${item.kind}:${JSON.stringify(meta)} `; + } + return out; +} + function unitMetas(items: readonly Units[]): string { let out = ""; for (const item of items) { @@ -42,6 +58,14 @@ const loose: Loose<"alpha" | "beta">[] = [ ]; console.log(looseMetas(loose)); +const empty: Empty<"alpha" | "beta">[] = [ + { kind: "alpha", meta: { free: true } }, + { kind: "alpha", meta: "text" }, + { kind: "beta", meta: { depth: 4 } }, +]; +console.log(emptyMetas(empty)); +console.log(JSON.stringify(empty)); + const units: Units<"alpha" | "beta" | "gamma">[] = [ { kind: "alpha", meta: undefined }, { kind: "beta", meta: { depth: 3 } }, From 9ad04922b25c6a3fe6524a161029c878d57d3126 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 19:05:26 -0700 Subject: [PATCH 7/7] Reject a literal import() of a module inside a rejected import graph A module reached by an already-rejected literal import() is `covered`, but a direct import() of that module still needs its own rejection entry: its graph rejects when that import runs, so it must not join the compiled graph merely because another rejected import also reaches it. Without the entry the module is compiled and lowering fails on the unloaded binding instead of the import() rejecting at run time as Node does. Walk each direct root independently and extend the unloaded-dynamic-import fixture with a direct import() of the rejected graph's dependency. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/frontend/program.ts | 7 ++++++- tests/fixtures/npm/cases/unloaded-dynamic-import/dep.ts | 9 +++++++++ tests/fixtures/npm/cases/unloaded-dynamic-import/lazy.ts | 3 ++- tests/fixtures/npm/cases/unloaded-dynamic-import/main.ts | 9 +++++++++ tests/harness/package-shims.test.ts | 1 + 5 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/npm/cases/unloaded-dynamic-import/dep.ts diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 099e020f3b..49c6498820 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -6551,7 +6551,12 @@ function preflight7(load: LoadResult): { const root = resolveImport7(program, sf, arg.text) ?? npmStaticDepSf7(program, sf, arg.text); if (root === null || root.isDeclarationFile || startup.has(root)) continue; - if (dynamicImportRejections.has(root) || covered.has(root)) continue; + // A module inside an already-rejected graph is `covered`, but a + // literal import() of it needs its OWN rejection entry: its graph + // rejects when that import runs, so it must not join the compiled + // graph (where lowering would fail) merely because another rejected + // import also reaches it. Walk each direct root independently. + if (dynamicImportRejections.has(root)) continue; seen.clear(); const crash = walk(root); if (crash === null) continue; diff --git a/tests/fixtures/npm/cases/unloaded-dynamic-import/dep.ts b/tests/fixtures/npm/cases/unloaded-dynamic-import/dep.ts new file mode 100644 index 0000000000..6fb3943859 --- /dev/null +++ b/tests/fixtures/npm/cases/unloaded-dynamic-import/dep.ts @@ -0,0 +1,9 @@ +// A dependency of the import()-only `lazy.ts` graph below. Its own static +// import names a package the project marks unloaded, so the graph rejects +// when `./lazy.ts` is imported — and equally when `./dep.ts` is imported +// directly. +import { heavy } from "gtunloadedheavy"; + +export function helper(text: string): string { + return heavy(text); +} diff --git a/tests/fixtures/npm/cases/unloaded-dynamic-import/lazy.ts b/tests/fixtures/npm/cases/unloaded-dynamic-import/lazy.ts index df940c7940..28a364f82e 100644 --- a/tests/fixtures/npm/cases/unloaded-dynamic-import/lazy.ts +++ b/tests/fixtures/npm/cases/unloaded-dynamic-import/lazy.ts @@ -1,7 +1,8 @@ import { heavy } from "gtunloadedheavy"; +import { helper } from "./dep.ts"; console.log("lazy evaluated"); export function run(text: string): string { - return heavy(text); + return helper(text) + heavy(text); } diff --git a/tests/fixtures/npm/cases/unloaded-dynamic-import/main.ts b/tests/fixtures/npm/cases/unloaded-dynamic-import/main.ts index 0750d8c4bb..53e8a90ca1 100644 --- a/tests/fixtures/npm/cases/unloaded-dynamic-import/main.ts +++ b/tests/fixtures/npm/cases/unloaded-dynamic-import/main.ts @@ -27,6 +27,15 @@ async function main(): Promise { } catch (err) { report("lazy:", err); } + // `dep.ts` is a dependency of the already-rejected `./lazy.ts` graph, but + // this literal import() targets it directly: its own graph rejects too, so + // it must reject at run time instead of joining the compiled graph. + try { + const dep = await import("./dep.ts"); + console.log(dep.helper("c")); + } catch (err) { + report("dep:", err); + } console.log("end"); } main(); diff --git a/tests/harness/package-shims.test.ts b/tests/harness/package-shims.test.ts index d7d991b829..2a3f2ae2da 100644 --- a/tests/harness/package-shims.test.ts +++ b/tests/harness/package-shims.test.ts @@ -132,6 +132,7 @@ describe("package shims", () => { const expected = await run(process.execPath, [join(original, "main.ts")], original); expect(expected).toContain("parser: ERR_MODULE_NOT_FOUND"); expect(expected).toContain("lazy: ERR_MODULE_NOT_FOUND"); + expect(expected).toContain("dep: ERR_MODULE_NOT_FOUND"); const dir = copyCase("unloaded-dynamic-import", "unloaded-dynamic-installed"); const write = (path: string, text: string) => {