Skip to content
Merged
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
20 changes: 16 additions & 4 deletions workspaces/arborist/lib/link.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,24 @@ class Link extends Node {
// so this is a no-op
[_loadDeps] () {}

// When a Link receives overrides (via edgesIn), forward them to the target node which holds the actual edgesOut.
// Without this, overrides stop at the Link and never reach the target's dependency edges.
// When a Link receives overrides (via edgesIn), forward them to the target node which holds the actual edgesOut — but only when the OverrideSet has at least one rule that names a dep the target actually depends on.
// Without this scope, the link forwards a generic ancestor OverrideSet that has no real effect on the target's edges, but still flips the target to "has overrides", which changes downstream `canReplaceWith` / placement decisions and causes `npm ci` to re-resolve lockfile-pinned edges from the registry.
// See npm/cli#9357.
recalculateOutEdgesOverrides () {
if (this.target) {
this.target.updateOverridesEdgeInAdded(this.overrides)
if (!this.target || !this.overrides) {
return
}
let hasMatchingRule = false
for (const rule of this.overrides.ruleset.values()) {
if (this.target.edgesOut.has(rule.name)) {
hasMatchingRule = true
break
}
}
if (!hasMatchingRule) {
return
}
this.target.updateOverridesEdgeInAdded(this.overrides)
}

// links can't have children, only their targets can
Expand Down
45 changes: 45 additions & 0 deletions workspaces/arborist/test/link.js
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,51 @@ t.test('recalculateOutEdgesOverrides forwards overrides to target', t => {
t.end()
})

t.test('recalculateOutEdgesOverrides does not forward when no rule matches a target dep — npm/cli#9357', t => {
// Regression: prior to the fix, Link.recalculateOutEdgesOverrides forwarded the link's full OverrideSet to the target unconditionally.
// For a target whose edges are NOT named in any override rule, that flipped target.overrides from undefined to the root's OverrideSet.
// Downstream, that "has overrides" state changed canPlaceDep's KEEP-vs-REPLACE decision and made `npm ci` re-resolve lockfile-pinned edges from the registry.
// After the fix, propagation is gated on at least one rule whose name matches an edge in target.edgesOut.
const root = new Node({
path: '/path/to/root',
pkg: {
name: 'root',
dependencies: { foo: '1.0.0' },
// override is for "bar", but the linked target only depends on "baz"
overrides: { bar: '2.0.0' },
},
loadOverrides: true,
})

const target = new Node({
path: '/path/to/store/foo',
pkg: {
name: 'foo',
version: '1.0.0',
dependencies: { baz: '1.0.0' },
},
root,
})

// eslint-disable-next-line no-new
new Link({
pkg: { name: 'foo', version: '1.0.0' },
path: '/path/to/root/node_modules/foo',
realpath: '/path/to/store/foo',
target,
parent: root,
})

t.ok(root.overrides, 'root has overrides')
t.notOk(target.overrides,
'target.overrides stays undefined when no rule matches a target dep')
const bazEdge = target.edgesOut.get('baz')
t.notOk(bazEdge.overrides,
'unrelated edge keeps edge.overrides undefined')
t.equal(bazEdge.spec, '1.0.0', 'unrelated edge spec is unchanged')
t.end()
})

t.test('link to root path gets root as target', t => {
const root = new Node({
path: '/project/root',
Expand Down
Loading