diff --git a/packages/compiler/src/frontend/lowering/lower-modules.ts b/packages/compiler/src/frontend/lowering/lower-modules.ts index ced8e7a9..a0caafaa 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 4dbe00a2..d13ac6a0 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 68958a95..1a5534ca 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