Skip to content

fix(no-extraneous-dependencies): merge deps from closest package.json#482

Open
B4nan wants to merge 2 commits into
un-ts:masterfrom
B4nan:fix/no-extraneous-deps-merge-closest
Open

fix(no-extraneous-dependencies): merge deps from closest package.json#482
B4nan wants to merge 2 commits into
un-ts:masterfrom
B4nan:fix/no-extraneous-deps-merge-closest

Conversation

@B4nan
Copy link
Copy Markdown

@B4nan B4nan commented Apr 11, 2026

Problem

In a monorepo (lerna / npm workspaces / pnpm workspaces), the typical eslint.config.js for the root sets packageDir to a list of paths that point at the repo root so shared devDeps are discoverable:

```js
'import-x/no-extraneous-dependencies': ['error', {
packageDir: ['.', '../..', '../../..'],
}]
```

With the current rule, setting `packageDir` makes the rule ignore the closest `package.json` entirely. That's a problem: each workspace package has its own `package.json` with its own dependencies (that's the whole point of a workspace), and those deps become invisible to the rule. Every dep declared in the workspace package — `lodash`, internal `@apify-packages/*` workspace packages, etc. — gets reported as "should be listed in the project's dependencies" even though it literally is.

Concretely, on apify/apify-core (~60 lerna workspace packages), the migration from `eslint-plugin-import` to `eslint-plugin-import-x` surfaced hundreds of false positives like this:

```
src/packages/action-queue/src/action_queue.ts
1:1 error 'lodash' should be listed in the project's dependencies. Run 'npm i lodash' to add it import-x/no-extraneous-dependencies
3:1 error '@apify/log' should be listed in the project's dependencies. Run 'npm i @apify/log' to add it import-x/no-extraneous-dependencies
5:1 error '@apify-packages/aws' should be listed in the project's dependencies. Run 'npm i @apify-packages/aws' to add it import-x/no-extraneous-dependencies
```

...even though `src/packages/action-queue/package.json` declares all three.

