Skip to content

fix(arborist): prune hoisted deps shadowed by closer placements - #10067

Draft
Saibamen wants to merge 2 commits into
npm:latestfrom
Saibamen:fix/prune-shadowed-hoisted-deps
Draft

Saibamen wants to merge 2 commits into
npm:latestfrom
Saibamen:fix/prune-shadowed-hoisted-deps

Conversation

@Saibamen

@Saibamen Saibamen commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Summary

When Arborist placed a dependency deeper in the tree than an existing hoisted copy of the same name, the post-placement cleanup in PlaceDep only pruned same-name nodes inside the placed target subtree (isDescendantOf(target)). A hoisted copy at an ancestor location, such as the project root, survived with no remaining edgesIn and was written to the lockfile marked "extraneous", where it also kept winning dedupe decisions for the version it pinned.

Reproduction from #9135: root dep simplebar, workspace named newman with devDeps newman ~6.2.1 (same name as the workspace) and postman-collection ~4.4.0. postman-collection@4.4.1 hoisted to the root for the workspace edge, then newman@6.2.2's exact pin placed a closer postman-collection@4.4.0 in the workspace, orphaning the root copy. The first clean install wrote it to package-lock.json marked "extraneous": true, and its exact-pin lodash@4.17.21 kept winning root dedupe over the range simplebar resolved.

Changes

  • workspaces/arborist/lib/place-dep.js: after a placement, ascend the placed node's resolveParent chain and prune same-name nodes found there when they have no edgesIn left (pruneOrphan), gathering their exclusive subtrees (children + fsChildren) and reloading any outside edges that pointed into them, queuing those sources in needEvaluation. The prune is a no-op while any valid edgesIn remain, so a root or workspace edge that still depends on the hoisted copy keeps it in place. Detaching before reloading outside edges is required: otherwise from.resolve(name) still finds the doomed node and the reload is a no-op.
  • workspaces/arborist/lib/arborist/build-ideal-tree.js: guard the #nodeFromSpec workspace shortcut against a root with no edgesOut entry for a spec name that matches a workspace (previously a TypeError).
  • workspaces/arborist/test/arborist/reify.js: regression tests under workspace named like its own dependency:
    • the [BUG] Inconsistent npm install with workspaces #9135 shadowed hoisted dep is pruned (and its exclusively-owned nested transitive with it), with a second reify from the written lockfile being a no-op
    • the prune is a no-op while a root edge still depends on the hoisted copy
    • a second workspace depending on the hoisted copy keeps it alive (prune guard via a workspace edge)
    • the workspace link satisfies a wildcard edge on its own name

Validation

  • test/arborist/reify.js: all tests pass except three pre-existing, unrelated failures verified identical on a clean checkout.
  • Full arborist suite (tap test/*.js test/arborist/*.js): all files pass except the pre-existing reify failures above.
  • End-to-end with the patched npm on the repro from the issue: first clean install produces a lockfile with zero "extraneous" markers and postman-collection@4.4.0, and a second install leaves the lockfile byte-identical.

References

Fixes #9135

When Arborist placed a dependency deeper in the tree than an existing
hoisted copy of the same name, the post-placement cleanup in PlaceDep
only pruned same-name nodes inside the placed target subtree
(isDescendantOf(target)).  A hoisted copy at an ancestor location, such
as the project root, survived with no remaining edgesIn and was written
to the lockfile marked "extraneous", where it also kept winning dedupe
decisions for the version it pinned.

Ascend the placed node's resolveParent chain and prune same-name nodes
found there when they have no edgesIn left, gathering their exclusive
subtrees and reloading any outside edges that pointed into them.  The
prune is a no-op while any valid edgesIn remain, so a root or workspace
edge that still depends on the hoisted copy keeps it in place.

Also guard the #nodeFromSpec workspace shortcut against a root with no
edgesOut entry for a spec name that matches a workspace (previously a
TypeError).

## References

Fixes npm#9135
@Saibamen
Saibamen requested a review from a team as a code owner October 2, 2026 17:50
Refactor pruneOrphan to remove the redundant explicit edge reload loop (the root=null setter already reloads cross-tree edges) and add unit tests for the two remaining uncovered branches: pruning a shadowed hoisted dep that has fsChildren, and reloading outside edges that point into a pruned subtree.

Refs npm#9135
@Saibamen
Saibamen marked this pull request as draft October 3, 2026 18:43
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.

[BUG] Inconsistent npm install with workspaces

1 participant