Conversation
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
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
2 tasks done
Saibamen
marked this pull request as draft
October 3, 2026 18:43
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.
Summary
When Arborist placed a dependency deeper in the tree than an existing hoisted copy of the same name, the post-placement cleanup in
PlaceDeponly 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 remainingedgesInand 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 namednewmanwith devDepsnewman ~6.2.1(same name as the workspace) andpostman-collection ~4.4.0.postman-collection@4.4.1hoisted to the root for the workspace edge, thennewman@6.2.2's exact pin placed a closerpostman-collection@4.4.0in the workspace, orphaning the root copy. The first clean install wrote it topackage-lock.jsonmarked"extraneous": true, and its exact-pinlodash@4.17.21kept winning root dedupe over the rangesimplebarresolved.Changes
workspaces/arborist/lib/place-dep.js: after a placement, ascend the placed node'sresolveParentchain and prune same-name nodes found there when they have noedgesInleft (pruneOrphan), gathering their exclusive subtrees (children+fsChildren) and reloading any outside edges that pointed into them, queuing those sources inneedEvaluation. The prune is a no-op while any validedgesInremain, so a root or workspace edge that still depends on the hoisted copy keeps it in place. Detaching before reloading outside edges is required: otherwisefrom.resolve(name)still finds the doomed node and the reload is a no-op.workspaces/arborist/lib/arborist/build-ideal-tree.js: guard the#nodeFromSpecworkspace shortcut against a root with noedgesOutentry for a spec name that matches a workspace (previously aTypeError).workspaces/arborist/test/arborist/reify.js: regression tests underworkspace named like its own dependency:npm installwith 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-opValidation
test/arborist/reify.js: all tests pass except three pre-existing, unrelated failures verified identical on a clean checkout.tap test/*.js test/arborist/*.js): all files pass except the pre-existing reify failures above."extraneous"markers andpostman-collection@4.4.0, and a second install leaves the lockfile byte-identical.References
Fixes #9135