`apify-core` has been carrying a local `patch-package` patch on `eslint-plugin-import` to work around this for years. We re-applied the same patch on `eslint-plugin-import-x` during the migration (see apify/apify-core#26946). This PR upstreams that fix so we can drop the patch.

Fix

Always seed `packageContent` from the closest `package.json` walking up from `context.physicalFilename`, then layer any `packageDir` entries on top:

  • packageDir not set → behaves like before (use closest only).
  • packageDir set → closest wins the base layer; any extra deps declared in the paths listed in `packageDir` are merged in on top (so repo-root shared devDeps still work).

The order is deliberate: entries later in `packageDir` can still override the closest match if there's a version conflict, matching the existing behavior where later `packageDir` entries already override earlier ones.

Verification

After this PR + re-applying it as a `patch-package` patch against `eslint-plugin-import-x` 4.16.2 locally: `npx eslint ./src/packages` on apify-core completes cleanly. Without it: hundreds of false-positive "should be listed in the project's dependencies" errors.

Companion PR

This is the second of two fixes I've sent to address the CI breakage caused by this issue. The first one (#481) was about native memory leaking in `legacyNodeResolve`; you suggested migrating to `import-x/resolver-next` which we've done via apify/apify-eslint-config#42. This one is a different bug — it's in the rule's deps-lookup, not the resolver — so the `resolver-next` migration doesn't cover it.

Happy to add a test; let me know what shape you'd prefer.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed dependency resolution so the nearest package manifest is always used as the base and configured package directories are layered on top, improving correctness for monorepos and nested packages.
  • Tests
    • Added coverage for monorepo/nested-package scenarios and adjusted expectations to reflect the corrected resolution behavior.

In monorepos (lerna, npm workspaces, pnpm workspaces), `packageDir` is
typically set to point at the repo root so that the shared devDeps are
discoverable. The current behavior is "packageDir XOR closest" — setting
packageDir makes the rule ignore the workspace package's own
`package.json` entirely. That means every dep declared in the workspace
package (lodash, internal `@scope/*` workspace packages, etc.) gets
reported as "should be listed in the project's dependencies" even though
it literally is.

The fix: always seed `packageContent` from the closest `package.json`
walking up from `context.physicalFilename`, then layer any packageDir
entries on top. If packageDir is not set, only the closest is used (same
as before). If it is set, the closest wins the base layer and any extra
deps declared in the root still get merged in.

This mirrors the long-standing downstream patch that `apify/apify-core`
has been carrying on `eslint-plugin-import`, which we've been forced to
re-apply on `eslint-plugin-import-x` since the migration. Upstreaming so
we can drop the patch.
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 11, 2026

⚠️ No Changeset found

Latest commit: 8047900

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 11, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 72d3b8da-1f6f-4673-9db1-e57f2618c7b9

📥 Commits

Reviewing files that changed from the base of the PR and between 4f1468e and 8047900.

📒 Files selected for processing (1)
  • test/rules/no-extraneous-dependencies.spec.ts

📝 Walkthrough

Walkthrough

getDependencies now initializes package data from the nearest package.json first, then overlays dependency fields from each resolved packageDir entry in order, replacing previous conditional selection between closest and packageDir sources.

Changes

Cohort / File(s) Summary
Dependency Resolution Logic
src/rules/no-extraneous-dependencies.ts
getDependencies now reads the closest package.json into a const packageContent immediately, then sequentially merges/overlays dependency fields from each resolved packageDir path. Removed prior branch that could replace closest content when packageDir was empty and changed path/throwAtRead handling.
Tests — monorepo packageDir cases
test/rules/no-extraneous-dependencies.spec.ts
Added 3 valid test cases covering packageDir resolution with nested package.json (monorepo scenarios) and removed one invalid test that expected react as missing when nested package.json should satisfy it.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested labels

bug

Suggested reviewers

  • JounQin

Poem

🐰 I hopped to the package, nose twitching bright,

Closest first, then layers stitched tight.
Monorepo paths now blend and agree,
No more missing for deps beneath the tree.
A carrot for tests — all green in sight! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: merging dependencies from the closest package.json in the no-extraneous-dependencies rule.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codesandbox-ci
Copy link
Copy Markdown

codesandbox-ci Bot commented Apr 11, 2026

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/rules/no-extraneous-dependencies.ts`:
- Around line 98-101: The current merge uses Object.assign(packageContent[key],
closestPackageContent[key]) which corrupts array fields like bundledDependencies
by treating arrays as objects; update the merge to handle arrays and objects
separately: in the loop over Object.keys(packageContent) (variables
packageContent, closestPackageContent, and type PackageDeps), check if both
values are arrays via Array.isArray and, if so, set packageContent[key] =
Array.from(new Set([...closestPackageContent[key], ...packageContent[key]])) (or
concatenate in the correct precedence) instead of Object.assign; otherwise, for
plain objects keep using Object.assign or deep-merge as needed. Apply the same
array-aware fix to the similar packageDir merge loop that handles
packageDir/closestPackageContent merging.
- Around line 119-122: The loop merging packageContent and packageContent_ uses
Object.assign which overwrites array fields (notably bundledDependencies)
instead of merging them; update the loop that iterates
depsKey/packageContent[key] to detect when both packageContent[key] and
packageContent_[key] are arrays (e.g., key === 'bundledDependencies') and
concatenate them deduplicating entries (e.g., via a Set), otherwise use
Object.assign for plain objects or simple assignment for primitives so arrays
are merged rather than replaced.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 60baff08-d3dc-4fbe-9ca7-30e43ac4aae5

📥 Commits

Reviewing files that changed from the base of the PR and between 4b2c0c5 and 4f1468e.

📒 Files selected for processing (1)
  • src/rules/no-extraneous-dependencies.ts

Comment on lines +98 to +101
for (const depsKey of Object.keys(packageContent)) {
const key = depsKey as keyof PackageDeps
Object.assign(packageContent[key], closestPackageContent[key])
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Object.assign corrupts bundledDependencies array during merge.

bundledDependencies is an array, but Object.assign on arrays overwrites by numeric index rather than concatenating. For example:

Object.assign(['dep1', 'dep2'], ['dep3']) // → ['dep3', 'dep2']

This loses 'dep1' and will cause false positives when the closest package.json declares bundled deps that get overwritten by a shorter array from packageDir.

🐛 Proposed fix: Handle array and object merging separately
       if (closestPackageContent) {
         for (const depsKey of Object.keys(packageContent)) {
           const key = depsKey as keyof PackageDeps
-          Object.assign(packageContent[key], closestPackageContent[key])
+          if (key === 'bundledDependencies') {
+            packageContent[key] = [
+              ...new Set([...packageContent[key], ...closestPackageContent[key]]),
+            ]
+          } else {
+            Object.assign(packageContent[key], closestPackageContent[key])
+          }
         }
       }

Apply the same pattern at lines 119-121 for the packageDir merge loop.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/rules/no-extraneous-dependencies.ts` around lines 98 - 101, The current
merge uses Object.assign(packageContent[key], closestPackageContent[key]) which
corrupts array fields like bundledDependencies by treating arrays as objects;
update the merge to handle arrays and objects separately: in the loop over
Object.keys(packageContent) (variables packageContent, closestPackageContent,
and type PackageDeps), check if both values are arrays via Array.isArray and, if
so, set packageContent[key] = Array.from(new Set([...closestPackageContent[key],
...packageContent[key]])) (or concatenate in the correct precedence) instead of
Object.assign; otherwise, for plain objects keep using Object.assign or
deep-merge as needed. Apply the same array-aware fix to the similar packageDir
merge loop that handles packageDir/closestPackageContent merging.

Comment on lines +119 to +122
for (const depsKey of Object.keys(packageContent)) {
const key = depsKey as keyof PackageDeps
Object.assign(packageContent[key], packageContent_[key])
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Same bundledDependencies array merge issue here.

The same Object.assign problem applies in this loop when merging deps from packageDir entries.

🐛 Proposed fix
       if (packageContent_) {
         for (const depsKey of Object.keys(packageContent)) {
           const key = depsKey as keyof PackageDeps
-          Object.assign(packageContent[key], packageContent_[key])
+          if (key === 'bundledDependencies') {
+            packageContent[key] = [
+              ...new Set([...packageContent[key], ...packageContent_[key]]),
+            ]
+          } else {
+            Object.assign(packageContent[key], packageContent_[key])
+          }
         }
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/rules/no-extraneous-dependencies.ts` around lines 119 - 122, The loop
merging packageContent and packageContent_ uses Object.assign which overwrites
array fields (notably bundledDependencies) instead of merging them; update the
loop that iterates depsKey/packageContent[key] to detect when both
packageContent[key] and packageContent_[key] are arrays (e.g., key ===
'bundledDependencies') and concatenate them deduplicating entries (e.g., via a
Set), otherwise use Object.assign for plain objects or simple assignment for
primitives so arrays are merged rather than replaced.

@codacy-production
Copy link
Copy Markdown

codacy-production Bot commented Apr 12, 2026

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 0 complexity · 0 duplication

Metric Results
Complexity 0
Duplication 0

View in Codacy

TIP This summary will be updated as you push new changes. Give us feedback

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 13, 2026

Open in StackBlitz

npm i https://pkg.pr.new/eslint-plugin-import-x@482

commit: 8047900

Copy link
Copy Markdown
Collaborator

@SukkaW SukkaW left a comment

Choose a reason for hiding this comment

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

I will take a look at this when I have some time. In the meantime, can you add tests so that your changes are verifiable?

…on merge behavior

Move the react-from-nested-package test from invalid to valid since the
rule now correctly finds react via the closest package.json. Add new
valid cases covering: deps merged from both closest and packageDir
entries, and unchanged behavior when closest IS the packageDir.

Co-Authored-By: Claude Opus 4.6 (1M context) <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.

2 participants