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..9bd54c9188 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,40 @@ 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 +5544,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 +5566,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/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": [] +} 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) => {