diff --git a/workspaces/arborist/lib/link.js b/workspaces/arborist/lib/link.js index d200f3d8f8d78..3824dc81ffb3c 100644 --- a/workspaces/arborist/lib/link.js +++ b/workspaces/arborist/lib/link.js @@ -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 diff --git a/workspaces/arborist/test/link.js b/workspaces/arborist/test/link.js index f9b7d8eb1d96d..472f33c2f4535 100644 --- a/workspaces/arborist/test/link.js +++ b/workspaces/arborist/test/link.js @@ -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',