Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions packages/compiler/src/frontend/lowering/lower-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 10 additions & 1 deletion packages/compiler/src/frontend/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -321,6 +321,15 @@ function loadProgram7(
externalTypes: ReadonlyMap<string, string> = 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<string, string[]>) : 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
Expand Down
67 changes: 66 additions & 1 deletion packages/compiler/src/frontend/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]> | null = null;

export function setTsconfigPaths(paths: Record<string, string[]> | 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down