From 3039af2a661dd24e10690d78fb4eaf6c48a5fee4 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 20 Oct 2026 07:10:52 -0700 Subject: [PATCH 1/2] 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/2] 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": [] +}