diff --git a/CHANGELOG.md b/CHANGELOG.md index 25296bd4..690677cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -299,6 +299,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In React, a component declared through a wrapper with no render function of its own, like styled-components' `styled(ActionButton)` (with `.attrs(…)`, `.withConfig(…)` or type arguments too), emotion's and MUI's `styled(Box)(…)`, `memo(Button)` or `forwardRef(Render)`, now links to the component it wraps, so a render flow in `codegraph_explore`, a component's callers and its impact go on through the wrapper instead of stopping at it. The calls and values in a wrapper's styles, like `${(props) => theme.color(props)}` or a shared `css` block, now belong to the wrapper, as they do to any other constant, so a theme helper used only in styles has callers. A component that wraps its render function in `forwardRef(…)` or `memo(…)` now shows in the impact of the top-level constants it reads, as a function component does. A wrapper around a component that comes from a package, like react-router's `Link` or antd's `Typography.Text`, links to nothing, and the file that declares a wrapper no longer shows as referencing the component it wraps. Re-index React projects after upgrading. - In TypeScript, a type written with a qualifier, like `React.ReactNode`, `Y.Doc`, `TChat.ChatMessage` or `EQueryType.CLICKHOUSE`, now links to the member of what the qualifier names: a type from a package, like React's, no longer links to a class or component of your own that shares its name, and a member of an enum or a namespace your project declares links to that member instead of to another enum's or a same-named component. Re-index TypeScript projects after upgrading. - In JavaScript and TypeScript, an import written from the app's root with a leading slash, like bigbluebutton's `import Icon from '/imports/ui/components/common/icon/component'`, now links to the file it names when the app's `tsconfig.json` or `jsconfig.json` maps it, as `"paths": { "/*": ["*"] }` does. Before, such an import was taken for a package, so the module looked unused and every name it imports linked by name instead, often to the wrong one of several components of the same name, or to nothing. Re-index projects that import this way after upgrading. +- In JavaScript and TypeScript, an import spelled with a trailing slash, a `./` or `../` segment or a doubled slash, like `import('@/widgets/Chart/')`, `from 'src/widgets/./Chart'` or a workspace package's `from '@acme/ui/'`, now links like the plain spelling: to the file it names, and a lazily loaded component to the component that file exports. Before, the import was dropped, its names were matched by name alone, and the lazily loaded component pointed at the importing file's own `lazy(…)` constant. A trailing slash still names a folder: `@/widgets/Chart/` is `Chart/index.tsx`, never a `Chart.tsx` beside it. Thanks @inth3shadows for the report and @JiaMingWang-CN. (#2577) - In JavaScript and TypeScript, a call whose callee is not a name, like an immediately invoked function `(() => { … })()`, a curried call such as `connect(mapState)(View)`, `handlers[type]()` or `(0, fn)()`, no longer stores the callee's whole source text as the name of a reference that nothing in the project can answer. A single immediately invoked function could leave its entire body in the index that way. The calls inside such a callee, and the inner call of a curried one, are still recorded. A call through parentheses, a cast or a non-null assertion, like `(fn)()`, `(fn as F)()` or `fn!()`, now links to `fn` exactly as `fn()` does, so a parameter named `fn` no longer links to a same-named property elsewhere in the project. In TypeScript, a call with type arguments behind an operator, like `!settings.get('X')` or `void apiRequest()`, now links to what it calls, as `await load()` already did. - In JavaScript, TypeScript, Python, Go, Rust, Java and Kotlin, a function named in the value of a top-level constant or variable, or of a class field, like `const routes = { home }`, `HANDLERS = {"home": home}`, a Go `map[string]func()` table, a Rust `static` list of functions, `static handlers = { click: onClick }`, a Java `List.of(Registry::home)` field or a Kotlin `listOf(::home)` property, is now referenced once, by the constant, variable, field or property that holds it. The file or class around it no longer also shows up as a second reference from the same place, so a function's callers and impact list each table once. - `codegraph sync` and the file watcher now follow an edit that changes which declaration a module exports as its default, like `export default A` becoming `export default B` while `A` stays, or the module dropping its default. References through `import Y from './x'`, `{ default as Y }`, `require('./x').default`, a barrel that passes the default on or a route that renders it as a layout, and calls on a value typed by it, now link to the new default, or stay unlinked, as a fresh index has them. Before, they kept pointing at the old declaration until the importing file changed or the project was indexed again. diff --git a/__tests__/import-path-spelling.test.ts b/__tests__/import-path-spelling.test.ts new file mode 100644 index 00000000..8f81b954 --- /dev/null +++ b/__tests__/import-path-spelling.test.ts @@ -0,0 +1,123 @@ +/** + * Issue #2577: an import spelled with a trailing slash (`@/widgets/Chart/`), a + * `.` segment (`src/widgets/./Chart`), a doubled slash or a `..` segment names + * the same file as the plain spelling, but the alias, workspace and direct-path + * branches of the import resolver joined the specifier as written. The disk + * check accepts any spelling of a file, so the import came back as + * `src/widgets/Chart//index.tsx`, which no file node is keyed by: the file + * import was dropped, the names fell back to name matching, and a lazily loaded + * component rendered the importing file's own `lazy(…)` constant. + * + * A trailing slash still names a folder: `@/widgets/Chart/` is + * `Chart/index.tsx`, never a `Chart.tsx` beside it. + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +const CHART = `export default function ChartView() { return null; } +export function Legend() { return 1; } +`; +/** An app that imports and lazily loads `Chart` through `spec`. */ +const app = (spec: string): string => `import { lazy } from 'react'; +import { Legend } from '${spec}'; +const Chart = lazy(() => import('${spec}')); +export function App() { Legend(); return ; } +`; + +const FILES: Record = { + 'package.json': JSON.stringify({ name: 'root', private: true, workspaces: ['packages/*'], dependencies: { react: '^18.0.0' } }), + 'src/widgets/Chart/index.tsx': CHART, + 'src/Plain.tsx': app('@/widgets/Chart'), + 'src/TrailingSlash.tsx': app('@/widgets/Chart/'), + 'src/DotSegment.tsx': app('src/widgets/./Chart'), + 'src/DoubleSlash.tsx': app('@/widgets//Chart'), + 'src/DotDot.tsx': app('@/other/../widgets/Chart'), + 'src/other/x.ts': `export const x = 1; +`, + // A trailing slash names the folder, not the file beside it. + 'src/cards/Card.tsx': `export function Legend() { return 2; } +`, + 'src/cards/Card/index.tsx': CHART, + 'src/Folder.tsx': app('@/cards/Card/'), + // A workspace member, imported as `@acme/ui/` and through a `.` segment. + 'packages/ui/package.json': JSON.stringify({ name: '@acme/ui' }), + 'packages/ui/index.tsx': CHART, + 'packages/ui/widgets/legend.ts': `export function Legend() { return 3; } +`, + 'packages/app/package.json': JSON.stringify({ name: 'app', dependencies: { '@acme/ui': 'workspace:*', react: '^18.0.0' } }), + 'packages/app/src/Member.tsx': app('@acme/ui/'), + 'packages/app/src/MemberPath.ts': `import { Legend } from '@acme/ui/./widgets/legend'; +export function show() { return Legend(); } +`, +}; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-import-path-spelling-')); + 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); + } + cg = await CodeGraph.init(root, { index: true }); +}, 60_000); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +/** `kind name@file` of every edge but `contains` leaving the nodes of `file`. */ +function edgesFrom(file: string): string[] { + const out: string[] = []; + for (const node of cg.getNodesInFile(file)) { + for (const e of cg.getOutgoingEdges(node.id)) { + if (e.kind === 'contains') continue; + const target = cg.getNode(e.target)!; + out.push(`${e.kind} ${target.name}@${target.filePath}`); + } + } + return out; +} + +/** What the plain spelling gives: the file import, the lazily loaded component, and the named import by import. */ +function expectLinked(file: string, module: string): void { + const edges = edgesFrom(file); + expect(edges).toContain(`imports ${path.posix.basename(module)}@${module}`); + expect(edges).toContain(`calls ChartView@${module}`); + expect(edges).toContain(`calls Legend@${module}`); + expect(edges.filter((e) => e.startsWith('calls Chart@'))).toEqual([]); +} + +describe('an import spelled with a trailing slash or a `.`, `..` or empty segment (#2577)', () => { + it('links like the plain spelling', () => { + expectLinked('src/Plain.tsx', 'src/widgets/Chart/index.tsx'); + }); + + it('with a trailing slash', () => { + expectLinked('src/TrailingSlash.tsx', 'src/widgets/Chart/index.tsx'); + }); + + it('with a `.` segment, a doubled slash and a `..` segment', () => { + expectLinked('src/DotSegment.tsx', 'src/widgets/Chart/index.tsx'); + expectLinked('src/DoubleSlash.tsx', 'src/widgets/Chart/index.tsx'); + expectLinked('src/DotDot.tsx', 'src/widgets/Chart/index.tsx'); + }); + + it('a trailing slash names the folder, not a same-named file beside it', () => { + expectLinked('src/Folder.tsx', 'src/cards/Card/index.tsx'); + expect(edgesFrom('src/Folder.tsx')).not.toContain('calls Legend@src/cards/Card.tsx'); + }); + + it('through a workspace member', () => { + expectLinked('packages/app/src/Member.tsx', 'packages/ui/index.tsx'); + expect(edgesFrom('packages/app/src/MemberPath.ts')).toEqual(expect.arrayContaining([ + 'imports legend.ts@packages/ui/widgets/legend.ts', + 'calls Legend@packages/ui/widgets/legend.ts', + ])); + }); +}); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index d7e84477..6d11a974 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -1330,14 +1330,38 @@ function resolveAliasedImport( return tryWithExt(importPath); } -/** The file `basePath` names: with the language's extensions or index files, as is, or as an emitted `.js`. */ +/** + * The file `basePath` names: with the language's extensions or index files, as + * is, or as an emitted `.js` — each spelled as the index keys it + * (indexedSpelling). + */ function tryWithExtensions(basePath: string, language: Language, context: ResolutionContext): string | null { for (const ext of EXTENSION_RESOLUTION[language] || []) { - const candidate = basePath + ext; + const candidate = indexedSpelling(basePath + ext, language); if (context.fileExists(candidate)) return candidate; } - if (context.fileExists(basePath)) return basePath; - return findSourceForEmittedSpecifier(basePath, language, context); + const asIs = indexedSpelling(basePath, language); + if (context.fileExists(asIs)) return asIs; + return findSourceForEmittedSpecifier(asIs, language, context); +} + +/** + * A path as the index keys its files. The alias, workspace and direct-path + * branches join an import specifier as written, so `@/widgets/Chart/` made + * `src/widgets/Chart//index.tsx` and `src/widgets/./Chart` made + * `src/widgets/./Chart/index.tsx`. The disk check accepts either spelling, but + * no file node or `files` row is keyed by it, so the import linked nothing + * (#2577). An ES module specifier is read lexically — by Node, tsc and the + * bundlers alike — so `.` and empty segments drop out and `..` folds, here as + * there. A trailing slash still names a folder: `Chart/` gives + * `Chart/index.tsx`, never `Chart.tsx`. Other languages keep the path as + * found: a C include's `a/../b.h` follows the folder `a` on disk, symlinks + * included. + */ +function indexedSpelling(filePath: string, language: Language): string { + return ESM_IMPORT_LANGUAGES.has(language) && /\/\/|(?:^|\/)\.\.?(?:\/|$)/.test(filePath) + ? path.posix.normalize(filePath) + : filePath; } /**