Skip to content

fix(js,ts): a catch-all or node_modules path alias no longer makes package imports look local - #2437

Merged
colbymchenry merged 6 commits into
mainfrom
claude/objective-agnesi-255b83
Oct 7, 2026
Merged

colbymchenry merged 6 commits into
mainfrom
claude/objective-agnesi-255b83

Conversation

@colbymchenry

@colbymchenry colbymchenry commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

Problem

The out-of-repo import guard (#2120, #2128) keeps a name that a file imports from an npm package from binding to a project symbol that only shares the name. In two setups it never fired.

1. A catch-all paths key. isOutOfRepoImport starts with isExternalImport. That call treats an import as local when it starts with a root alias's prefix, so that alias resolution gets a chance to run. A "*" key has an empty prefix, so every package import counted as local.

  • cord-field ("baseUrl": "src", "*": ["./typings/*"]) bound 169 imports of Typography from @mui/material to src/components/Typography/Typography.stories.tsx.

2. An alias that lands on disk but outside the index. resolveImportPath "resolves" such an import, because fileExists falls back to fs.existsSync. That also returns true for a directory or for a file under node_modules.

  • home-assistant's "lit/decorators": ["./node_modules/lit/decorators.js"] (and 44 aliases like it): on main, 4,916 lit @property() decorators bind to ZWaveJSNodeConfigParam::property, and 3,098 @state() decorators to unrelated state fields.
  • topcoder's catch-all ends in node_modules/*.
  • topcoder's import config from 'config' lands on the repo's config/ folder.

Fix

isOutOfRepoImport (src/resolution/index.ts) now:

  • calls isExternalImport without the alias-prefix test (new option aliasPrefixes: false). The prefix test is redundant here because the guard then asks resolveImportPath whether an alias maps the specifier to a file;
  • counts that resolution only when it lands on a file the index holds (knownFiles).

The result:

  • A package the file's package.json chain declares, which no alias maps to an indexed file, is outside the repo, whatever the aliases.
  • An alias that does map to a project file keeps the import local. Examples: a local .d.ts for an untyped package, or components/Button through "*": ["src/*"].

The other isExternalImport callers are unchanged:

  • resolveImportPath needs the prefix test, or "*": ["src/*"]-style aliases would stop resolving.
  • isBoundToOutOfRepoImport, the inheritance gate, keeps its old answer. In these repos the name-matcher guard now declines the package-bound extends / implements refs, and none of those edges remain.

Tests

__tests__/catch-all-path-alias.test.ts, three cases:

  • cord-field's shape. Package imports don't bind to project namesakes. ~/… imports still bind, and so does a package whose types the catch-all finds in the project.
  • "*": ["src/*"]. A project import still resolves through the catch-all, and a package import doesn't bind to a namesake.
  • Aliases that land outside the index. home-assistant's lit decorators, topcoder's node_modules/* catch-all, and its config/ folder.

All three fail on main. The third also fails with only the prefix half of the fix, which pins the indexed-file check. The TypeScript build is clean.

Test runs on the merged branch (Windows, a shared box at 100% CPU):

  • The 35 test files on aliases, the guard, import resolution, frameworks, routers and sync, run serially: 868/869 pass.
  • The one failure was function-ref's #1820 module-global test hitting its own 60 s limit. With the limit raised it passes alone (99 s).
  • A parallel full run hit 624 load timeouts. A serial rerun of the 112 files involved passed 2,561 of 2,563. The other 2 also pass when run alone:
    • function-ref's #1820 again (99 s with its limit raised);
    • mcp-daemon's #1356 disconnect test (2.3 s, under its own 20 s limit).

Validation

I compared main with this branch on 18 repos, each indexed fresh (init -y), with edges compared by qualified-name keys. Each repo was indexed twice:

  • as cloned;
  • with node_modules stubbed on disk to emulate an installed checkout: a folder for every declared package and every imported specifier, plus each alias target file.

The first run was on main at 22a7ba8. After merging main at ed199e6, which includes #2412, #2415 and #2423, I re-indexed the 13 repos that moved or are JS controls. Their removed edge sets came out line-for-line the same. The other 5 controls were byte-identical on 22a7ba8.

repo path alias edges removed (as cloned / installed) added
SeedCompany/cord-field "*": ["./typings/*"] 475 / 475 0
giantswarm/happa "*": ["src/components/*", "src/*"] 226 / 226 0
topcoder-platform/community-app "*": ["src/shared/*", "src/*", "node_modules/*"] 276 / 276 0
Windscribe browser extension "*": ["src/*"] 38 / 38 0
hypha-dao/dho-web "*": ["src/*"] 17 / 17 0
kentcdodds jest-cypress-react-babel-webpack "*": ["src/*", "src/shared/*", "test/*"] 4 / 4 1 (the same cors() edge, now from a framework resolver instead of name matching)
sensors-africa, react-hn "*" 0 / 0 0
home-assistant/frontend 45 aliases into node_modules 8,022 / 8,022 0
LegendApp/legend-state aliases into node_modules 0 / 14 0
next-saas-starter, jira-clone, takenote, ghostfolio, excalidraw, mastodon, outline, bitwarden/clients aliases, none pointing a package outside the index 0 / 0 0
  • No node changed anywhere. Each removed edge's ref stays in unresolved_refs as failed.
  • The first version of this fix had only the prefix change. Once node_modules existed, it changed nothing on topcoder or home-assistant, which is why the indexed-file check is part of the fix.
  • legend-state's 14 appear only in the installed checkout. Without node_modules, its react-native import falls through to the repo's own root react-native.ts, which is also what TypeScript would pick.

Every removed edge, triaged

All 9,072 removed edges bind a name the file imports from a declared package:

where the edge landed edges examples
an unrelated project symbol 8,914 cord-field: Typography, IconButton, Form → stories and wrapper components; lodash merge → the project's own merge. happa: grommet Text ×99 → a project Text. topcoder: config from topcoder-react-utils ×133 → a jest config constant. home-assistant: the 8,014 lit decorators. 39 of these hit the file's own const x = require('pkg') binding, the CommonJS shape #2120 already declines.
a declaration of another package in the repo 26 Theme from @mui/material → a declare module '@emotion/react' augmentation. legend-state's 11 self-loops: a property's react-native type reference resolved to the property itself.
the repo's augmentation of the imported type 126 cord-field: luxon DateTime ×32, MUI Palette, react-router NavigateProps. happa: grommet FormFieldProps, styled-components DefaultTheme. TypeScript merges these into the package's type, but the import names the package.
the repo's own ambient declaration of that package 6 cord-field: MsgReader from @freiraum/msgreader, MammothOptions from mammoth → typings/…/index.d.ts. TypeScript resolves these imports there.

Main already declines the last two groups in every repo without a catch-all; they bound here only by name. This PR applies the guard's existing rule to catch-all repos rather than changing it. Binding these imports to the repo's own declare module declarations is a follow-up.

Not in this PR (filed as follow-ups)

  • The jsx-render synthesizer and matchFunctionRef never consult the guard, alias or not. <Typography> from @mui/material still gets a jsx-render edge to the project's Typography: 313 such edges on cord-field, and 33 on outline, a control.
  • Imports of a package the repo declares itself (declare module 'x') could bind to that declaration. Related: declare function signatures in .d.ts files are not extracted.

🤖 Generated with Claude Code

colbymchenry and others added 2 commits October 7, 2026 07:22
…ckage 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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant