Skip to content

Commit 09f3c20

Browse files
[Fix] importType: correctly classify aliases resolving outside package root
Fixes #496. When a resolver (e.g., webpack, typescript) resolves an aliased import to a path outside the current package root but NOT in node_modules (e.g., a monorepo sibling package), `isExternalPath` previously classified it as 'external' because any path with a relative prefix of '..' was treated as external. This caused `no-extraneous-dependencies` to report false positives for these imports. The fix replaces the blanket "outside package root = external" check in `isExternalPath` with a more targeted approach: 1. Check if the path is under a configured external-module-folder relative to the package (preserves existing behavior for local node_modules) 2. For paths outside the package root, check if the external-module-folder appears as a path segment (catches hoisted deps in monorepos) Also updates `isInternalPath` to be the logical complement of `isExternalPath` for resolved paths, so any successfully resolved path that is not in an external module folder is correctly classified as 'internal'. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent eb6a828 commit 09f3c20

4 files changed

Lines changed: 66 additions & 8 deletions

File tree

src/core/importType.js

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,25 +64,44 @@ function isExternalPath(path, context) {
6464

6565
const { settings } = context;
6666
const packagePath = getContextPackagePath(context);
67+
const folders = settings && settings['import/external-module-folders'] || ['node_modules'];
6768

68-
if (relative(packagePath, path).startsWith('..')) {
69-
return true;
70-
}
69+
const isOutsidePackage = relative(packagePath, path).startsWith('..');
7170

72-
const folders = settings && settings['import/external-module-folders'] || ['node_modules'];
7371
return folders.some((folder) => {
72+
// For absolute folder paths in external-module-folders, check directly
73+
if (nodeIsAbsolute(folder)) {
74+
return !relative(folder, path).startsWith('..');
75+
}
76+
77+
// Check if the resolved path is under this external folder relative to the package
7478
const folderPath = nodeResolve(packagePath, folder);
75-
const relativePath = relative(folderPath, path);
76-
return !relativePath.startsWith('..');
79+
if (!relative(folderPath, path).startsWith('..')) {
80+
return true;
81+
}
82+
83+
// For paths outside the package root (e.g., hoisted deps in a monorepo),
84+
// check if the external module folder appears as a path segment in the resolved path.
85+
// This detects e.g. /monorepo/node_modules/lodash (hoisted) → external,
86+
// but NOT /monorepo/packages/shared/src (a monorepo sibling or alias target).
87+
if (isOutsidePackage) {
88+
const normalizedPath = path.replace(/\\/g, '/');
89+
const cleanFolder = folder.replace(/[/\\]+$/, '');
90+
return normalizedPath.includes(`/${cleanFolder}/`);
91+
}
92+
93+
return false;
7794
});
7895
}
7996

8097
function isInternalPath(path, context) {
8198
if (!path) {
8299
return false;
83100
}
84-
const packagePath = getContextPackagePath(context);
85-
return !relative(packagePath, path).startsWith('../');
101+
// A resolved path that is not classified as external is internal.
102+
// This correctly handles aliases and monorepo siblings that resolve to paths
103+
// outside the package root but are not in any external module folder (e.g., node_modules).
104+
return !isExternalPath(path, context);
86105
}
87106

88107
function isExternalLookingName(name) {
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "alias-outside-package",
3+
"version": "0.0.0",
4+
"private": true
5+
}

tests/src/core/importType.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,19 @@ describe('importType(name)', function () {
9191
expect(importType('@/does-not-exist', pathContext)).to.equal('unknown');
9292
});
9393

94+
it("should return 'internal' for aliases that resolve outside the package root but not in node_modules", function () {
95+
// Simulates a monorepo or workspace scenario where an alias resolves to a sibling package.
96+
// The resolved path is OUTSIDE the current package root (alias-outside-package/)
97+
// but is NOT in node_modules — it should be classified as 'internal', not 'external'.
98+
const alias = { 'my-alias': path.join(pathToTestFiles, 'internal-modules') };
99+
const webpackConfig = { resolve: { alias } };
100+
const aliasContext = {
101+
getFilename() { return testFilePath('alias-outside-package/foo.js'); },
102+
settings: { 'import/resolver': { webpack: { config: webpackConfig } } },
103+
};
104+
expect(importType('my-alias/api/service', aliasContext)).to.equal('internal');
105+
});
106+
94107
it("should return 'parent' for internal modules that go through the parent", function () {
95108
expect(importType('../foo', context)).to.equal('parent');
96109
expect(importType('../../foo', context)).to.equal('parent');

tests/src/rules/no-extraneous-dependencies.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,27 @@ ruleTester.run('no-extraneous-dependencies', rule, {
188188
},
189189
},
190190
}),
191+
192+
// Alias that resolves outside the current package root but not in node_modules.
193+
// Simulates a monorepo sibling package accessed via a webpack alias.
194+
// The alias target (internal-modules/) is outside alias-outside-package/'s root.
195+
test({
196+
code: 'import "my-alias/api/service";',
197+
filename: testFilePath('alias-outside-package/foo.js'),
198+
settings: {
199+
'import/resolver': {
200+
webpack: {
201+
config: {
202+
resolve: {
203+
alias: {
204+
'my-alias': testFilePath('internal-modules'),
205+
},
206+
},
207+
},
208+
},
209+
},
210+
},
211+
}),
191212
],
192213
invalid: [
193214
test({

0 commit comments

Comments
 (0)