fix(js,ts): an import written from a jsconfig or tsconfig baseUrl links the file it names (#2649)

CRA and Vite admin templates (codedthemes' berry and mantis, uilibrary's
matx) set `"baseUrl": "src"` or `"paths": { "*": ["./src/*"] }` in a
jsconfig and write every cross-file import as `layout/MainLayout`. The
import resolver took each one for an npm package: loadProjectAliases
returned null for a config with a baseUrl and no paths, and
isExternalImport classifies a bare specifier from the root config alone,
before a nested app's config is read. The imports stayed unresolved,
their names linked by bare-name matching (vite components rendering the
remix app's namesakes), and React Router routes loaded through
`lazy(() => import('views/x'))` bound to the local Loadable value.

- path-aliases: a config with only `baseUrl` loads, marked
  `declaresBaseUrl`; aliasAndBaseUrlCandidates lists the most specific
  `paths` pattern's targets, then the specifier read from a declared
  baseUrl (paths alone never reads bare specifiers, as in TS 4.1+).
- import-resolver: a specifier isExternalImport takes for a package is
  read through the importing file's own config (nearest, else the root's)
  and binds only to a file the index holds (`fileExists` also answers for
  folders and node_modules). A package the file's package.json or an
  enclosing one declares, or a Node built-in, never binds to a project
  module that way, only to a `.d.ts` the project keeps for it (#2437's
  `"*": ["./typings/*"]`). A root catch-all `"*"` no longer marks every
  specifier local in isExternalImport's default mode either.
- The module ref of a one-segment import (`'config'`) links the file its
  specifier resolves to, as a path-shaped one already does (#2528).
- isBoundToOutOfRepoImport keeps the `extends` edge of a class whose base
  is imported through baseUrl.
- importBindingTail parks a binding under its module when the app's
  config maps the specifier to an indexed file, so a sync that edits the
  module relinks `import Layout from 'layout/MainLayout'` and
  `{ Foo as Bar }` (#2450's rule for relative imports). An undeclared name
  that maps to nothing (`express` under `"baseUrl": "."`) and a declared
  package keep their own tail, which root-"*" repos had lost.
- The TypeScript and TSX extension lists probe `/index.jsx` last, as tsc
  does: a route resolves its lazy module as TypeScript.

Validated against main 6071c9e4 on 21 repos (wasm path): every new
import-resolved edge lands in the file its specifier names; failed
imports naming a project file go 194/190/96 -> 0 on berry/mantis/matx;
their routes bind their lazily loaded pages and layouts (matx 7 -> 21
routes); 37 + 45 cross-app JSX renders move to the app's own components.
proshop, next-saas-starter, create-t3-turbo, excalidraw and six more are
byte-identical; root-"*" repos change only parked tails plus module
links. Same deltas with stubbed node_modules. Sync after late adds and
edits equals a fresh index, except one pre-existing barrel-edit gap that
relative imports share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-19 08:36:56 +00:00
committed by GitHub
parent d3a778d0b9
commit de3eb71df6
8 changed files with 722 additions and 48 deletions
+123 -24
View File
@@ -8,7 +8,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { Language, Node } from '../types';
import { UnresolvedRef, ResolvedRef, ResolutionContext, ImportMapping, ReExport } from './types';
import { applyAliases } from './path-aliases';
import { applyAliases, aliasAndBaseUrlCandidates } from './path-aliases';
import { extractLocalExportAliases } from './alias-binding';
import { resolveWorkspaceImport } from './workspace-packages';
import { stripCommentsForRegex } from './strip-comments';
@@ -32,14 +32,18 @@ import { JS_ARRAY_METHODS } from './js-builtins';
* Extension resolution order by language
*/
const EXTENSION_RESOLUTION: Record<string, string[]> = {
typescript: ['.ts', '.tsx', '.d.ts', '.js', '.jsx', '/index.ts', '/index.tsx', '/index.js'],
// `/index.jsx` last, as tsc probes a directory: a route table resolves the
// module its `lazy(() => import('views/x'))` loads as TypeScript, whatever
// the file is written in, and admin templates keep their pages in
// `views/x/index.jsx`.
typescript: ['.ts', '.tsx', '.d.ts', '.js', '.jsx', '/index.ts', '/index.tsx', '/index.js', '/index.jsx'],
// ArkTS imports both `.ets` components and plain `.ts` logic modules —
// HarmonyOS projects are always a mix. `/Index.ets` (capital I) is ohpm's
// module-entry convention, hit when a bare workspace import ("data") is
// rewritten to the member's directory; lowercase variants for safety.
arkts: ['.ets', '.ts', '.d.ts', '.js', '/Index.ets', '/index.ets', '/index.ts', '/index.js'],
javascript: ['.js', '.jsx', '.mjs', '.cjs', '.xsjs', '.xsjslib', '/index.js', '/index.jsx'],
tsx: ['.tsx', '.ts', '.d.ts', '.js', '.jsx', '/index.tsx', '/index.ts', '/index.js'],
tsx: ['.tsx', '.ts', '.d.ts', '.js', '.jsx', '/index.tsx', '/index.ts', '/index.js', '/index.jsx'],
jsx: ['.jsx', '.js', '/index.jsx', '/index.js'],
// SFC consumers import plain TS/JS, sibling components, and barrels
// (`./lib` → `./lib/index.ts`). Without a list, relative imports from a
@@ -366,9 +370,10 @@ function resolveImportPathUncached(
// Skip external/npm packages — but pass the context so the
// bare-specifier heuristic can consult the project's tsconfig
// alias map first (custom prefixes like `@components/*` would
// otherwise be misclassified as npm).
// otherwise be misclassified as npm). What still looks like a package
// may name a file through the importing app's own config.
if (isExternalImport(importPath, language, context)) {
return null;
return resolveFromOwnConfig(importPath, fromFile, language, context);
}
const projectRoot = context.getProjectRoot();
@@ -561,11 +566,13 @@ export function isExternalImport(
if (['fs', 'path', 'os', 'crypto', 'http', 'https', 'url', 'util', 'events', 'stream', 'child_process', 'buffer'].includes(importPath)) {
return true;
}
// Project-defined alias prefix? Treat as local.
// Project-defined alias prefix? Treat as local. A catch-all `"*"` has no
// prefix and matches `react` too: what it names is left to
// resolveFromOwnConfig, which keeps the app's packages out.
const aliases = options.aliasPrefixes === false ? null : context?.getProjectAliases?.();
if (aliases) {
for (const pat of aliases.patterns) {
if (importPath.startsWith(pat.prefix)) return false;
if (pat.prefix !== '' && importPath.startsWith(pat.prefix)) return false;
}
}
// Scoped packages or bare specifiers that don't start with aliases
@@ -727,15 +734,7 @@ function resolveAliasedImport(
context: ResolutionContext,
fromFile?: string
): string | null {
const extensions = EXTENSION_RESOLUTION[language] || [];
const tryWithExt = (basePath: string): string | null => {
for (const ext of extensions) {
const candidate = basePath + ext;
if (context.fileExists(candidate)) return candidate;
}
if (context.fileExists(basePath)) return basePath;
return findSourceForEmittedSpecifier(basePath, language, context);
};
const tryWithExt = (basePath: string): string | null => tryWithExtensions(basePath, language, context);
// 1. tsconfig/jsconfig paths: the config nearest the importing file (an
// app of a monorepo keeps its own `@/*`), then the project root's.
@@ -783,6 +782,84 @@ function resolveAliasedImport(
return tryWithExt(importPath);
}
/** The file `basePath` names: with the language's extensions or index files, as is, or as an emitted `.js`. */
function tryWithExtensions(basePath: string, language: Language, context: ResolutionContext): string | null {
for (const ext of EXTENSION_RESOLUTION[language] || []) {
const candidate = basePath + ext;
if (context.fileExists(candidate)) return candidate;
}
if (context.fileExists(basePath)) return basePath;
return findSourceForEmittedSpecifier(basePath, language, context);
}
/**
* A bare specifier `isExternalImport` takes for a package, resolved through
* the importing app's own tsconfig / jsconfig — the one nearest the file,
* else the root's — as tsc and a paths-aware bundler resolve it: the most
* specific `paths` pattern it matches (a catch-all `"*": ["./src/*"]`, an
* alias only a nested app declares), then `baseUrl` (`"baseUrl": "src"`
* makes `layout/MainLayout` read `src/layout/MainLayout`). CRA and Vite
* admin templates import nearly everything this way.
*
* Only an indexed file binds: `fileExists` also answers for a folder and
* for `node_modules`. A package — one the file's package.json or an
* enclosing one declares, or a Node built-in — never binds to a project
* module (`lodash` stays lodash even beside a `src/lodash/`), only to a
* declaration file the project keeps for it, which is the package as tsc
* reads it: `legacy-widgets` under `"*": ["./typings/*"]` is
* `typings/legacy-widgets.d.ts`.
*/
function resolveFromOwnConfig(
importPath: string,
fromFile: string,
language: Language,
context: ResolutionContext
): string | null {
const candidates = ownConfigCandidates(importPath, fromFile, language, context);
if (!candidates) return null;
const indexed = (file: string): boolean => context.isIndexedFile?.(file) !== false;
if (context.isDeclaredPackage?.(importPath, fromFile) !== false) {
if (!EXTENSION_RESOLUTION[language]?.includes('.d.ts')) return null;
for (const candidate of candidates) {
const declarations = `${candidate}.d.ts`;
if (context.fileExists(declarations) && indexed(declarations)) return declarations;
}
return null;
}
for (const candidate of candidates) {
const hit = tryWithExtensions(candidate, language, context);
if (hit && indexed(hit)) return hit;
}
return null;
}
/** Where the importing app's own config reads a bare specifier from (see resolveFromOwnConfig), or null when it reads none. */
function ownConfigCandidates(
importPath: string,
fromFile: string,
language: Language,
context: ResolutionContext
): string[] | null {
if (!ESM_IMPORT_LANGUAGES.has(language) || !importPath || importPath.startsWith('/') || /^[A-Za-z][\w+.-]*:/.test(importPath)) return null;
const config = context.getNearestAliases?.(fromFile) ?? context.getProjectAliases?.();
if (!config) return null;
const candidates = aliasAndBaseUrlCandidates(importPath, config, context.getProjectRoot());
return candidates.length > 0 ? candidates : null;
}
/**
* Whether a bare specifier names one of the project's files through the
* importing app's own config: its `paths` or `baseUrl` map it to a file the
* index holds, and no package the app declares goes by it
* (`layout/MainLayout` under `"baseUrl": "src"`). Not a name that maps to
* nothing yet: under `"baseUrl": "."` an undeclared `express` is far likelier
* a package the manifest leaves out than a module still to come.
*/
function namesOwnConfigModule(importPath: string, fromFile: string, language: Language, context: ResolutionContext): boolean {
return context.isDeclaredPackage?.(importPath, fromFile) === false &&
resolveImportPath(importPath, fromFile, language, context) !== null;
}
/**
* C/C++ include directory cache (keyed by project root).
* Loaded once per resolver instance, shared across calls.
@@ -1743,6 +1820,20 @@ export function isJsPathImportRef(ref: UnresolvedRef): boolean {
return ref.referenceKind === 'imports' && ESM_IMPORT_LANGUAGES.has(ref.language) && isJsPathSpecifier(ref.referenceName);
}
/**
* A JS/TS `imports` reference to the module of an import named by one bare
* segment — `'config'` in `import { DEFAULT_THEME_MODE } from 'config'` under
* `"baseUrl": "src"`. Shaped like a package, it names a file only when
* resolveImportPath finds one (the app's `baseUrl` or catch-all `paths`, a
* workspace member); a real package stays unresolved. A name some import
* binds locally is that binding, not the module, and resolves as one.
*/
function isJsBareModuleRef(ref: UnresolvedRef, context: ResolutionContext): boolean {
if (ref.referenceKind !== 'imports' || !ESM_IMPORT_LANGUAGES.has(ref.language) || isJsPathSpecifier(ref.referenceName)) return false;
const imports = context.getImportMappings(ref.filePath, ref.language);
return imports.some((m) => m.source === ref.referenceName) && !imports.some((m) => m.localName === ref.referenceName);
}
/** PHP reference kinds whose name is a class name, written as in the source. */
const PHP_CLASS_NAME_REFS: ReadonlySet<string> = new Set(['instantiates', 'extends', 'implements', 'references']);
@@ -1819,7 +1910,7 @@ export function resolveViaImport(
// found the way the runtime finds it: extensions, `index` files, path
// aliases. Matching the basename instead missed every extensionless one
// (the common spelling) and could land on a same-named file elsewhere.
if (isJsPathImportRef(ref)) {
if (isJsPathImportRef(ref) || isJsBareModuleRef(ref, context)) {
const file = resolveImportPath(ref.referenceName, ref.filePath, ref.language, context);
const fileNode = file && file !== ref.filePath ? context.getNodesInFile(file).find((n) => n.kind === 'file') : undefined;
if (fileNode) return { original: ref, targetNodeId: fileNode.id, confidence: 0.9, resolvedBy: 'import' };
@@ -3083,7 +3174,9 @@ function collectRustUseBindings(content: string): Map<string, string> {
* has no `src/ports/` directory to walk yet is entirely in-repo — that
* generalization measured 13 real trait implementations deleted.
* - **ES modules** — `isExternalImport`, which already accounts for tsconfig
* path aliases and monorepo workspace packages.
* path aliases and monorepo workspace packages, unless the specifier still
* names a project file through the importing app's `baseUrl` or catch-all
* `paths` (`import Base from 'components/Base'`).
*
* Everything else returns false and resolves exactly as before. JVM and Python
* imports notably do NOT go through `resolveImportPath` (they have dedicated
@@ -3111,7 +3204,8 @@ export function isBoundToOutOfRepoImport(
if (!ESM_IMPORT_LANGUAGES.has(ref.language)) return false;
for (const imp of context.getImportMappings(ref.filePath, ref.language)) {
if (imp.localName !== name) continue;
return isExternalImport(imp.source, ref.language, context);
return isExternalImport(imp.source, ref.language, context) &&
resolveImportPath(imp.source, ref.filePath, ref.language, context) === null;
}
return false;
}
@@ -3151,10 +3245,13 @@ export function isBoundToOutOfRepoImport(
* key a sync looks up for every file it adds or changes, so the file that
* appears or gains the export finds it.
*
* Only for a module of the project: a package's binding keeps its tail.
* Undefined for every other reference, and for the whole name of an
* unaliased named import, which is the name its module declares. A Python
* reference is {@link pythonImportBindingTail}'s, by its whole name only.
* Only for a module of the project: a package's binding keeps its tail. A
* bare specifier is the project's when the importing app's own config
* (`baseUrl`, a catch-all `"*"`) maps it to a file the index holds and no
* package the app declares goes by it. Undefined for every other reference,
* and for the whole name of an unaliased named import, which is the name its
* module declares. A Python reference is {@link pythonImportBindingTail}'s,
* by its whole name only.
*/
export function importBindingTail(ref: UnresolvedRef, context: ResolutionContext): string | undefined {
if (ref.language === 'python') return pythonImportBindingTail(ref, context);
@@ -3169,7 +3266,9 @@ export function importBindingTail(ref: UnresolvedRef, context: ResolutionContext
if (!binding || (!receiver && !binding.isDefault && !binding.isNamespace && binding.exportedName === binding.localName)) return undefined;
if (isExternalImport(binding.source, ref.language, context) &&
// A monorepo app's own tsconfig alias (`#/views/…`): isExternalImport reads the root's only.
!context.getNearestAliases?.(ref.filePath)?.patterns.some((p) => p.prefix !== '' && binding.source.startsWith(p.prefix))) {
!context.getNearestAliases?.(ref.filePath)?.patterns.some((p) => p.prefix !== '' && binding.source.startsWith(p.prefix)) &&
// A module the app's `baseUrl` or catch-all `paths` reads (`layout/MainLayout`).
!namesOwnConfigModule(binding.source, ref.filePath, ref.language, context)) {
return undefined;
}
// `..` names a folder through the importing file's own location.
+3 -1
View File
@@ -307,7 +307,7 @@ export class ReferenceResolver {
// `null` = computed and absent. Treated as immutable for the
// resolver's lifetime; callers re-create the resolver if config changes.
private projectAliases: AliasMap | null | undefined = undefined;
// Per directory: the aliases of the nearest non-root tsconfig declaring `paths`.
// Per directory: the aliases of the nearest non-root tsconfig declaring `paths` or `baseUrl`.
private dirAliases = new Map<string, AliasMap | null>();
// Per project-relative directory ('' = the root): the module of the nearest
// go.mod at or above it, up to the project root. Same lazy/immutable
@@ -692,6 +692,8 @@ export class ReferenceResolver {
if (resolved !== null && this.isIndexedFile(resolved)) return false;
return this.isDeclaredOutsidePackage(source, fromFile);
},
isDeclaredPackage: (source, fromFile) => this.isDeclaredOutsidePackage(source, fromFile),
isIndexedFile: (filePath) => this.isIndexedFile(filePath),
getNodesInFile: (filePath: string) => {
if (!this.nodeCache.has(filePath)) {
this.nodeCache.set(filePath, this.queries.getNodesByFile(filePath));
+51 -18
View File
@@ -13,7 +13,10 @@
*
* Scope:
* - reads tsconfig.json, then jsconfig.json, then tsconfig.base.json
* - honours `compilerOptions.baseUrl` and `compilerOptions.paths`
* - honours `compilerOptions.baseUrl` and `compilerOptions.paths`; a
* `baseUrl` with no `paths` still loads, because it alone makes
* `layout/MainLayout` name `src/layout/MainLayout` (CRA / Vite admin
* templates)
* - follows `extends` chains, nearest config wins (#1534) — Nx-style
* monorepos keep every alias in a `tsconfig.base.json` the root
* config merely inherits, so without this they resolved nothing
@@ -49,9 +52,17 @@ export interface AliasPattern {
export interface AliasMap {
/** Absolute path. The directory `compilerOptions.paths` is rooted at. */
baseUrl: string;
/**
* Whether the config (or one it extends) sets `compilerOptions.baseUrl`.
* A non-relative specifier is then also read from {@link baseUrl}, after
* `paths`, the way tsc reads it. `paths` alone anchors only its own
* targets (TypeScript 4.1+), so a map without a `baseUrl` never does.
*/
declaresBaseUrl?: boolean;
/**
* Patterns ordered by specificity: longer prefix first, then literal-
* before-wildcard, so the resolver tries the most-specific match.
* Empty for a config that sets only `baseUrl`.
*/
patterns: AliasPattern[];
}
@@ -245,7 +256,8 @@ function splitWildcard(pattern: string): {
/**
* Load aliases for `projectRoot`. Returns `null` when no tsconfig /
* jsconfig is present or when the file has no usable `paths`.
* jsconfig is present or when the file has neither usable `paths` nor a
* `baseUrl`.
*
* Cheap to call repeatedly — caching is the caller's job (the
* resolver does it via {@link aliasCache}).
@@ -283,25 +295,24 @@ export function loadProjectAliases(projectRoot: string): AliasMap | null {
// declared them — which is the project root only when that config is the
// root one (the pre-`extends` assumption).
const baseUrl = effective.baseUrl ?? effective.pathsDir ?? projectRoot;
const paths = effective.paths;
if (!paths || typeof paths !== 'object') {
// baseUrl alone isn't an "alias" per se; with no paths we'd just
// be redirecting the whole tree. Skip — the existing resolver
// already handles relative imports.
return null;
}
const declaresBaseUrl = effective.baseUrl !== undefined;
const patterns: AliasPattern[] = [];
for (const [pattern, targets] of Object.entries(paths)) {
if (!Array.isArray(targets) || targets.length === 0) continue;
const filtered = targets.filter((t): t is string => typeof t === 'string');
if (filtered.length === 0) continue;
const { prefix, suffix, hasWildcard } = splitWildcard(pattern);
patterns.push({ prefix, suffix, hasWildcard, replacements: filtered });
const paths = effective.paths;
if (paths && typeof paths === 'object') {
for (const [pattern, targets] of Object.entries(paths)) {
if (!Array.isArray(targets) || targets.length === 0) continue;
const filtered = targets.filter((t): t is string => typeof t === 'string');
if (filtered.length === 0) continue;
const { prefix, suffix, hasWildcard } = splitWildcard(pattern);
patterns.push({ prefix, suffix, hasWildcard, replacements: filtered });
}
}
if (patterns.length === 0) return null;
// A `baseUrl` with no `paths` is still a map: the resolver reads a bare
// specifier from it, but only one that names a project file and no package
// the app depends on (see resolveFromOwnConfig in import-resolver.ts).
if (patterns.length === 0 && !declaresBaseUrl) return null;
// Specificity sort: longer prefix first; literal patterns before
// wildcard patterns of the same prefix length. TypeScript itself
@@ -315,10 +326,11 @@ export function loadProjectAliases(projectRoot: string): AliasMap | null {
logDebug('path-aliases loaded', {
file: usedFile,
baseUrl,
declaresBaseUrl,
patternCount: patterns.length,
});
return { baseUrl, patterns };
return { baseUrl, declaresBaseUrl, patterns };
}
/**
@@ -362,3 +374,24 @@ export function applyAliases(
}
return [];
}
/**
* Where a non-relative specifier can live under `aliases`, in the order tsc
* tries them: the targets of the most specific pattern it matches, then the
* specifier read from `baseUrl` when the config declares one. Relative to
* `projectRoot`; a candidate that escapes it is dropped.
*/
export function aliasAndBaseUrlCandidates(
importPath: string,
aliases: AliasMap,
projectRoot: string
): string[] {
const out = applyAliases(importPath, aliases, projectRoot);
if (aliases.declaresBaseUrl) {
const relative = path.relative(projectRoot, path.resolve(aliases.baseUrl, importPath));
if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) {
out.push(relative.replace(/\\/g, '/'));
}
}
return out;
}
+19 -3
View File
@@ -185,8 +185,23 @@ export interface ResolutionContext {
*/
isOutOfRepoImport?(source: string, fromFile: string, language: Language): boolean;
/**
* Project import-path aliases (tsconfig/jsconfig `paths`). Returns
* `null` when the project doesn't define any. Cached per resolver
* Whether `source`, imported from `fromFile`, is a package: one the file's
* package.json or an enclosing one declares, a Node built-in, or a
* runtime's virtual module (`node:`, `bun:`, `astro:`…). Such an import
* never binds to a same-named project module, only to a declaration file
* the project keeps for the package. Supplied by the coordinator.
*/
isDeclaredPackage?(source: string, fromFile: string): boolean;
/**
* Whether `filePath` (project-relative) is one of the files the index holds.
* `fileExists` also answers for a directory and for a file on disk the
* index never read, such as one under `node_modules`. Supplied by the
* coordinator; a minimal context can omit it.
*/
isIndexedFile?(filePath: string): boolean;
/**
* Project import-path aliases (tsconfig/jsconfig `paths`, and `baseUrl`).
* Returns `null` when the project doesn't define any. Cached per resolver
* instance — safe to call from any resolver code path. Optional so
* existing test fixtures and external context implementations
* compile without modification; production resolver implements it.
@@ -194,7 +209,8 @@ export interface ResolutionContext {
getProjectAliases?(): import('./path-aliases').AliasMap | null;
/**
* The aliases of the tsconfig / jsconfig nearest `fromFile` below the
* project root that declares `paths` — a monorepo app's own `@/*` — or null.
* project root that declares `paths` or `baseUrl` — a monorepo app's own
* `@/*`, a nested Vite app's `"baseUrl": "src"` — or null.
*/
getNearestAliases?(fromFile: string): import('./path-aliases').AliasMap | null;
/**