From cf10785aed91157b1e82e06124c5bb97f8d488da Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 07:22:31 -0500 Subject: [PATCH] fix(js,ts): a catch-all or node_modules path alias no longer makes package imports look local The out-of-repo import guard (`isOutOfRepoImport`) never fired in two setups: - A catch-all `paths` key. The guard started with `isExternalImport`, which treats an import as local when it starts with a root alias's prefix, and a `"*"` key's prefix is empty. On cord-field (`"*": ["./typings/*"]`), 169 imports of `Typography` from `@mui/material` bound to the project's own `Typography`. - An alias that lands on disk but outside the index. `fileExists` falls back to `fs.existsSync`, which also answers for a directory or a file under `node_modules`. home-assistant's `"lit/decorators": ["./node_modules/lit/decorators.js"]` bound 4,916 `@property()` decorators to an unrelated `property` field, and topcoder's `config` package landed on its `config/` folder. The guard now skips the alias-prefix test (it already asks `resolveImportPath` whether an alias maps the import to a file) and counts that resolution only when it lands on an indexed file. Other callers of `isExternalImport` are unchanged: resolving an import still needs the prefix test so `"*": ["src/*"]` aliases resolve at all. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/catch-all-path-alias.test.ts | 184 +++++++++++++++++++++++++ src/resolution/import-resolver.ts | 10 +- src/resolution/index.ts | 23 +++- 4 files changed, 212 insertions(+), 6 deletions(-) create mode 100644 __tests__/catch-all-path-alias.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bfedee281..17751f38a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Angular templates, a property binding, an interpolation, a structural directive or a control-flow block that calls one of the component's own members, like `[name]="icon()"`, `{{ label() }}`, `*ngIf="isOpen()"` or `@if (loading()) {`, now links the component to that member, and so does reading a getter, like `[disabled]="!canSave"`, or handing a method to a child component, like `[displayWith]="displayFn"` or `trackBy: trackById`. Before, only event bindings such as `(click)="save()"` were read, so a signal, a `computed` value, a getter or a method used only from a template had no callers and looked unused. A pipe, a template variable, a call on another object such as `form.reset()`, and a plain field read like `[value]="title"` still link nothing. Re-index Angular projects after upgrading. - React Router routes kept in a table of their own are now indexed: the ASP.NET Core React template's `AppRoutes` array that `App.js` maps into ``, a `routes` array imported into `useRoutes(routes)` or `createBrowserRouter(routes)` or returned by a function as in `useRoutes(routes(isLoggedIn))`, route objects kept one per file and listed in `createBrowserRouter([MainRoutes, LoginRoutes])`, and routes written in place in `useRoutes([…])`. Before, route objects were read only from a file that itself creates a data router, so these apps had no routes, and their `navigate('/login')` calls and `` links led nowhere. Each route links to the page its `element` renders, past a guard like `` or ``, and through `lazy(() => import(…))` to the page it loads rather than a same-named page elsewhere in the repository. An `index: true` route is the page at its parent's address, and a parent route around others counts as their layout. A `{ path, element }` list that nothing hands to the router, such as a menu, still makes no routes. Re-index React projects after upgrading. - In Go, a call or type written through an import whose path ends in a version, or in something other than the package's name, is now known to go through that import: `yaml.Unmarshal(…)` after `import "go.yaml.in/yaml/v3"` or `"gopkg.in/yaml.v3"`, `sqlite3.Error` after `import "github.com/mattn/go-sqlite3"`, `klog.V(2)` after `import "k8s.io/klog/v2"`. Before, only the last part of the path named such an import (`v3`, `yaml.v3`, `go-sqlite3`), so the name was matched on its own and could link to any project function, method or type that shared it: kubernetes' `klog.V(…)` calls were linked to a logging wrapper's `V` method, and etcd's `semver.Version` parameters to an unrelated `Version` function. A name from another module now links to nothing, and one through a package of your own project, like `kit.New()` after `import "example.com/kit/v2"`, links to that package's symbol. A comment in an import block is also no longer taken for the name of the import after it. Re-index Go projects after upgrading. Thanks @danusha2345 for the comment fix. (#2374) +- 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