Repository navigation
fix(js,ts): a catch-all or node_modules path alias no longer makes package imports look local - #2437
Merged
Merged
Conversation
…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>
…si-255b83 # Conflicts: # CHANGELOG.md
This was referenced Oct 7, 2026
…si-255b83 # Conflicts: # CHANGELOG.md
This was referenced Oct 7, 2026
Merged
colbymchenry
added a commit
that referenced
this pull request
Oct 19, 2026
…ks the file it names (#2649) CRA and Vite admin templates (codedthemes' berry and mantis, uilibrary's matx) set `"baseUrl": "src"` or `"paths": { "*": ["./src/*"] }` in a jsconfig and write every cross-file import as `layout/MainLayout`. The import resolver took each one for an npm package: loadProjectAliases returned null for a config with a baseUrl and no paths, and isExternalImport classifies a bare specifier from the root config alone, before a nested app's config is read. The imports stayed unresolved, their names linked by bare-name matching (vite components rendering the remix app's namesakes), and React Router routes loaded through `lazy(() => import('views/x'))` bound to the local Loadable value. - path-aliases: a config with only `baseUrl` loads, marked `declaresBaseUrl`; aliasAndBaseUrlCandidates lists the most specific `paths` pattern's targets, then the specifier read from a declared baseUrl (paths alone never reads bare specifiers, as in TS 4.1+). - import-resolver: a specifier isExternalImport takes for a package is read through the importing file's own config (nearest, else the root's) and binds only to a file the index holds (`fileExists` also answers for folders and node_modules). A package the file's package.json or an enclosing one declares, or a Node built-in, never binds to a project module that way, only to a `.d.ts` the project keeps for it (#2437's `"*": ["./typings/*"]`). A root catch-all `"*"` no longer marks every specifier local in isExternalImport's default mode either. - The module ref of a one-segment import (`'config'`) links the file its specifier resolves to, as a path-shaped one already does (#2528). - isBoundToOutOfRepoImport keeps the `extends` edge of a class whose base is imported through baseUrl. - importBindingTail parks a binding under its module when the app's config maps the specifier to an indexed file, so a sync that edits the module relinks `import Layout from 'layout/MainLayout'` and `{ Foo as Bar }` (#2450's rule for relative imports). An undeclared name that maps to nothing (`express` under `"baseUrl": "."`) and a declared package keep their own tail, which root-"*" repos had lost. - The TypeScript and TSX extension lists probe `/index.jsx` last, as tsc does: a route resolves its lazy module as TypeScript. Validated against main 6071c9e on 21 repos (wasm path): every new import-resolved edge lands in the file its specifier names; failed imports naming a project file go 194/190/96 -> 0 on berry/mantis/matx; their routes bind their lazily loaded pages and layouts (matx 7 -> 21 routes); 37 + 45 cross-app JSX renders move to the app's own components. proshop, next-saas-starter, create-t3-turbo, excalidraw and six more are byte-identical; root-"*" repos change only parked tails plus module links. Same deltas with stubbed node_modules. Sync after late adds and edits equals a fresh index, except one pre-existing barrel-edit gap that relative imports share. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
pathskey.isOutOfRepoImportstarts withisExternalImport. 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."baseUrl": "src","*": ["./typings/*"]) bound 169 imports ofTypographyfrom@mui/materialtosrc/components/Typography/Typography.stories.tsx.2. An alias that lands on disk but outside the index.
resolveImportPath"resolves" such an import, becausefileExistsfalls back tofs.existsSync. That also returns true for a directory or for a file undernode_modules."lit/decorators": ["./node_modules/lit/decorators.js"](and 44 aliases like it): on main, 4,916 lit@property()decorators bind toZWaveJSNodeConfigParam::property, and 3,098@state()decorators to unrelatedstatefields.node_modules/*.import config from 'config'lands on the repo'sconfig/folder.Fix
isOutOfRepoImport(src/resolution/index.ts) now:isExternalImportwithout the alias-prefix test (new optionaliasPrefixes: false). The prefix test is redundant here because the guard then asksresolveImportPathwhether an alias maps the specifier to a file;knownFiles).The result:
package.jsonchain declares, which no alias maps to an indexed file, is outside the repo, whatever the aliases..d.tsfor an untyped package, orcomponents/Buttonthrough"*": ["src/*"].The other
isExternalImportcallers are unchanged:resolveImportPathneeds 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-boundextends/implementsrefs, and none of those edges remain.Tests
__tests__/catch-all-path-alias.test.ts, three cases:~/…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.node_modules/*catch-all, and itsconfig/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):
#1820module-global test hitting its own 60 s limit. With the limit raised it passes alone (99 s).#1820again (99 s with its limit raised);#1356disconnect 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:node_modulesstubbed 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.
"*": ["./typings/*"]"*": ["src/components/*", "src/*"]"*": ["src/shared/*", "src/*", "node_modules/*"]"*": ["src/*"]"*": ["src/*"]"*": ["src/*", "src/shared/*", "test/*"]cors()edge, now from a framework resolver instead of name matching)"*"node_modulesnode_modulesunresolved_refsasfailed.node_modulesexisted, it changed nothing on topcoder or home-assistant, which is why the indexed-file check is part of the fix.node_modules, itsreact-nativeimport falls through to the repo's own rootreact-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:
Typography,IconButton,Form→ stories and wrapper components; lodashmerge→ the project's ownmerge. happa: grommetText×99 → a projectText. topcoder:configfromtopcoder-react-utils×133 → a jest config constant. home-assistant: the 8,014 lit decorators. 39 of these hit the file's ownconst x = require('pkg')binding, the CommonJS shape #2120 already declines.Themefrom@mui/material→ adeclare module '@emotion/react'augmentation. legend-state's 11 self-loops: a property's react-native type reference resolved to the property itself.DateTime×32, MUIPalette, react-routerNavigateProps. happa: grommetFormFieldProps, styled-componentsDefaultTheme. TypeScript merges these into the package's type, but the import names the package.MsgReaderfrom@freiraum/msgreader,MammothOptionsfrommammoth→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 moduledeclarations is a follow-up.Not in this PR (filed as follow-ups)
jsx-rendersynthesizer andmatchFunctionRefnever consult the guard, alias or not.<Typography>from@mui/materialstill gets ajsx-renderedge to the project'sTypography: 313 such edges on cord-field, and 33 on outline, a control.declare module 'x') could bind to that declaration. Related:declare functionsignatures in.d.tsfiles are not extracted.🤖 Generated with Claude Code