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 <noreply@anthropic.com>
This commit is contained in:
Malte Ubl
2026-10-20 07:10:52 -07:00
co-authored by Claude Opus 5.5
parent 9458d389ba
commit 3039af2a66
11 changed files with 304 additions and 12 deletions
@@ -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<ts.SourceFile, readonly CycleEdge[]> | undefined;
inlineCycleCalls?: Set<ts.Node>;
/** LoadResult.dynamicImportRejections: import() roots that never load. */
rejected?: ReadonlyMap<ts.SourceFile, StartupCrash> | 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 &&
@@ -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);
@@ -787,6 +787,8 @@ export interface LowerOptions {
inlineRequireCycleCalls?: ReadonlySet<ts.Node> | undefined;
/** LoadResult.moduleEdges. */
moduleEdges?: ReadonlyMap<ts.SourceFile, readonly CycleEdge[]> | undefined;
/** LoadResult.dynamicImportRejections. */
dynamicImportRejections?: ReadonlyMap<ts.SourceFile, StartupCrash> | 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<ts.Node> | undefined;
/** LoadResult.dynamicImportRejections. */
dynamicImportRejections?: ReadonlyMap<ts.SourceFile, StartupCrash> | 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<ts.Node>(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<ts.Node>;
/** LoadResult.dynamicImportRejections. */
readonly dynamicImportRejections: ReadonlyMap<ts.SourceFile, StartupCrash>;
/** 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;
@@ -168,6 +168,8 @@ function addDynamicImportGraph(load: LoadResult, scanned: Set<SourceFile>): 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,
+61 -5
View File
@@ -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<ts.SourceFile, readonly CycleEdge[]> | 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<ts.SourceFile, StartupCrash> | 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<T extends LoadResult>(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<ts.Node>;
moduleEdges?: ReadonlyMap<ts.SourceFile, readonly CycleEdge[]>;
dynamicImportRejections?: ReadonlyMap<ts.SourceFile, StartupCrash>;
} {
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<ts.SourceFile, StartupCrash>();
{
const seen = new Set<ts.SourceFile>();
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<ts.SourceFile>): 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<ts.SourceFile>();
reach(entry, startup);
const covered = new Set<ts.SourceFile>();
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,
};
}
+4 -1
View File
@@ -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 ||
@@ -0,0 +1,7 @@
import { heavy } from "gtunloadedheavy";
console.log("lazy evaluated");
export function run(text: string): string {
return heavy(text);
}
@@ -0,0 +1,32 @@
/// <reference path="./types.d.ts" />
// 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<void> {
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();
@@ -0,0 +1,10 @@
{
"private": true,
"type": "module",
"scriptc": {
"packages": {
"wsunloadedparser": "unloaded",
"gtunloadedheavy": "unloaded"
}
}
}
@@ -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;
}
+54 -1
View File
@@ -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) => {