diff --git a/CHANGELOG.md b/CHANGELOG.md index fde26e979..83e03d4b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript and TypeScript, a module loaded with `require('./x').default` or `const { default: X } = require('./x')` now links to its default export when it is written as an ES module: bitwarden's desktop app loads `export default class OsBiometricsServiceMac` this way before calling `new OsBiometricsServiceMac(…)`, and React Native's own libraries load many of their modules like this. Before, `.default` was looked up only as an export named `default`, which `export default` is not, so the `new` and the calls made through it linked to nothing, to the local variable holding the module, or to a method of the same name somewhere else. A Svelte component loaded this way and created with `new App({ target })` now links to the component. A CommonJS module that sets `exports.default` itself, or both `module.exports` and `module.exports.default` as fastify does, still links to what it sets, and one that sets no `default` still links to nothing. Re-index JavaScript and TypeScript projects after upgrading. - In JavaScript and TypeScript, an import written `import { default as AppRoot } from './routes/app/root'`, as in bulletproof-react's router, now counts as the module's default import, the same as `import AppRoot from './routes/app/root'`. Before, it was read as an import of an export named `default`, which no module has, so a call, a route or a JSX attribute that used `AppRoot` was matched by its name alone: it could link to another file's `AppRoot`, or to nothing when the default export has a name of its own. Svelte, Vue and Astro script blocks are read the same way. - In React, a component's JSX no longer links it to a class or component that only shares a name with one of its type arguments or with a variable of its own. A type in angle brackets, like `Document` in ` items={…} />`, `User` in `useState()` or `Entry` in a generic `(…) =>` component, is no longer read as a tag. A tag naming a variable or parameter the component sets itself, like `` after `const Content = isDropdown ? DropdownMenu.SubContent : ContextMenu.SubContent` or `` in `widgets.map((Widget) => )`, now links to nothing, unless the component declares a component of that name inside itself. Before, these linked to an unrelated class or component elsewhere in the repository: outline's document lists showed up among the callers of its `Document` model class, and its menus among the callers of the command bar's `Content`, so `codegraph_explore`, callers and impact followed renders that never happen. 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. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/catch-all-path-alias.test.ts b/__tests__/catch-all-path-alias.test.ts new file mode 100644 index 000000000..7f4163151 --- /dev/null +++ b/__tests__/catch-all-path-alias.test.ts @@ -0,0 +1,184 @@ +/** + * A tsconfig `paths` entry keyed `"*"` matches every specifier, so treating + * "an alias pattern matches" as "the import is the project's" made every + * package import in such a project look local. The out-of-repo guard never + * fired there: on cord-field (`"baseUrl": "src"`, `"*": ["./typings/*"]`), + * 169 imports of `Typography` from `@mui/material` landed on a project + * `Typography`, and `Form` from `react-final-form` on the project's form. + * + * An alias counts only when it maps the specifier to a project file. A + * package the importing file's package.json declares, which no alias maps to + * a file, is outside the repository, catch-all or not. A catch-all that does + * find a file (a local `.d.ts` for an untyped package, or `"*": ["src/*"]` + * for the project's own folders) still makes that import the project's. + */ +import { describe, it, expect, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +const roots: string[] = []; +afterAll(() => { + for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true }); +}); + +async function indexProject(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-catch-all-alias-')); + roots.push(root); + 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); + } + return CodeGraph.init(root, { index: true }); +} + +/** + * ` :` for every edge leaving + * `file`'s nodes. JSX tags are left out: a separate pass links `; } +`, + 'src/components/Card.tsx': `export function Card(props: { children?: unknown }) { return
{String(props.children)}
; } +`, + 'src/components/form/Form.tsx': `export function Form() { return
; } +export function useForm() { return { valid: true }; } +`, + 'src/common/DateTime.ts': `export class DateTime { + static now(): DateTime { return new DateTime(); } +} +`, + // The catch-all does answer this package: its types live in the project. + 'src/typings/legacy-widgets.d.ts': `export interface WidgetOptions { name: string } +export declare class Widget { + constructor(options: WidgetOptions); + render(): void; +} +`, + 'src/scenes/Page.tsx': `import { Button, type ButtonProps } from '@mui/material'; +import { Form, useForm } from 'react-final-form'; +import { DateTime } from 'luxon'; +import { Widget, type WidgetOptions } from 'legacy-widgets'; +import { Card } from '~/components/Card'; + +const options: WidgetOptions = { name: 'page' }; + +export function Page(props: ButtonProps) { + const form = useForm(); + const when = DateTime.now(); + new Widget(options).render(); + return ; +} +`, + }); + try { + const fromPage = edgesFrom(cg, 'src/scenes/Page.tsx'); + // No name the page imports from a package lands on a project namesake. + expect(fromPage.filter((e) => /src\/components\/(?:Button|form\/Form)\.tsx|src\/common\/DateTime\.ts/.test(e))).toEqual([]); + // `~/…` still reaches the project, and so does a package whose types the + // catch-all finds in the project. + expect(fromPage).toContain('imports src/components/Card.tsx:Card'); + expect(fromPage).toContain('imports src/typings/legacy-widgets.d.ts:Widget'); + expect(fromPage).toContain('imports src/typings/legacy-widgets.d.ts:WidgetOptions'); + } finally { + cg.close(); + } + }); + + it('still makes an import the project’s when it maps the import to a project file', async () => { + const cg = await indexProject({ + 'package.json': JSON.stringify({ name: 'shop', dependencies: { '@mui/material': '^5.15.0', react: '^18.2.0' } }), + 'tsconfig.json': JSON.stringify({ compilerOptions: { baseUrl: '.', jsx: 'react-jsx', paths: { '*': ['src/*'] } } }), + 'src/components/Button.tsx': `export function Button() { return