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
co-authored by Claude Opus 5.5
parent d3a778d0b9
commit de3eb71df6
8 changed files with 722 additions and 48 deletions
+1
View File
@@ -169,6 +169,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In TypeScript and JavaScript, a call on a value another file exports, like `store.add(…)` after `import { store } from './state'` with `export const store = new Store()` there, now links to the method of the class that file imports or declares when other classes in the project share its name, in Vue, Svelte and Astro components too. Before, it linked to whichever of them was indexed first: in vscode, calls on `FileAccess`, `extUri`, `errorHandler` and `MenuRegistry` reached the copies the Copilot extension vendors or one of its test fixtures, and in folo the mobile app's `settingSyncQueue.init()` reached the desktop app's `SettingSyncQueue`. Re-index TypeScript and JavaScript projects after upgrading.
- In TypeScript, a call through a field that a wrapper sets up, like `this._onDidChange.fire()` after `private readonly _onDidChange = this._register(new Emitter<void>())` or `= this._store.add(new Emitter<void>())`, now links to the method of the class handed to the wrapper, also in a class that inherits the field. Before, such a field had no type, so its calls linked nowhere, and that is one of the most common ways vscode declares its event emitters and disposables. A wrapper counts only when its own declaration hands back what it is given, like `_register<T>(o: T): T`, so a field set from a wrapper that returns something else, or from a member of what it returns (`.event`), is never taken for the class. A call through an inherited field is also found now when the field is declared further up the class hierarchy than before. Re-index TypeScript projects after upgrading.
- In TypeScript and JavaScript, a class whose declaration constrains a type parameter, like `class EditorPane<MementoType extends object = object> extends Composite<MementoType>`, is now read as extending the class after its type parameters. Before, the constraint's type was taken for its base class, so fields, wrapped event emitters and static methods inherited through such a class were missed or found on the wrong class. Class declarations that span several lines or sit below a decorator are read too. Re-index TypeScript and JavaScript projects after upgrading.
- In JavaScript and TypeScript, an import written from a jsconfig or tsconfig `baseUrl`, like `import MainLayout from 'layout/MainLayout'` under `"baseUrl": "src"`, or through a catch-all `"paths": { "*": ["./src/*"] }`, now links to the file it names, read with the config of the app the importing file belongs to, and so does an import of a single name like `'config'` or `'themes'`. Create React App and Vite admin templates such as Berry, Mantis and MatX write nearly every import this way. Before, each one was taken for an npm package, so it linked nothing, its names were matched by name alone, which could reach a same-named component in another app of the same repository, and a React Router route whose page loads through `lazy(() => import('views/dashboard/Default'))` pointed at that loader instead of the page and its layout. A package the app's `package.json` declares, or a Node built-in, never links to a project module of the same name, only to type declarations the project keeps for it, and only a file the index holds counts, never a folder or something in `node_modules`. `codegraph sync` and the file watcher link such an import too when the file it names is added later or gains what it imports. Re-index JavaScript and TypeScript projects after upgrading.
## [1.6.2] - 2026-10-03
+447
View File
@@ -0,0 +1,447 @@
/**
* Bare specifiers that name a project file through the importing app's own
* tsconfig / jsconfig: a `baseUrl` with no `paths` (berry, matx) or a
* catch-all `"*": ["./src/*"]` (mantis). CRA and Vite admin templates write
* nearly every cross-file import this way (`import MainLayout from
* 'layout/MainLayout'`), and each one used to be taken for an npm package:
* the import stayed unresolved and its names linked by bare-name matching,
* often into another app of the same repository.
*
* Locked in:
* - the importing app's own config decides (two apps, two baseUrls)
* - only an indexed file binds; a specifier that names none stays unresolved
* - a package the app's package.json declares, or a Node built-in, never
* binds to a same-named project folder or file, also under a root `"*"`
* - a route's `lazy(() => import('views/x'))` renders `views/x/index.jsx`,
* and the layout around it links through the same kind of import
* - a class extending one imported this way keeps its `extends` edge
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { resolveImportPath } from '../src/resolution/import-resolver';
import type { AliasMap } from '../src/resolution/path-aliases';
import type { ResolutionContext } from '../src/resolution';
const ROOT = path.join(os.tmpdir(), 'cg-baseurl-stub');
function stubContext(files: string[], opts: {
nearest?: (fromFile: string) => AliasMap | null;
project?: AliasMap | null;
declared?: string[];
noPackageCheck?: boolean;
}): ResolutionContext {
const set = new Set(files);
return {
getNodesInFile: () => [],
getNodesByName: () => [],
getNodesByQualifiedName: () => [],
getNodesByKind: () => [],
fileExists: (p: string) => set.has(p),
readFile: () => null,
getProjectRoot: () => ROOT,
getAllFiles: () => files,
getImportMappings: () => [],
getNearestAliases: opts.nearest ?? (() => null),
getProjectAliases: () => opts.project ?? null,
...(opts.noPackageCheck ? {} : { isDeclaredPackage: (source: string) => (opts.declared ?? []).includes(source.split('/')[0]!) }),
} as unknown as ResolutionContext;
}
const baseUrlOnly = (dir: string): AliasMap => ({ baseUrl: path.join(ROOT, dir), declaresBaseUrl: true, patterns: [] });
describe('resolving a bare specifier through the app’s own config', () => {
it('reads a nested app’s baseUrl, down to an index.jsx (berry)', () => {
const files = ['vite/src/routes/MainRoutes.jsx', 'vite/src/layout/MainLayout/index.jsx', 'vite/src/views/dashboard/Default/index.jsx'];
const ctx = stubContext(files, { nearest: (f) => (f.startsWith('vite/') ? baseUrlOnly('vite/src') : null) });
expect(resolveImportPath('layout/MainLayout', 'vite/src/routes/MainRoutes.jsx', 'jsx', ctx)).toBe('vite/src/layout/MainLayout/index.jsx');
// A route table resolves a lazily loaded module as TypeScript, whatever it is written in.
expect(resolveImportPath('views/dashboard/Default', 'vite/src/routes/MainRoutes.jsx', 'typescript', ctx)).toBe('vite/src/views/dashboard/Default/index.jsx');
});
it('reads a catch-all "*" from the config’s directory (mantis)', () => {
const star: AliasMap = {
baseUrl: path.join(ROOT, 'vite'),
declaresBaseUrl: false,
patterns: [{ prefix: '', suffix: '', hasWildcard: true, replacements: ['./src/*'] }],
};
const ctx = stubContext(['vite/src/routes/LoginRoutes.jsx', 'vite/src/pages/auth/Register.jsx'], { nearest: () => star });
expect(resolveImportPath('pages/auth/Register', 'vite/src/routes/LoginRoutes.jsx', 'jsx', ctx)).toBe('vite/src/pages/auth/Register.jsx');
});
it('reads the root jsconfig when no nearer config has one (matx)', () => {
const ctx = stubContext(['src/app/routes.jsx', 'src/app/views/dashboard/Analytics.jsx'], { project: baseUrlOnly('src') });
expect(resolveImportPath('app/views/dashboard/Analytics', 'src/app/routes.jsx', 'jsx', ctx)).toBe('src/app/views/dashboard/Analytics.jsx');
});
it('uses the importing app’s baseUrl, not another app’s', () => {
const files = ['vite/src/views/Page.jsx', 'vite/src/utils/format.js', 'remix/app/routes/index.jsx', 'remix/app/utils/format.js'];
const ctx = stubContext(files, {
nearest: (f) => (f.startsWith('vite/') ? baseUrlOnly('vite/src') : f.startsWith('remix/') ? baseUrlOnly('remix/app') : null),
});
expect(resolveImportPath('utils/format', 'vite/src/views/Page.jsx', 'jsx', ctx)).toBe('vite/src/utils/format.js');
expect(resolveImportPath('utils/format', 'remix/app/routes/index.jsx', 'jsx', ctx)).toBe('remix/app/utils/format.js');
});
it('never binds a declared package to a same-named project folder', () => {
const files = ['src/hooks/useSearch.js', 'src/lodash/index.js'];
expect(resolveImportPath('lodash', 'src/hooks/useSearch.js', 'javascript', stubContext(files, { project: baseUrlOnly('src'), declared: ['lodash'] }))).toBeNull();
// Only the declaration keeps it out: the same folder is the project's when nothing declares `lodash`.
expect(resolveImportPath('lodash', 'src/hooks/useSearch.js', 'javascript', stubContext(files, { project: baseUrlOnly('src') }))).toBe('src/lodash/index.js');
// A context that cannot tell packages apart resolves nothing this way.
expect(resolveImportPath('lodash', 'src/hooks/useSearch.js', 'javascript', stubContext(files, { project: baseUrlOnly('src'), noPackageCheck: true }))).toBeNull();
});
it('lets a declared package reach a declaration file the project keeps for it, never a module', () => {
// cord-field's shape: `"*": ["./typings/*"]` holds types for untyped packages.
const typings: AliasMap = {
baseUrl: path.join(ROOT, 'src'),
declaresBaseUrl: true,
patterns: [{ prefix: '', suffix: '', hasWildcard: true, replacements: ['./typings/*'] }],
};
const files = ['src/scenes/Page.tsx', 'src/typings/legacy-widgets.d.ts', 'src/axios/index.ts'];
const ctx = stubContext(files, { project: typings, declared: ['legacy-widgets', 'axios'] });
expect(resolveImportPath('legacy-widgets', 'src/scenes/Page.tsx', 'tsx', ctx)).toBe('src/typings/legacy-widgets.d.ts');
// `baseUrl` reads `src/axios/index.ts`, the app's own wrapper: the package stays the package.
expect(resolveImportPath('axios', 'src/scenes/Page.tsx', 'tsx', ctx)).toBeNull();
});
it('leaves a specifier that names no project file unresolved', () => {
const ctx = stubContext(['src/App.jsx'], { project: baseUrlOnly('src') });
expect(resolveImportPath('react', 'src/App.jsx', 'jsx', ctx)).toBeNull();
expect(resolveImportPath('virtual:pwa-register', 'src/App.jsx', 'jsx', ctx)).toBeNull();
});
it('binds only a file the index holds, not a folder or node_modules on disk', () => {
// `fileExists` falls back to the disk: true for `src/assets` (a folder) and
// for a package installed under a `"*"` target like `node_modules/*`.
const onDisk = new Set(['src/App.jsx', 'src/assets', 'node_modules/history/index.js']);
const indexed = new Set(['src/App.jsx']);
const star: AliasMap = {
baseUrl: ROOT,
declaresBaseUrl: true,
patterns: [{ prefix: '', suffix: '', hasWildcard: true, replacements: ['src/*', 'node_modules/*'] }],
};
const ctx = {
...stubContext([...indexed], { project: star }),
fileExists: (p: string) => onDisk.has(p),
isIndexedFile: (p: string) => indexed.has(p),
} as unknown as ResolutionContext;
expect(resolveImportPath('assets', 'src/App.jsx', 'jsx', ctx)).toBeNull();
expect(resolveImportPath('history', 'src/App.jsx', 'jsx', ctx)).toBeNull();
});
it('does not read bare specifiers from a config with paths but no baseUrl', () => {
const atOnly: AliasMap = {
baseUrl: ROOT,
declaresBaseUrl: false,
patterns: [{ prefix: '@/', suffix: '', hasWildcard: true, replacements: ['./src/*'] }],
};
const ctx = stubContext(['src/App.jsx', 'components/Button.jsx', 'src/components/Button.jsx'], { project: atOnly });
expect(resolveImportPath('components/Button', 'src/App.jsx', 'jsx', ctx)).toBeNull();
expect(resolveImportPath('@/components/Button', 'src/App.jsx', 'jsx', ctx)).toBe('src/components/Button.jsx');
});
});
function writeTree(root: string, files: Record<string, string>): void {
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
}
describe('two admin apps with their own baseUrl (berry shape)', () => {
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-baseurl-apps-'));
writeTree(root, {
'package.json': JSON.stringify({ name: 'admin-templates', private: true }),
'vite/package.json': JSON.stringify({ name: 'vite-app', dependencies: { react: '^18', 'react-router-dom': '^6', lodash: '^4' } }),
'vite/jsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: 'src', jsx: 'react-jsx' } }),
'vite/src/routes/index.jsx': `import { createBrowserRouter } from 'react-router-dom';
import MainRoutes from './MainRoutes';
const router = createBrowserRouter([MainRoutes]);
export default router;
`,
'vite/src/routes/MainRoutes.jsx': `import { lazy } from 'react';
import MainLayout from 'layout/MainLayout';
import Loadable from 'ui-component/Loadable';
const DashboardDefault = Loadable(lazy(() => import('views/dashboard/Default')));
const MainRoutes = {
path: '/',
element: <MainLayout />,
children: [
{
path: 'dashboard',
element: <DashboardDefault />
}
]
};
export default MainRoutes;
`,
'vite/src/layout/MainLayout/index.jsx': `export default function MainLayout() {
return <main />;
}
`,
'vite/src/ui-component/Loadable.jsx': `export default function Loadable(Component) {
return (props) => <Component {...props} />;
}
`,
'vite/src/views/dashboard/Default/index.jsx': `import { formatDate } from 'utils/format';
export default function Dashboard() {
return <section>{formatDate(new Date())}</section>;
}
`,
'vite/src/utils/format.js': `export function formatDate(d) {
return String(d);
}
`,
// A one-segment specifier, and a binding named like another module.
'vite/src/config.js': `export const DEFAULT_THEME_MODE = 'system';
export const themes = ['light', 'dark'];
`,
'vite/src/themes/index.jsx': `export default function ThemeCustomization({ children }) {
return children;
}
`,
'vite/src/layout/Customization/index.jsx': `import { DEFAULT_THEME_MODE, themes } from 'config';
export default function Customization() {
return themes.includes(DEFAULT_THEME_MODE);
}
`,
// A folder named like a dependency, and a file named like a Node built-in.
'vite/src/lodash/index.js': `export function debounce(fn) {
return fn;
}
`,
'vite/src/events.js': `export class EventEmitter {}
`,
'vite/src/hooks/useSearch.js': `import { debounce } from 'lodash';
import { EventEmitter } from 'events';
export function useSearch() {
return debounce(() => new EventEmitter());
}
`,
'vite/src/components/BasePanel.js': `export default class BasePanel {}
`,
'vite/src/components/Panel.js': `import BasePanel from 'components/BasePanel';
export class Panel extends BasePanel {}
`,
'remix/package.json': JSON.stringify({ name: 'remix-app', dependencies: { react: '^18' } }),
'remix/jsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: 'app' } }),
'remix/app/utils/format.js': `export function formatDate(d) {
return d.toISOString();
}
`,
'remix/app/routes/index.jsx': `import { formatDate } from 'utils/format';
export default function Index() {
return formatDate(new Date());
}
`,
});
cg = await CodeGraph.init(root, { index: true });
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true, maxRetries: 5 });
});
/** The files `file`'s nodes import, by path. */
const importedFiles = (file: string): string[] =>
cg.getOutgoingEdgesFrom(cg.getNodesInFile(file).map((n) => n.id))
.filter((e) => e.kind === 'imports')
.map((e) => cg.getNode(e.target)!.filePath);
/** What `file`'s import of `ref` (a module specifier or a binding) links to, as `kind:name@path`. */
const importTargets = (file: string, ref: string): string[] =>
cg.getOutgoingEdgesFrom(cg.getNodesInFile(file).map((n) => n.id))
.filter((e) => e.kind === 'imports' && (e.metadata as Record<string, unknown> | undefined)?.refName === ref)
.map((e) => `${cg.getNode(e.target)!.kind}:${cg.getNode(e.target)!.name}@${cg.getNode(e.target)!.filePath}`);
const routeLinks = (name: string, layout: boolean): string[] => {
const route = cg.getNodesByKind('route').find((r) => r.name === name);
if (!route) return [];
return cg.getOutgoingEdges(route.id)
.filter((e) => e.kind === 'references' && Boolean((e.metadata as Record<string, unknown> | undefined)?.layout) === layout)
.map((e) => `${cg.getNode(e.target)!.name}@${cg.getNode(e.target)!.filePath}`);
};
it('links an import written from baseUrl to the file it names', () => {
const file = 'vite/src/routes/MainRoutes.jsx';
expect(importTargets(file, 'layout/MainLayout')).toEqual(['file:index.jsx@vite/src/layout/MainLayout/index.jsx']);
expect(importTargets(file, 'ui-component/Loadable')).toEqual(['file:Loadable.jsx@vite/src/ui-component/Loadable.jsx']);
});
it('links a one-segment module, and reads a binding named like another module as the binding', () => {
const file = 'vite/src/layout/Customization/index.jsx';
expect(importTargets(file, 'config')).toEqual(['file:config.js@vite/src/config.js']);
expect(importTargets(file, 'themes')).toEqual(['constant:themes@vite/src/config.js']);
expect(importedFiles(file)).not.toContain('vite/src/themes/index.jsx');
});
it('calls the importing app’s own function, not the other app’s namesake', () => {
const callees = (fn: string, file: string) => {
const node = cg.getNodesInFile(file).find((n) => n.name === fn)!;
return cg.getCallees(node.id).map((c) => c.node.filePath);
};
expect(callees('Dashboard', 'vite/src/views/dashboard/Default/index.jsx')).toContain('vite/src/utils/format.js');
expect(callees('Dashboard', 'vite/src/views/dashboard/Default/index.jsx')).not.toContain('remix/app/utils/format.js');
expect(callees('Index', 'remix/app/routes/index.jsx')).toContain('remix/app/utils/format.js');
expect(callees('Index', 'remix/app/routes/index.jsx')).not.toContain('vite/src/utils/format.js');
});
it('renders a lazily loaded page and the layout around it', () => {
expect(routeLinks('/dashboard', false)).toEqual(['Dashboard@vite/src/views/dashboard/Default/index.jsx']);
expect(routeLinks('/dashboard', true)).toEqual(['MainLayout@vite/src/layout/MainLayout/index.jsx']);
});
it('keeps a declared package and a Node built-in out of same-named project files', () => {
const imported = importedFiles('vite/src/hooks/useSearch.js');
expect(imported).not.toContain('vite/src/lodash/index.js');
expect(imported).not.toContain('vite/src/events.js');
});
it('keeps the extends edge of a class whose base is imported from baseUrl', () => {
const panel = cg.getNodesInFile('vite/src/components/Panel.js').find((n) => n.name === 'Panel')!;
const supers = cg.getOutgoingEdges(panel.id).filter((e) => e.kind === 'extends').map((e) => cg.getNode(e.target)!.filePath);
expect(supers).toEqual(['vite/src/components/BasePanel.js']);
});
});
describe('a root catch-all "*" beside a folder named like a dependency', () => {
let root = '';
let cg: CodeGraph;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-baseurl-star-'));
writeTree(root, {
'package.json': JSON.stringify({ name: 'star-app', dependencies: { axios: '^1', react: '^18' } }),
'jsconfig.json': JSON.stringify({ compilerOptions: { paths: { '*': ['./src/*'] } } }),
// The app's own configured client, in a folder named after the package it wraps.
'src/axios/index.js': `import axios from 'axios';
const instance = axios.create({ baseURL: '/api' });
export default instance;
`,
'src/components/Button.jsx': `export default function Button() {
return <button />;
}
`,
'src/App.jsx': `import axios from 'axios';
import Button from 'components/Button';
export default function App() {
axios.get('/users');
return <Button />;
}
`,
});
cg = await CodeGraph.init(root, { index: true });
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true, maxRetries: 5 });
});
it('resolves the project’s own module and leaves the dependency alone', () => {
const ids = cg.getNodesInFile('src/App.jsx').map((n) => n.id);
const targets = cg.getOutgoingEdgesFrom(ids).map((e) => cg.getNode(e.target)!.filePath);
expect(targets).toContain('src/components/Button.jsx');
expect(targets).not.toContain('src/axios/index.js');
});
});
/** What a sync must link once a module imported from `baseUrl` appears after its importers. */
const SYNC_IMPORTERS: Record<string, string> = {
'package.json': JSON.stringify({ name: 'late-app', dependencies: { react: '^18' } }),
'jsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: 'src' } }),
// Bindings their modules never declare by that name: a default import, an
// aliased import, a member of a named import, and a one-segment module.
'src/call.js': "import runIt from 'handlers/run';\nexport function go() { return runIt(); }\n",
'src/draw.js': "import { Widget as W } from 'widgets/Widget';\nexport function paint() { return new W(); }\n",
'src/read.js': "import { store } from 'state/store';\nexport function read(k) { return store.get(k); }\n",
'src/use-config.js': "import config from 'config';\nexport const port = config.port;\n",
};
const SYNC_MODULES: Record<string, string> = {
'src/handlers/run.js': 'export default function handle() { return 1; }\n',
'src/widgets/Widget.js': 'export class Widget {}\n',
'src/state/store.js': 'export const store = { get(k) { return k; } };\n',
'src/config.js': 'const config = { port: 3000 };\nexport default config;\n',
};
describe('a sync links an import read from baseUrl once its module appears', () => {
const roots: string[] = [];
const graphs: CodeGraph[] = [];
afterAll(() => {
for (const graph of graphs) graph.close();
for (const dir of roots) fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5 });
});
const indexed = async (files: Record<string, string>): Promise<{ root: string; cg: CodeGraph }> => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-baseurl-sync-'));
roots.push(root);
writeTree(root, files);
const cg = await CodeGraph.init(root, { index: true });
graphs.push(cg);
return { root, cg };
};
/** Every edge by its ends' natural keys, where it is written and how it resolved. */
const edgesOf = (cg: CodeGraph): string[] => {
const keys = new Map(
cg.getFiles()
.flatMap((file) => cg.getNodesInFile(file.path))
.map((n) => [n.id, `${n.kind} ${n.filePath}:${n.qualifiedName}:${n.startLine}`] as const)
);
return cg.getOutgoingEdgesFrom([...keys.keys()])
.map((e) => `${e.kind} ${keys.get(e.source)} -> ${keys.get(e.target) ?? e.target} @${e.line}:${e.column} ${JSON.stringify(e.metadata ?? {})}`)
.sort();
};
it('matches a fresh index of the same files', async () => {
const { root, cg } = await indexed(SYNC_IMPORTERS);
writeTree(root, SYNC_MODULES);
const result = await cg.sync();
expect(result.filesAdded).toBe(Object.keys(SYNC_MODULES).length);
expect(result.filesModified).toBe(0);
const fresh = await indexed({ ...SYNC_IMPORTERS, ...SYNC_MODULES });
const freshEdges = edgesOf(fresh.cg);
// The fresh index links every binding through its module.
expect(freshEdges.some((e) => e.startsWith('calls function src/call.js:go') && e.includes('-> function src/handlers/run.js:handle'))).toBe(true);
expect(freshEdges.some((e) => e.startsWith('instantiates function src/draw.js:paint') && e.includes('-> class src/widgets/Widget.js:Widget'))).toBe(true);
expect(freshEdges.some((e) => e.includes('src/use-config.js') && e.includes('-> file src/config.js'))).toBe(true);
expect(edgesOf(cg)).toEqual(freshEdges);
}, 120_000);
it('matches a fresh index when an edit gives the module what its importers import', async () => {
const stubs = Object.fromEntries(Object.keys(SYNC_MODULES).map((file) => [file, 'export const placeholder = 1;\n']));
const { root, cg } = await indexed({ ...SYNC_IMPORTERS, ...stubs });
writeTree(root, SYNC_MODULES);
const result = await cg.sync();
expect(result.filesModified).toBe(Object.keys(SYNC_MODULES).length);
expect(result.filesAdded).toBe(0);
const fresh = await indexed({ ...SYNC_IMPORTERS, ...SYNC_MODULES });
expect(edgesOf(cg)).toEqual(edgesOf(fresh.cg));
}, 120_000);
});
+63 -1
View File
@@ -18,12 +18,14 @@
* - the nearest config wins: a child's own `paths` replaces (not merges with)
* the parent's, which is what `tsc` does
* - a cyclic `extends` terminates instead of blowing the stack
* - a `baseUrl` with no `paths` still loads, and only a declared `baseUrl`
* reads a bare specifier from its directory
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { loadProjectAliases, applyAliases } from '../src/resolution/path-aliases';
import { loadProjectAliases, applyAliases, aliasAndBaseUrlCandidates } from '../src/resolution/path-aliases';
function write(file: string, content: unknown): void {
fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -180,3 +182,63 @@ describe('tsconfig `extends` chains (#1534)', () => {
expect(loadProjectAliases(root)).toBeNull();
});
});
describe('a baseUrl with no paths', () => {
let root: string;
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-tsbaseurl-'));
});
afterEach(() => {
fs.rmSync(root, { recursive: true, force: true });
});
it('loads a jsconfig that sets only baseUrl (CRA / Vite admin templates)', () => {
write(path.join(root, 'jsconfig.json'), { compilerOptions: { baseUrl: 'src' } });
const aliases = loadProjectAliases(root);
expect(aliases).not.toBeNull();
expect(aliases!.patterns).toEqual([]);
expect(aliases!.declaresBaseUrl).toBe(true);
expect(aliasAndBaseUrlCandidates('layout/MainLayout', aliases!, root)).toEqual(['src/layout/MainLayout']);
});
it('takes a baseUrl inherited through extends, relative to the file that set it', () => {
write(path.join(root, 'config/tsconfig.base.json'), { compilerOptions: { baseUrl: '../src' } });
write(path.join(root, 'tsconfig.json'), { extends: './config/tsconfig.base.json' });
const aliases = loadProjectAliases(root);
expect(aliases?.declaresBaseUrl).toBe(true);
expect(aliasAndBaseUrlCandidates('utils/format', aliases!, root)).toEqual(['src/utils/format']);
});
it('tries the most specific paths pattern before baseUrl', () => {
write(path.join(root, 'tsconfig.json'), {
compilerOptions: { baseUrl: '.', paths: { 'shared/*': ['libs/shared/src/*'] } },
});
const aliases = loadProjectAliases(root);
expect(aliasAndBaseUrlCandidates('shared/date', aliases!, root)).toEqual(['libs/shared/src/date', 'shared/date']);
expect(aliasAndBaseUrlCandidates('components/Button', aliases!, root)).toEqual(['components/Button']);
});
it('reads a catch-all "*" pattern without a baseUrl, but nothing else from the config directory', () => {
// mantis-free-react-admin-template's vite/jsconfig.json: `paths` alone,
// anchored at the config's own directory (TypeScript 4.1+).
write(path.join(root, 'jsconfig.json'), { compilerOptions: { paths: { '*': ['./src/*'] } } });
const aliases = loadProjectAliases(root);
expect(aliases?.declaresBaseUrl).toBe(false);
expect(aliasAndBaseUrlCandidates('pages/auth/Register', aliases!, root)).toEqual(['src/pages/auth/Register']);
write(path.join(root, 'jsconfig.json'), { compilerOptions: { paths: { '@/*': ['./src/*'] } } });
expect(aliasAndBaseUrlCandidates('pages/auth/Register', loadProjectAliases(root)!, root)).toEqual([]);
});
it('drops a candidate that leaves the project', () => {
write(path.join(root, 'jsconfig.json'), { compilerOptions: { baseUrl: 'src' } });
expect(aliasAndBaseUrlCandidates('../../outside/x', loadProjectAliases(root)!, root)).toEqual([]);
});
});
File diff suppressed because one or more lines are too long
+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;
/**