diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c3d20477..21cc20d3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -290,6 +290,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In React, a tag for a component a file destructures at its top, like `const { Title, Text } = Typography;` with antd's `Typography`, as in refine's pages, now links to nothing in every component of that file, unless the component declares one of that name inside itself. A name destructured from `require(…)` there still links to that module's export. Before, these tags were matched by their name alone, so refine's pages showed up among the callers of other examples' `Title` and `Text` components. Re-index React projects after upgrading. - In React, a tag a component only mentions in a comment or a string no longer links it to that component: a JSDoc example, markup commented out like `{/* */}`, or an error message like react-router's "You cannot render a inside another " or shadcn's "useCarousel must be used within a ". Nothing in a TypeScript `.ts` file, which can't hold JSX, is read as a tag either, like the XML in outline's OpenSearch template. Before, these counted as renders, so react-router's hooks showed up among the callers of `Router`, `Routes` and `Route`, and `codegraph_explore`, callers and impact followed renders that never happen. A tag after an apostrophe, a URL or a backtick in a component's text, like `

Don't have an account? `, still links as before. Re-index React projects after upgrading. - In JavaScript and TypeScript, a name imported from a package your `package.json` lists no longer links to a project symbol that only shares its name when `tsconfig.json` or `jsconfig.json` has a catch-all path alias, like `"*": ["./typings/*"]` or `"*": ["src/*", "node_modules/*"]`, or an alias that points the package at a file in `node_modules`, like `"lit/decorators": ["./node_modules/lit/decorators.js"]`. Such an alias made every package look like part of the project, so `import { Typography } from '@mui/material'` was linked to the project's own `Typography` and every lit `@property()` decorator to an unrelated class's `property` field, and `codegraph callers`, impact and `codegraph affected` listed code that never used them. An import the alias does map to a file of your project, like a `.d.ts` you keep for an untyped package or `components/Button` through `"*": ["src/*"]`, links as before. Re-index affected projects after upgrading. +- In JavaScript and TypeScript, a `tsconfig.json` or `jsconfig.json` path alias with text on both sides of its `*`, like `"@x/*/index": ["widgets/*/index"]`, no longer matches an import that leaves nothing for the `*`, like `@x/index`. Before, the one `/` in `@x/index` counted for both sides, so the import and every call through it linked to `widgets/index`, a file it never named; now the alias is skipped there, as TypeScript skips it, and a less specific alias can still match. Re-index affected projects after upgrading. Thanks @Dashsoap. - In JavaScript and TypeScript, a function the file imports from a package and passes along as a value, like react-router's `Link` in `styled(Link)`, lodash's `noop` in `createContext({ onClose: noop })` or chart.js's `Legend` in `Chart.register(Legend)`, no longer links to a project function that only shares its name. Before, whenever the project had exactly one function of that name, every such value linked to it: outline's breadcrumbs, menus and document lists showed up among the callers of an email template's `Link`, and a test's own `noop` stub in cord-field among the code the app's contexts use, so `codegraph callers`, impact and `codegraph_explore` followed references that never happen. A function the file declares itself, including one that hides the import inside a function, and one imported from a file of your project link as before. Re-index JavaScript and TypeScript projects after upgrading. - In JavaScript and TypeScript, a function a file loads with a dynamic import from Node or a package, like `const { createServer } = await import('node:http')`, `const { default: chalk } = await import('chalk')` or `import('node:fs/promises').then(({ readFile }) => …)`, no longer links to a project function that only shares its name. Before, vite's call to Node's `createServer` showed up among the callers of vite's own `createServer`. The import counts only where it is in scope, so the rest of the file links as before, and a dynamic import of your own project's module still leads to that module. Thanks @Ancienttwo for the report. (#2491) - In JavaScript and TypeScript, code outside your tests no longer links to a Jest or Vitest manual mock under `__mocks__/`. Before, when an import of a workspace package couldn't be followed, a call like `formatLabel(name)` linked by name to the mock beside it instead of the package's own function, so the real function lost its callers, and the same happened to rendered components and event handlers. Production code is now matched as if the mocks weren't there, so it reaches the real function, and adding or deleting a mock leaves it alone. Tests, other mocks, and a file that imports a mock by its path, like a `jest.setup.ts`, still reach the mock. Thanks @Y-Osawa2022 for the report and @danusha2345. (#2455) diff --git a/__tests__/path-alias-overlap.test.ts b/__tests__/path-alias-overlap.test.ts new file mode 100644 index 000000000..a8aed8f8b --- /dev/null +++ b/__tests__/path-alias-overlap.test.ts @@ -0,0 +1,96 @@ +/** + * A `paths` wildcard whose prefix and suffix overlap. + * + * `"@x/*\/index"` splits into the prefix `@x/` and the suffix `/index`. An + * import of `@x/index` starts with the one and ends with the other, but only + * by counting its single `/` twice: nothing is left for `*`. tsc's + * `isPatternMatch` asks for a specifier at least as long as the prefix and the + * suffix together, so it skips the pattern there. The resolver took the empty + * capture instead and linked the import, and every call through it, to + * `widgets/index` — a file the import never named. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { applyAliases, loadProjectAliases } from '../src/resolution/path-aliases'; + +let dirs: string[] = []; +let graphs: CodeGraph[] = []; +afterEach(() => { + for (const graph of graphs) graph.close(); + for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true }); + graphs = []; + dirs = []; +}); + +const project = (files: Record): string => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-alias-overlap-')); + dirs.push(dir); + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), content); + } + return dir; +}; + +const tsconfig = (paths: Record): string => + JSON.stringify({ compilerOptions: { baseUrl: './src', paths } }); + +describe('a wildcard alias whose prefix and suffix overlap', () => { + it('does not match a specifier that leaves nothing for the wildcard', () => { + const root = project({ 'tsconfig.json': tsconfig({ '@x/*/index': ['widgets/*/index'] }) }); + const aliases = loadProjectAliases(root)!; + + expect(applyAliases('@x/index', aliases, root)).toEqual([]); + expect(applyAliases('@x/button/index', aliases, root)).toEqual(['src/widgets/button/index']); + }); + + it('falls through to the next pattern that does match', () => { + // Both prefixes are `@x/`, so the first-declared pattern is tried first, + // as in tsc. Skipping it must not end the search. + const root = project({ 'tsconfig.json': tsconfig({ '@x/*/index': ['widgets/*/index'], '@x/*': ['lib/*'] }) }); + const aliases = loadProjectAliases(root)!; + + expect(applyAliases('@x/index', aliases, root)).toEqual(['src/lib/index']); + expect(applyAliases('@x/button/index', aliases, root)).toEqual(['src/widgets/button/index']); + }); + + it('still accepts an empty capture when the specifier is exactly the prefix plus the suffix', () => { + // tsc's bound is `>=`: here the prefix and suffix meet without sharing + // a character, so `*` matches the empty string. + const root = project({ 'tsconfig.json': tsconfig({ '@x/*/index': ['widgets/*/index'] }) }); + + expect(applyAliases('@x//index', loadProjectAliases(root)!, root)).toEqual(['src/widgets/index']); + }); + + it('links neither the import nor its calls to the file the empty capture named', async () => { + const root = project({ + 'tsconfig.json': tsconfig({ '@x/*/index': ['widgets/*/index'] }), + 'src/index.ts': 'export function trap(): number { return 1; }\n', + 'src/widgets/index.ts': 'export function trap(): number { return 0; }\n', + 'src/widgets/button/index.ts': 'export function press(): number { return 2; }\n', + 'src/main.ts': + "import { trap as caught } from '@x/index';\n" + + "import { press } from '@x/button/index';\n" + + 'export function go(): number { return caught() + press(); }\n', + }); + const cg = await CodeGraph.init(root, { index: true }); + graphs.push(cg); + + const fn = (name: string, file: string) => + cg.getNodesByKind('function').find((n) => n.name === name && n.filePath === file)!; + const go = fn('go', 'src/main.ts'); + + // The overlapping import names no project file. + const widgetsFile = cg.getNodesInFile('src/widgets/index.ts').find((n) => n.kind === 'file')!; + expect(cg.getIncomingEdges(widgetsFile.id).filter((e) => e.kind === 'imports')).toEqual([]); + expect(cg.getCallers(fn('trap', 'src/widgets/index.ts').id)).toEqual([]); + + // The pattern still serves a specifier with a segment for `*`. + expect(cg.getCallees(go.id).map((c) => `${c.node.name}@${c.node.filePath}`)).toEqual([ + 'press@src/widgets/button/index.ts', + ]); + }); +}); diff --git a/src/resolution/path-aliases.ts b/src/resolution/path-aliases.ts index 60de648b0..96d2b1748 100644 --- a/src/resolution/path-aliases.ts +++ b/src/resolution/path-aliases.ts @@ -353,6 +353,12 @@ export function applyAliases( let captured = ''; if (pat.hasWildcard) { + // The prefix and the suffix may not share characters. `@x/*/index` + // both starts and ends `@x/index` only by counting its one `/` twice, + // which leaves nothing for `*` to stand on. tsc's `isPatternMatch` + // wants the specifier at least as long as the two together, so this + // pattern is skipped and a less specific one may still match. + if (importPath.length < pat.prefix.length + pat.suffix.length) continue; captured = importPath.slice(pat.prefix.length, importPath.length - pat.suffix.length); } else if (importPath !== pat.prefix) { // Literal pattern must match exactly.