diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index bffee4ef6f..da00a79aaf 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -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 | undefined; inlineCycleCalls?: Set; + /** LoadResult.dynamicImportRejections: import() roots that never load. */ + rejected?: ReadonlyMap | 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 && diff --git a/packages/compiler/src/frontend/lowering/lower-stdlib.ts b/packages/compiler/src/frontend/lowering/lower-stdlib.ts index 26200efbb7..44f2b7370e 100644 --- a/packages/compiler/src/frontend/lowering/lower-stdlib.ts +++ b/packages/compiler/src/frontend/lowering/lower-stdlib.ts @@ -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); diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index bf202ab5b8..05e3858199 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -965,6 +965,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 @@ -1086,6 +1088,8 @@ export interface LowererMode { dynNodeStreams?: boolean; /** LoadResult.inlineRequireCycleCalls. */ inlineRequireCycleCalls?: ReadonlySet | undefined; + /** LoadResult.dynamicImportRejections. */ + dynamicImportRejections?: ReadonlyMap | 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(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; + /** 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. */ @@ -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; diff --git a/packages/compiler/src/frontend/pipeline.ts b/packages/compiler/src/frontend/pipeline.ts index 0071d02959..7e94f817b3 100644 --- a/packages/compiler/src/frontend/pipeline.ts +++ b/packages/compiler/src/frontend/pipeline.ts @@ -208,6 +208,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); @@ -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, diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 74ccb01c8b..5c8eaa8ab0 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -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 | 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 @@ -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(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; moduleEdges?: ReadonlyMap; + dynamicImportRejections?: ReadonlyMap; } { 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(); { const seen = new Set(); 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): 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; + // 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, }; } diff --git a/packages/compiler/src/frontend/resolve.ts b/packages/compiler/src/frontend/resolve.ts index 86252499fc..dca3e52a8d 100644 --- a/packages/compiler/src/frontend/resolve.ts +++ b/packages/compiler/src/frontend/resolve.ts @@ -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 || diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts index 062d199736..6d2421af7b 100644 --- a/packages/compiler/src/frontend/type-mapper.ts +++ b/packages/compiler/src/frontend/type-mapper.ts @@ -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` 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 | 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 | 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; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-conditional-member-bindings.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-conditional-member-bindings.ts.json new file mode 100644 index 0000000000..398cc39e9a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-conditional-member-bindings.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/generic-conditional-member-bindings.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-conditional-member-units.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-conditional-member-units.ts.json new file mode 100644 index 0000000000..35b3ba808a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/generic-conditional-member-units.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/generic-conditional-member-units.ts" + ], + "diags": [] +} diff --git a/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/corpus/generic-conditional-member-bindings.ts b/tests/corpus/generic-conditional-member-bindings.ts new file mode 100644 index 0000000000..60631e191c --- /dev/null +++ b/tests/corpus/generic-conditional-member-bindings.ts @@ -0,0 +1,100 @@ +// Members typed by a distributive conditional over a generic parameter +// (`opts?: T extends "alpha" ? AlphaOpts : ...`), read inside the generic +// body: the instance's binding decides the layout, member by member of a +// literal union, and an all-never binding leaves an optional member that +// holds only undefined, as the concrete instantiation's member does. + +type KnownApi = "alpha" | "beta" | "gamma"; +type Api = KnownApi | (string & {}); + +interface AlphaOpts { + fast?: boolean; +} +interface BetaOpts { + depth?: number; +} + +interface Base { + id: string; + api: TApi; +} + +interface Item extends Base { + kind?: "item"; + opts?: TApi extends "alpha" ? AlphaOpts : TApi extends "beta" ? BetaOpts : never; +} + +interface Other extends Base { + kind: "other"; + size: number; +} + +type AnyItem = Item | Other; + +interface Options { + name: string; + items: readonly AnyItem[]; +} + +interface Built { + name: string; + all: () => readonly AnyItem[]; + chat: () => Item[]; + add: (item: AnyItem) => void; +} + +function describe(item: Item): string { + return `${item.id}:${item.api}:${item.opts === undefined ? "-" : JSON.stringify(item.opts)}`; +} + +function build(input: Options): Built { + const baseline = input.items; + let dynamic: readonly AnyItem[] = []; + const current = (): readonly AnyItem[] => { + const merged = [...baseline]; + for (const item of dynamic) merged.push(item); + return merged; + }; + return { + name: input.name, + all: current, + chat: () => current().filter((item): item is Item => item.kind !== "other"), + add: (item) => { + dynamic = [...dynamic, item]; + }, + }; +} + +function report(built: Built): void { + console.log(`${built.name}: ${built.all().length} items, ${built.chat().length} chat`); + for (const item of built.chat()) console.log(` ${describe(item)}`); +} + +const mixed: Built<"alpha" | "gamma"> = build({ + name: "mixed", + items: [ + { id: "a1", api: "alpha", opts: { fast: true } }, + { id: "g1", api: "gamma" }, + ], +}); +mixed.add({ id: "a2", api: "alpha" }); +report(mixed); + +const beta: Built<"beta"> = build({ + name: "beta", + items: [ + { id: "b1", api: "beta", opts: { depth: 2 } }, + { id: "o1", api: "other", kind: "other", size: 1 }, + ], +}); +beta.add({ id: "o2", api: "other", kind: "other", size: 3 }); +report(beta); + +const custom = build({ name: "custom", items: [{ id: "c1", api: "custom" }] }); +custom.add({ id: "c2", api: "custom" }); +report(custom); + +const open = build({ name: "open", items: [{ id: "x1", api: "external" }] }); +report(open); +const first = open.chat()[0]; +console.log(first === undefined ? "none" : first.opts === undefined ? "no opts" : "opts"); diff --git a/tests/corpus/generic-conditional-member-units.ts b/tests/corpus/generic-conditional-member-units.ts new file mode 100644 index 0000000000..5899f6d35e --- /dev/null +++ b/tests/corpus/generic-conditional-member-units.ts @@ -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 { + kind: K; + meta: K extends "alpha" ? unknown : K extends "beta" ? BetaOpts : never; +} + +interface Empty { + kind: K; + meta: K extends "alpha" ? {} : K extends "beta" ? BetaOpts : never; +} + +interface Units { + kind: K; + meta: K extends "alpha" ? undefined : K extends "beta" ? BetaOpts : K extends "gamma" ? null : never; +} + +function looseMetas(items: readonly Loose[]): string { + let out = ""; + for (const item of items) out += `${item.kind}:${JSON.stringify(item.meta)} `; + return out; +} + +function emptyMetas(items: readonly Empty[]): string { + let out = ""; + for (const item of items) { + // The local holds the member's type, so the `{}` arm's mapping is + // emitted (a bare read would convert to dyn at the use site). + const meta = item.meta; + out += `${item.kind}:${JSON.stringify(meta)} `; + } + return out; +} + +function unitMetas(items: readonly Units[]): string { + let out = ""; + for (const item of items) { + 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)); diff --git a/tests/fixtures/npm/cases/unloaded-dynamic-import/dep.ts b/tests/fixtures/npm/cases/unloaded-dynamic-import/dep.ts new file mode 100644 index 0000000000..6fb3943859 --- /dev/null +++ b/tests/fixtures/npm/cases/unloaded-dynamic-import/dep.ts @@ -0,0 +1,9 @@ +// A dependency of the import()-only `lazy.ts` graph below. Its own static +// import names a package the project marks unloaded, so the graph rejects +// when `./lazy.ts` is imported — and equally when `./dep.ts` is imported +// directly. +import { heavy } from "gtunloadedheavy"; + +export function helper(text: string): string { + return heavy(text); +} diff --git a/tests/fixtures/npm/cases/unloaded-dynamic-import/lazy.ts b/tests/fixtures/npm/cases/unloaded-dynamic-import/lazy.ts new file mode 100644 index 0000000000..28a364f82e --- /dev/null +++ b/tests/fixtures/npm/cases/unloaded-dynamic-import/lazy.ts @@ -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); +} 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..53e8a90ca1 --- /dev/null +++ b/tests/fixtures/npm/cases/unloaded-dynamic-import/main.ts @@ -0,0 +1,41 @@ +/// +// 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); + } + // `dep.ts` is a dependency of the already-rejected `./lazy.ts` graph, but + // this literal import() targets it directly: its own graph rejects too, so + // it must reject at run time instead of joining the compiled graph. + try { + const dep = await import("./dep.ts"); + console.log(dep.helper("c")); + } catch (err) { + report("dep:", err); + } + console.log("end"); +} +main(); diff --git a/tests/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/corpus-costs/generic-conditional-member-bindings.ts.json b/tests/harness/corpus-costs/generic-conditional-member-bindings.ts.json new file mode 100644 index 0000000000..f46d03f09a --- /dev/null +++ b/tests/harness/corpus-costs/generic-conditional-member-bindings.ts.json @@ -0,0 +1 @@ +{ "plain": 14775, "san": 16991 } diff --git a/tests/harness/corpus-costs/generic-conditional-member-units.ts.json b/tests/harness/corpus-costs/generic-conditional-member-units.ts.json new file mode 100644 index 0000000000..2336c02d92 --- /dev/null +++ b/tests/harness/corpus-costs/generic-conditional-member-units.ts.json @@ -0,0 +1 @@ +{ "plain": 3600, "san": 5500 } diff --git a/tests/harness/package-shims.test.ts b/tests/harness/package-shims.test.ts index a21c4e7686..2a3f2ae2da 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 @@ -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 });