Merge main

This commit is contained in:
Chris Tate
2026-10-27 22:03:19 -05:00
20 changed files with 650 additions and 11 deletions
@@ -34,11 +34,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,
@@ -175,6 +177,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
@@ -440,7 +444,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);
@@ -722,6 +727,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 (
@@ -742,6 +768,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 &&
@@ -38,6 +38,7 @@ import {
isJsSourceFile,
locOf,
npmPackageNameOf,
type StartupCrash,
} from "../program.js";
import { foldedStringKeyOf, lowerDynObjectLiteral } from "./expressions/object-literals.js";
import { noteEventListeners } from "./lower-event-target.js";
@@ -2243,6 +2244,7 @@ export function lowerDynamicImportCall(lowerer: Lowerer, call: ts.CallExpression
}
const res = lowerer.dynImports.get(`${ts.getSourceFileOfNode(call).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);
}
@@ -2252,6 +2254,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 = lowerer.closureName(loc, "_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);
@@ -965,6 +965,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
@@ -1086,6 +1088,8 @@ export interface LowererMode {
dynNodeStreams?: boolean;
/** LoadResult.inlineRequireCycleCalls. */
inlineRequireCycleCalls?: ReadonlySet<ts.Node> | undefined;
/** LoadResult.dynamicImportRejections. */
dynamicImportRejections?: ReadonlyMap<ts.SourceFile, StartupCrash> | undefined;
/** LowerOptions.libraryCallbacks (see there). */
libraryCallbacks?: boolean;
projectCheckJs?: boolean;
@@ -1219,7 +1223,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);
@@ -1240,6 +1248,7 @@ export function lowerToIr(
targetPlatform,
startupCrash,
inlineRequireCycleCalls,
dynamicImportRejections: options.dynamicImportRejections,
ffiImports,
libraryCallbacks,
forkTargets,
@@ -1264,6 +1273,7 @@ export function lowerToIr(
targetPlatform,
startupCrash,
inlineRequireCycleCalls,
dynamicImportRejections: options.dynamicImportRejections,
ffiImports,
libraryCallbacks,
forkTargets,
@@ -1329,6 +1339,7 @@ export function lowerToIr(
targetPlatform,
startupCrash,
inlineRequireCycleCalls,
dynamicImportRejections: options.dynamicImportRejections,
ffiImports,
libraryCallbacks,
forkTargets,
@@ -1374,6 +1385,7 @@ export function lowerToIr(
targetPlatform,
startupCrash,
inlineRequireCycleCalls,
dynamicImportRejections: options.dynamicImportRejections,
ffiImports,
libraryCallbacks,
forkTargets,
@@ -1515,6 +1527,7 @@ export function lowerToIr(
degradedTypes: resultLowerer.degradedTypes,
retriedSymbols: resultLowerer.retriedSymbols,
inlineRequireCycleCalls,
dynamicImportRejections: options.dynamicImportRejections,
targetPlatform,
ffiImports,
libraryCallbacks,
@@ -2135,6 +2148,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. */
@@ -3472,7 +3487,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<
@@ -4030,6 +4047,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),
@@ -4781,6 +4799,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;
@@ -208,6 +208,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);
@@ -849,6 +851,7 @@ function loadFrontend(
startupCrash: l.startupCrash ?? null,
inlineRequireCycleCalls: l.inlineRequireCycleCalls,
moduleEdges: l.moduleEdges,
dynamicImportRejections: l.dynamicImportRejections,
projectCheckJs: l.typeWorld?.projectCheckJs ?? false,
externalTypes: l.externalTypes,
externalTypeSpecifiersByFile: l.externalTypeSpecifiersByFile,
+67 -5
View File
@@ -97,6 +97,7 @@ import {
configurePackageShims,
isPackageShimFile,
isShimmedImport,
isUnloadedPackage,
npmStaticOmitsPackage,
} from "./package-shims.js";
import { provenanceEntryFor, provenancePaths } from "./provenance-registry.js";
@@ -393,6 +394,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
@@ -545,6 +551,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
@@ -1467,12 +1475,19 @@ function declBoundaryDroppedDiag(file: string): ScrDiagnostic {
* 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;
}
@@ -5411,6 +5426,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];
@@ -5968,9 +5984,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: ts.tokenStartOfNode(stmt),
});
@@ -6013,6 +6030,7 @@ function preflight7(load: LoadResult): {
refusal.message,
"%Error",
`the '${spec}' package (its runtime resolution fails: ${refusal.message})`,
refusal.code,
);
}
continue;
@@ -6050,6 +6068,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;
}
@@ -6070,6 +6089,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;
}
@@ -6078,7 +6098,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
@@ -6544,6 +6564,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 => {
@@ -6563,6 +6584,45 @@ 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;
// 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;
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
@@ -6570,7 +6630,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);
}
}
@@ -6591,6 +6652,7 @@ function preflight7(load: LoadResult): {
startupCrash: resolveCrash ?? linkCrash,
inlineRequireCycleCalls,
moduleEdges: edges,
dynamicImportRejections,
};
}
@@ -24,6 +24,7 @@ import {
} from "./npm-static.js";
import {
clearPackageShimCaches,
isUnloadedPackage,
packageShimFor,
throughBundledShimForwarder,
} from "./package-shims.js";
@@ -1026,6 +1027,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 ||
+121 -1
View File
@@ -2287,6 +2287,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);
@@ -5299,11 +5304,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> |
// void` return types, `string | void`): it becomes the undefinedT
// unit arm, exactly like an undefined part — the value either
@@ -5311,6 +5324,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) {
@@ -5353,6 +5367,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<T> | 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
@@ -6439,6 +6458,104 @@ 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;
// 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`
// 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<T> | 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, as the concrete conditional's union maps
* them. An `unknown` or `any` result absorbs the rest (the checker's union
* 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;
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;
// 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;
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));
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. */
@@ -8569,7 +8686,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;
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/generic-conditional-member-bindings.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/generic-conditional-member-units.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/fixtures/npm/cases/unloaded-dynamic-import/main.ts"
],
"diags": []
}
@@ -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<TApi extends string> {
id: string;
api: TApi;
}
interface Item<TApi extends Api> extends Base<TApi> {
kind?: "item";
opts?: TApi extends "alpha" ? AlphaOpts : TApi extends "beta" ? BetaOpts : never;
}
interface Other extends Base<string> {
kind: "other";
size: number;
}
type AnyItem<TApi extends Api = Api> = Item<TApi> | Other;
interface Options<TApi extends Api> {
name: string;
items: readonly AnyItem<TApi>[];
}
interface Built<TApi extends Api> {
name: string;
all: () => readonly AnyItem<TApi>[];
chat: () => Item<TApi>[];
add: (item: AnyItem<TApi>) => void;
}
function describe(item: Item<Api>): string {
return `${item.id}:${item.api}:${item.opts === undefined ? "-" : JSON.stringify(item.opts)}`;
}
function build<TApi extends Api = Api>(input: Options<TApi>): Built<TApi> {
const baseline = input.items;
let dynamic: readonly AnyItem<TApi>[] = [];
const current = (): readonly AnyItem<TApi>[] => {
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<TApi> => item.kind !== "other"),
add: (item) => {
dynamic = [...dynamic, item];
},
};
}
function report<TApi extends Api>(built: Built<TApi>): 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<string>({ 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");
@@ -0,0 +1,75 @@
// 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` 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 & {});
interface BetaOpts {
depth: number;
}
interface Loose<K extends Kind> {
kind: K;
meta: K extends "alpha" ? unknown : K extends "beta" ? BetaOpts : never;
}
interface Empty<K extends Kind> {
kind: K;
meta: K extends "alpha" ? {} : K extends "beta" ? BetaOpts : never;
}
interface Units<K extends Kind> {
kind: K;
meta: K extends "alpha" ? undefined : K extends "beta" ? BetaOpts : K extends "gamma" ? null : never;
}
function looseMetas<K extends Kind>(items: readonly Loose<K>[]): string {
let out = "";
for (const item of items) out += `${item.kind}:${JSON.stringify(item.meta)} `;
return out;
}
function emptyMetas<K extends Kind>(items: readonly Empty<K>[]): 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<K extends Kind>(items: readonly Units<K>[]): 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 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 } },
{ kind: "gamma", meta: null },
];
console.log(unitMetas(units));
console.log(JSON.stringify(units));
@@ -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);
}
@@ -0,0 +1,8 @@
import { heavy } from "gtunloadedheavy";
import { helper } from "./dep.ts";
console.log("lazy evaluated");
export function run(text: string): string {
return helper(text) + heavy(text);
}
@@ -0,0 +1,41 @@
/// <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);
}
// `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();
@@ -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;
}
@@ -0,0 +1 @@
{ "plain": 14775, "san": 16991 }
@@ -0,0 +1 @@
{ "plain": 3600, "san": 5500 }
+63 -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
@@ -22,7 +25,15 @@
* Node running the real package.
*/
import { execFile, spawn } from "node:child_process";
import { copyFileSync, cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import {
copyFileSync,
cpSync,
mkdirSync,
mkdtempSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
@@ -116,6 +127,57 @@ 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");
expect(expected).toContain("dep: 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("a CommonJS package requiring fs-extra compiles the shim", async () => {
const dir = join(scratch, "fs-extra-require");
cpSync(fsExtraModules, join(dir, "node_modules"), { recursive: true });