Skip to content

feat(no-extraneous-dependencies): allow package to import itself #309

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cruel-cooks-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eslint-plugin-import-x": patch
---

Allow packages to import themselves in `import-x/no-extraneous-imports` if the `exports` field is defined in `package.json`
55 changes: 45 additions & 10 deletions src/rules/no-extraneous-dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ function readJSON<T>(jsonPath: string, throwException: boolean) {

function extractDepFields(pkg: PackageJson) {
return {
name: pkg.name,
exports: pkg.exports,
dependencies: pkg.dependencies || {},
devDependencies: pkg.devDependencies || {},
optionalDependencies: pkg.optionalDependencies || {},
Expand Down Expand Up @@ -70,6 +72,8 @@ function getDependencies(context: RuleContext, packageDir?: string | string[]) {

try {
let packageContent: PackageDeps = {
name: undefined,
exports: undefined,
dependencies: {},
devDependencies: {},
optionalDependencies: {},
Expand All @@ -92,9 +96,19 @@ function getDependencies(context: RuleContext, packageDir?: string | string[]) {
paths.length === 1,
)
if (packageContent_) {
for (const depsKey of Object.keys(packageContent)) {
const key = depsKey as keyof PackageDeps
Object.assign(packageContent[key], packageContent_[key])
if (!packageContent.name) {
packageContent.name = packageContent_.name
packageContent.exports = packageContent_.exports
}
const fieldsToMerge = [
'dependencies',
'devDependencies',
'optionalDependencies',
'peerDependencies',
'bundledDependencies',
] as const
for (const field of fieldsToMerge) {
Object.assign(packageContent[field], packageContent_[field])
}
}
}
Expand All @@ -112,13 +126,17 @@ function getDependencies(context: RuleContext, packageDir?: string | string[]) {
}

if (
![
packageContent.dependencies,
packageContent.devDependencies,
packageContent.optionalDependencies,
packageContent.peerDependencies,
packageContent.bundledDependencies,
].some(hasKeys)
!(
packageContent.name ||
packageContent.exports ||
[
packageContent.dependencies,
packageContent.devDependencies,
packageContent.optionalDependencies,
packageContent.peerDependencies,
packageContent.bundledDependencies,
].some(hasKeys)
)
) {
return
}
Expand Down Expand Up @@ -298,6 +316,20 @@ function reportIfMissing(
return
}

if (importPackageName === deps.name) {
if (!deps.exports) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure to understand why exports field is required, and is this prevents self-importing issues in source files?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don’t know the rationale behind that decision, but that is how Node.js behaves. You can try it by creating a directory with two files:

package.json:

{
  "name": "pkg",
  "exports": "./index.js"
}

index.js:

require('pkg')

console.log('Hello')

If you run index.js, this logs:

$ node index.js
Hello

If you now remove the exports field, or change it to main, this logs:

$ node index.js 
node:internal/modules/cjs/loader:1228
  throw err;
  ^

Error: Cannot find module 'pkg'
Require stack:
- /home/remco/Downloads/asd/index.js
    at Function._resolveFilename (node:internal/modules/cjs/loader:1225:15)
    at Function._load (node:internal/modules/cjs/loader:1055:27)
    at TracingChannel.traceSync (node:diagnostics_channel:322:14)
    at wrapModuleLoad (node:internal/modules/cjs/loader:220:24)
    at Module.require (node:internal/modules/cjs/loader:1311:12)
    at require (node:internal/modules/helpers:136:16)
    at Object.<anonymous> (/home/remco/Downloads/asd/index.js:1:1)
    at Module._compile (node:internal/modules/cjs/loader:1554:14)
    at Object..js (node:internal/modules/cjs/loader:1706:10)
    at Module.load (node:internal/modules/cjs/loader:1289:32) {
  code: 'MODULE_NOT_FOUND',
  requireStack: [ '/home/remco/Downloads/asd/index.js' ]
}

Node.js v22.14.0

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even if it works for self-importing, but I personally still believe it's not a good practice to do like this as circular dependency itself. It should only be allowed in non-source files IMO.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This example is indeed circular, which is a bit silly. This was to keep it minimal. Modules can resolve package-local modules using package.json exports. Tests are one useful example, but I can think of other use cases.

The goal of this PR however is not to discuss whether or not people should do this or when. People can do it. The concept of what a source file is also varies per project. The goal of this PR is to fix a false positives for detecting imports of extraneous modules.

Copy link
Member

@JounQin JounQin Apr 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

People can do anything they want without enabling this rule, the ESLint rules are for good practice, not how codes can be used in runtime. Otherwise, running the codes itself already helps you confirming it's working.

But I'd like to hear more voices from @thepassle @SukkaW @Shinigami92 @43081j

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, to summary, import/require via package's own exports is supported by Node, main is not, right?

Correct!

Copy link
Member

@JounQin JounQin May 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@remcohaszing Would you like to combine your proposed import-x/no-own-exports rule into this PR together? Or maybe wait for other reviewers.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That rule seems unrelated to these changes TBH. Also I don’t have personal interest in that rule.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, that rule should be added together with this behavior change IMO.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense to me

We can probably merge this one first @JounQin and one of us can work on the new rule separately.

It looks like this won't change existing behaviour other than making this edge case work. So should be an easy one to land

context.report({
node,
messageId: 'selfImport',
data: {
packageName,
},
})
}

return
}
Comment on lines +319 to +331
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Harden self-import detection using the resolved package name

Currently, self-import is detected via importPackageName === deps.name. If a resolver aliases the bare specifier to a different package/path, this could incorrectly treat a non-self import as self and skip reporting. Prefer using realPackageName when available.

-  if (importPackageName === deps.name) {
+  const isSelfImport = (realPackageName ?? importPackageName) === deps.name
+  if (isSelfImport) {
     if (!deps.exports) {
       context.report({
         node,
         messageId: 'selfImport',
         data: {
           packageName,
         },
       })
     }
 
     return
   }

Note: This preserves existing behavior but avoids false positives when aliasing is involved.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/rules/no-extraneous-dependencies.ts between lines 319 and 331, the
self-import detection uses importPackageName === deps.name, which can cause
false positives if a resolver aliases the import. To fix this, update the
condition to use realPackageName when available instead of importPackageName for
comparison with deps.name, ensuring more accurate self-import detection while
preserving existing behavior.


if (declarationStatus.isInDevDeps && !depsOptions.allowDevDeps) {
context.report({
node,
Expand Down Expand Up @@ -366,6 +398,7 @@ export type MessageId =
| 'devDep'
| 'optDep'
| 'missing'
| 'selfImport'

export default createRule<[Options?], MessageId>({
name: 'no-extraneous-dependencies',
Expand Down Expand Up @@ -399,6 +432,8 @@ export default createRule<[Options?], MessageId>({
optDep:
"'{{packageName}}' should be listed in the project's dependencies, not optionalDependencies.",
missing: `'{{packageName}}' should be listed in the project's dependencies. Run '${getNpmInstallCommand('{{packageName}}')}' to add it`,
selfImport:
"'{{packageName}}' may only import itself if the exports field is defined in package.json",
},
Comment on lines +435 to 437
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

New message text added; consider minor polish and docs/tests sync

  • Optional: end the message with a period to match house style if applicable.
  • Ensure the rule docs mention this new behavior/message and tests assert against selfImport.

To verify coverage quickly:


🏁 Script executed:

#!/bin/bash
# Check tests reference the selfImport messageId and that docs mention it.
rg -n "selfImport" -S
rg -n "no-extraneous-dependencies" docs -S || true

Length of output: 2274


Polish selfImport message and sync documentation

The selfImport message needs a terminal period and the docs must include this new error ID.

  • Update the message in src/rules/no-extraneous-dependencies.ts (around line 431):
      - "'{{packageName}}' may only import itself if the exports field is defined in package.json",
      + "'{{packageName}}' may only import itself if the exports field is defined in package.json."
  • In docs/rules/no-extraneous-dependencies.md, add a description/example for the selfImport message under the rule’s “Messages” (or “Examples”) section.
  • No test changes needed—selfImport is already asserted in test/rules/no-extraneous-dependencies.spec.ts.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
selfImport:
"'{{packageName}}' may only import itself if the exports field is defined in package.json",
},
selfImport:
"'{{packageName}}' may only import itself if the exports field is defined in package.json.",
},
🤖 Prompt for AI Agents
In src/rules/no-extraneous-dependencies.ts around lines 431 to 433, add a
terminal period to the selfImport message string to polish it. Then, update
docs/rules/no-extraneous-dependencies.md by adding a description and example for
the selfImport message under the rule’s Messages or Examples section to keep
documentation in sync with the code changes.

},
defaultOptions: [],
Expand Down
1 change: 1 addition & 0 deletions test/fixtures/package-named-exports/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default function () {}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions test/fixtures/package-named-exports/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "package-named-exports",
"description": "Standard, named package with exports",
"exports": "./index.js"
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions test/rules/no-extraneous-dependencies.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,11 @@ ruleTester.run('no-extraneous-dependencies', rule, {
{ packageDir: packageDirMonoRepoRoot, whitelist: ['not-a-dependency'] },
],
}),
tValid({
code: 'import "package-named-exports"',
filename: testFilePath('package-named-exports/index.js'),
options: [{ packageDir: testFilePath('package-named-exports') }],
}),
],
invalid: [
tInvalid({
Expand Down Expand Up @@ -446,6 +451,14 @@ ruleTester.run('no-extraneous-dependencies', rule, {
{ messageId: 'missing', data: { packageName: 'not-a-dependency' } },
],
}),
tInvalid({
code: 'import "package-named"',
filename: testFilePath('package-named/index.js'),
options: [{ packageDir: testFilePath('package-named') }],
errors: [
{ messageId: 'selfImport', data: { packageName: 'package-named' } },
],
}),
],
})

Expand Down
Loading