From b4eb36bed6b59744c44d6c7d5161d9ea6bbf820d Mon Sep 17 00:00:00 2001 From: Max B Date: Sat, 22 Aug 2026 11:15:51 +0200 Subject: [PATCH] fix(compiler): dynamic-import lowering resolves bare project specifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the dynamic-import codegen path's own resolver (resolveProjectImport in resolve.ts), independent from the type-checker's resolution but expected to agree with it: - dynamicImportProgramTargetOf only ever tried the checker-based resolveImport for relative/absolute specifiers, never for bare ones. A package importing its own name (`import("my-package/foo")` from within my-package, resolved via its package.json self-name "exports") is something the checker resolves fine, but the lowering path returned null unconditionally and reported "dynamic import of the program's own module ... is not part of the compiled module graph" even though the exact specifier had just resolved moments earlier. - resolveProjectImport's final fallback only tried loadAsFile then a bare isFile check, never loadAsDirectory (unlike every other resolver in this file) — so an exports/imports target landing on a directory answered null instead of falling back to its index file. Also adds a tsconfig `paths` fallback registry (setTsconfigPaths / resolveViaTsconfigPaths), consulted only when the package.json-exports walk finds nothing. It pairs with a separate, independent PR adopting a project's `paths` into the type-checker; until that lands the registry stays empty and this fallback is inert. --- .../src/frontend/lowering/lower-modules.ts | 27 +++++++- packages/compiler/src/frontend/program.ts | 11 ++- packages/compiler/src/frontend/resolve.ts | 67 ++++++++++++++++++- 3 files changed, 100 insertions(+), 5 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index ced8e7a97..a0caafaa8 100644 --- a/packages/compiler/src/frontend/lowering/lower-modules.ts +++ b/packages/compiler/src/frontend/lowering/lower-modules.ts @@ -10,6 +10,7 @@ import { isNpmStaticPackage } from "../npm-static.js"; import { isJsSourceFileName, isRelativeSpecifier } from "../shared.js"; import { canonicalBuiltinModule, cjsExportAssignmentOf, cjsExportDiscardReason, isCjsJsFile, isJsSourceFile, isRequireStatement, locOf, makeCycleAdmission, orderedImportsOf, resolveImport, resolveNpmImport } from "../program.js"; import type { CycleEdge } from "../program.js"; +import { resolveProjectImport } from "../resolve.js"; import { invalidJsonModuleDiag, npmEmbedFailedDiag, requiresDynamicImportDiag } from "../../diagnostics/diagnostic.js"; import { BOOL, DYN, IrClassDef, IrExpr, IrFunction, IrGlobal, IrRecordShape, IrStmt, IrType, IrUnionDef, JSVAL, RUNTIME_ERROR_CLASSES, STRING, SrcLoc, VOID, arrayOf, canConvertToDyn, isUnitType } from "../../ir/nodes.js"; import { ENTRY_NAME, PoisonError, boundIdentifiersOf, dynFallbackType, dynUndefinedExpr, importCallHandleType, newFnCtx, uncheckedOverloadHandleCall } from "./lowerer.js"; @@ -70,14 +71,34 @@ export interface FileParts { * module namespace (lowerOwnModuleImport): a non-declaration program file * that is not JSON and not CommonJS-flavored (a CJS namespace is built * from module.exports through Node's lexer — a different surface with no - * static story here). Null for everything else. */ + * static story here). Null for everything else. + * + * Relative/absolute specifiers resolve through the checker (resolveImport, + * program.ts's own tsgo-backed answer). A BARE specifier reaching this far + * can still name a program module: a package importing its OWN name + * through its package.json self-name "exports" (or, once a project's + * `paths` are adopted, a tsconfig alias) — the checker resolves that + * specifier too, so lowering must agree or a bare dynamic import that the + * checker admitted lowers as a program-module namespace build while never + * having been added to the compiled module graph (appendDynamicImportModules + * walks resolveImport/resolveProjectImport's own answers, not this + * function's — a mismatch here strands the edge). resolve.ts's + * resolveProjectImport is the SAME resolver appendDynamicImportModules' + * static-edge walk and the npm-import chokepoint both already trust for + * bare project-internal specifiers, so reusing it here keeps every bare- + * specifier answer in the compiler on one resolver. */ export function dynamicImportProgramTargetOf( program: ts.Program, sf: ts.SourceFile, spec: string, ): ts.SourceFile | null { - if (!isRelativeSpecifier(spec) && !spec.startsWith("/")) return null; - const dep = resolveImport(program, sf, spec); + let dep: ts.SourceFile | null; + if (isRelativeSpecifier(spec) || spec.startsWith("/")) { + dep = resolveImport(program, sf, spec); + } else { + const resolved = resolveProjectImport(sf.fileName, spec); + dep = resolved !== null ? (program.getSourceFile(resolved) ?? null) : null; + } if (!dep || dep.isDeclarationFile) return null; if (dep.fileName.endsWith(".json") || dep.fileName.endsWith(".cts")) return null; if (isCjsJsFile(dep)) return null; diff --git a/packages/compiler/src/frontend/program.ts b/packages/compiler/src/frontend/program.ts index 4dbe00a28..d13ac6a0e 100644 --- a/packages/compiler/src/frontend/program.ts +++ b/packages/compiler/src/frontend/program.ts @@ -50,7 +50,7 @@ import { tscPassthroughDiag, unsupportedDiag, } from "../diagnostics/diagnostic.js"; -import { isNodeModulesPath, nearestInvalidPackageJsonPath, nearestPackageType, nearestPkgJsonPath, projectDtsRuntimeSibling, resolveBareModule, resolveProjectImport, resolveRelativeModule, resolveTypeDirective, setProjectRealm } from "./resolve.js"; +import { isNodeModulesPath, nearestInvalidPackageJsonPath, nearestPackageType, nearestPkgJsonPath, projectDtsRuntimeSibling, resolveBareModule, resolveProjectImport, resolveRelativeModule, resolveTypeDirective, setProjectRealm, setTsconfigPaths } from "./resolve.js"; import { probeNodeImportRefusal, probeNodeRequireRefusal } from "./npm.js"; import { isNpmStaticPackage, npmStaticActive, npmStaticFsShadow, npmStaticPackageOfPath, reportNpmStaticOffender, setNpmStaticPackages } from "./npm-static.js"; import { provenanceEntryFor, provenancePaths } from "./provenance-registry.js"; @@ -321,6 +321,15 @@ function loadProgram7( externalTypes: ReadonlyMap = new Map(), ): LoadResult & { disposeAll: () => void } { const config = adoptProjectConfig7(host, entryPath); + // resolveProjectImport (resolve.ts) needs the same paths map handed to + // tsgo above — see setTsconfigPaths's doc comment. One program load, one + // registry write; a later load (a second entry point in the same + // process) overwrites it, matching how tsgo itself is reconfigured per + // program. + const configPaths = config.options["paths"]; + setTsconfigPaths( + configPaths && typeof configPaths === "object" ? (configPaths as Record) : null, + ); const nodeTypes = config.configFile ? resolveNodeTypes7(entryPath) : null; // skipLibCheck is FORCED with @types/node in the program: checking a // third-party lib's internals against OUR lib choice (es2025, no dyn) is diff --git a/packages/compiler/src/frontend/resolve.ts b/packages/compiler/src/frontend/resolve.ts index 68958a953..1a5534caf 100644 --- a/packages/compiler/src/frontend/resolve.ts +++ b/packages/compiler/src/frontend/resolve.ts @@ -232,6 +232,55 @@ function loadAsDirectory(base: string): string | null { return null; } +/* tsconfig `paths` registry — populated once per program load (program.ts's + * adoptProjectConfig7, which already parses the real tsconfig for the + * checker) with the SAME absolutized map handed to tsgo. tsgo resolves + * `paths` natively; this module's own resolveProjectImport only understands + * package.json self-name "exports" and the "#alias" imports field, so an + * alias with no package.json counterpart at all (a project's `@/*` pointing + * at its own src tree, distinct from its package name) previously had no + * project-internal resolver to answer it — SC1010 "package not installed" + * even though the checker resolved the same specifier fine. Values are + * already absolute (the same targets tsgo's synthesized tsconfig uses), so + * candidates need only the ordinary bundler extension-substitution pass. */ +let tsconfigPaths: Record | null = null; + +export function setTsconfigPaths(paths: Record | null): void { + tsconfigPaths = paths; +} + +/** Longest-prefix `paths` match, mirroring package.json "exports" pattern + * precedence (resolveExportsTypes below) rather than tsconfig's declared + * first-match-wins order — the two are equivalent for well-formed configs + * (a project should never declare two `paths` keys where a shorter one is + * also a prefix of the specifier and a real ambiguity would result), and + * longest-prefix avoids depending on object key enumeration order. */ +function resolveViaTsconfigPaths(specifier: string): string | null { + if (tsconfigPaths === null) return null; + let best: { targets: string[]; prefix: string; suffix: string } | null = null; + for (const [key, targets] of Object.entries(tsconfigPaths)) { + const star = key.indexOf("*"); + const prefix = star < 0 ? key : key.slice(0, star); + const suffix = star < 0 ? "" : key.slice(star + 1); + if ( + specifier.startsWith(prefix) && + specifier.length >= prefix.length + suffix.length && + specifier.endsWith(suffix) && + (best === null || prefix.length > best.prefix.length) + ) { + best = { targets, prefix, suffix }; + } + } + if (best === null) return null; + const wildcard = specifier.slice(best.prefix.length, specifier.length - best.suffix.length); + for (const target of best.targets) { + const path = target.includes("*") ? target.split("*").join(wildcard) : target; + const answer = loadAsFile(path) ?? loadAsDirectory(path) ?? (isFile(path) ? path : null); + if (answer !== null) return answer; + } + return null; +} + /** The RUNTIME sibling of a PROJECT declaration twin — "src/index.js" for * "src/index.d.ts" when both exist OUTSIDE node_modules — or null. Node * loads the JS (declaration files do not exist in its world), and a @@ -504,6 +553,15 @@ export function nearestInvalidPackageJsonPath(fromFile: string): string | null { * Relative specifiers, real node_modules packages, and builtins are other * resolvers' business; callers try those first. */ export function resolveProjectImport(fromFile: string, specifier: string): string | null { + const answer = resolveProjectImportViaPackageJson(fromFile, specifier); + // tsconfig `paths` fallback: an alias with no package.json counterpart at + // all (see resolveViaTsconfigPaths above) — never consulted for "#alias" + // specifiers, which are exclusively package.json's own imports-field + // business and must not silently pick up an unrelated `paths` entry. + return answer ?? (specifier.startsWith("#") ? null : resolveViaTsconfigPaths(specifier)); +} + +function resolveProjectImportViaPackageJson(fromFile: string, specifier: string): string | null { // --provenance-sources (flag-gated; the registry is empty otherwise): a // registered bare specifier answers its attested SOURCE entry — the one // chokepoint that makes preflight's user-module edges, the module @@ -542,7 +600,14 @@ export function resolveProjectImport(fromFile: string, specifier: string): strin } if (target === null) return null; const path = join(pkgDir, target); - return loadAsFile(path) ?? (isFile(path) ? path : null); + // Mirrors resolveRelativeModule below: a package.json "exports"/"imports" + // target can itself be a DIRECTORY (a wildcard subpath landing on + // "./src/foo", answered by "./src/foo/index.ts") — this resolver was + // missing that fallback entirely, unlike every other resolver in this + // module, so a self-name specifier landing on a directory answered null + // (SC1010 "package not installed") even though the exact same directory + // resolves fine as a relative import one character away. + return loadAsFile(path) ?? loadAsDirectory(path) ?? (isFile(path) ? path : null); } /* 5.9.3 with allowJs resolves node_modules in TWO FULL PASSES (probed): the