diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ce847c4..3e963792 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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())` or `= this._store.add(new Emitter())`, 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(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 extends Composite`, 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 diff --git a/__tests__/tsconfig-baseurl-imports.test.ts b/__tests__/tsconfig-baseurl-imports.test.ts new file mode 100644 index 00000000..47e2ebad --- /dev/null +++ b/__tests__/tsconfig-baseurl-imports.test.ts @@ -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): 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: , + children: [ + { + path: 'dashboard', + element: + } + ] +}; + +export default MainRoutes; +`, + 'vite/src/layout/MainLayout/index.jsx': `export default function MainLayout() { + return
; +} +`, + 'vite/src/ui-component/Loadable.jsx': `export default function Loadable(Component) { + return (props) => ; +} +`, + 'vite/src/views/dashboard/Default/index.jsx': `import { formatDate } from 'utils/format'; + +export default function Dashboard() { + return
{formatDate(new Date())}
; +} +`, + '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 | 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 | 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