Skip to content

Commit 21ea382

Browse files
fix(arborist): resolve sibling override sets via common ancestor (#9110)
1 parent 51365b1 commit 21ea382

File tree

5 files changed

+190
-16
lines changed

5 files changed

+190
-16
lines changed

lib/utils/explain-dep.js

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
const { relative } = require('node:path')
22

3-
const explainNode = (node, depth, chalk) =>
3+
const explainNode = (node, depth, chalk, seen = new Set()) =>
44
printNode(node, chalk) +
5-
explainDependents(node, depth, chalk) +
6-
explainLinksIn(node, depth, chalk)
5+
explainDependents(node, depth, chalk, seen) +
6+
explainLinksIn(node, depth, chalk, seen)
77

88
const colorType = (type, chalk) => {
99
const style = type === 'extraneous' ? chalk.red
@@ -34,24 +34,24 @@ const printNode = (node, chalk) => {
3434
(node.location ? chalk.dim(`\n${node.location}`) : '')
3535
}
3636

37-
const explainLinksIn = ({ linksIn }, depth, chalk) => {
37+
const explainLinksIn = ({ linksIn }, depth, chalk, seen) => {
3838
if (!linksIn || !linksIn.length || depth <= 0) {
3939
return ''
4040
}
4141

42-
const messages = linksIn.map(link => explainNode(link, depth - 1, chalk))
42+
const messages = linksIn.map(link => explainNode(link, depth - 1, chalk, seen))
4343
const str = '\n' + messages.join('\n')
4444
return str.split('\n').join('\n ')
4545
}
4646

47-
const explainDependents = ({ dependents }, depth, chalk) => {
47+
const explainDependents = ({ dependents }, depth, chalk, seen) => {
4848
if (!dependents || !dependents.length || depth <= 0) {
4949
return ''
5050
}
5151

5252
const max = Math.ceil(depth / 2)
5353
const messages = dependents.slice(0, max)
54-
.map(edge => explainEdge(edge, depth, chalk))
54+
.map(edge => explainEdge(edge, depth, chalk, seen))
5555

5656
// show just the names of the first 5 deps that overflowed the list
5757
if (dependents.length > max) {
@@ -75,29 +75,42 @@ const explainDependents = ({ dependents }, depth, chalk) => {
7575
return str.split('\n').join('\n ')
7676
}
7777

78-
const explainEdge = ({ name, type, bundled, from, spec, rawSpec, overridden }, depth, chalk) => {
78+
const explainEdge = (
79+
{ name, type, bundled, from, spec, rawSpec, overridden },
80+
depth, chalk, seen = new Set()
81+
) => {
7982
let dep = type === 'workspace'
8083
? chalk.bold(relative(from.location, spec.slice('file:'.length)))
8184
: `${name}@"${spec}"`
8285
if (overridden) {
8386
dep = `${colorType('overridden', chalk)} ${dep} (was "${rawSpec}")`
8487
}
8588

86-
const fromMsg = ` from ${explainFrom(from, depth, chalk)}`
89+
const fromMsg = ` from ${explainFrom(from, depth, chalk, seen)}`
8790

8891
return (type === 'prod' ? '' : `${colorType(type, chalk)} `) +
8992
(bundled ? `${colorType('bundled', chalk)} ` : '') +
9093
`${dep}${fromMsg}`
9194
}
9295

93-
const explainFrom = (from, depth, chalk) => {
96+
const explainFrom = (from, depth, chalk, seen) => {
9497
if (!from.name && !from.version) {
9598
return 'the root project'
9699
}
97100

98-
return printNode(from, chalk) +
99-
explainDependents(from, depth - 1, chalk) +
100-
explainLinksIn(from, depth - 1, chalk)
101+
// Prevent infinite recursion from cycles in the dependency graph (e.g. linked strategy store nodes). Use stack-based tracking so diamond dependencies (same node reached via different paths) are still explained, but recursive cycles are broken.
102+
const nodeId = `${from.name}@${from.version}:${from.location}`
103+
if (seen.has(nodeId)) {
104+
return printNode(from, chalk)
105+
}
106+
seen.add(nodeId)
107+
108+
const result = printNode(from, chalk) +
109+
explainDependents(from, depth - 1, chalk, seen) +
110+
explainLinksIn(from, depth - 1, chalk, seen)
111+
112+
seen.delete(nodeId)
113+
return result
101114
}
102115

103116
module.exports = { explainNode, printNode, explainEdge }

tap-snapshots/test/lib/utils/explain-dep.js.test.cjs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,28 @@
55
* Make sure to inspect the output below. Do not ignore changes!
66
*/
77
'use strict'
8+
exports[`test/lib/utils/explain-dep.js TAP basic > circular dependency does not recurse infinitely 1`] = `
9+
10+
node_modules/cycle-a
11+
cycle-a@"1.x" from [email protected]
12+
node_modules/cycle-b
13+
cycle-b@"2.x" from [email protected]
14+
node_modules/cycle-a
15+
cycle-a@"1.x" from [email protected]
16+
node_modules/cycle-b
17+
`
18+
19+
exports[`test/lib/utils/explain-dep.js TAP basic > circular dependency from other side 1`] = `
20+
21+
node_modules/cycle-b
22+
cycle-b@"2.x" from [email protected]
23+
node_modules/cycle-a
24+
cycle-a@"1.x" from [email protected]
25+
node_modules/cycle-b
26+
cycle-b@"2.x" from [email protected]
27+
node_modules/cycle-a
28+
`
29+
830
exports[`test/lib/utils/explain-dep.js TAP basic > ellipses test one 1`] = `
931
1032
manydep@"1.0.0" from [email protected]
@@ -21,6 +43,11 @@ [email protected]
2143
6 more (optdep, extra-neos, deep-dev, peer, the root project, a package with a pretty long name)
2244
`
2345

46+
exports[`test/lib/utils/explain-dep.js TAP basic > explainEdge without seen parameter 1`] = `
47+
some-dep@"1.x" from [email protected]
48+
node_modules/parent-pkg
49+
`
50+
2451
exports[`test/lib/utils/explain-dep.js TAP basic bundled > explain color deep 1`] = `
2552
[email protected] bundled
2653
node_modules/bundle-of-joy

test/lib/utils/explain-dep.js

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
const { resolve } = require('node:path')
22
const t = require('tap')
3-
const { explainNode, printNode } = require('../../../lib/utils/explain-dep.js')
3+
const { explainNode, printNode, explainEdge } = require('../../../lib/utils/explain-dep.js')
44
const { cleanCwd } = require('../../fixtures/clean-snapshot')
55

66
t.cleanSnapshot = (str) => cleanCwd(str)
@@ -268,6 +268,47 @@ t.test('basic', async t => {
268268
})
269269
}
270270

271+
// Regression test for https://github.com/npm/cli/issues/9109
272+
// Circular dependency graphs (common in linked strategy store nodes) should not cause infinite recursion in explainNode.
273+
const cycleA = {
274+
name: 'cycle-a',
275+
version: '1.0.0',
276+
location: 'node_modules/cycle-a',
277+
dependents: [],
278+
}
279+
const cycleB = {
280+
name: 'cycle-b',
281+
version: '2.0.0',
282+
location: 'node_modules/cycle-b',
283+
dependents: [{
284+
type: 'prod',
285+
name: 'cycle-b',
286+
spec: '2.x',
287+
from: cycleA,
288+
}],
289+
}
290+
cycleA.dependents = [{
291+
type: 'prod',
292+
name: 'cycle-a',
293+
spec: '1.x',
294+
from: cycleB,
295+
}]
296+
t.matchSnapshot(explainNode(cycleA, Infinity, noColor), 'circular dependency does not recurse infinitely')
297+
t.matchSnapshot(explainNode(cycleB, Infinity, noColor), 'circular dependency from other side')
298+
299+
// explainEdge called without seen parameter (covers default seen = new Set() branch on explainEdge and explainFrom)
300+
t.matchSnapshot(explainEdge({
301+
type: 'prod',
302+
name: 'some-dep',
303+
spec: '1.x',
304+
from: {
305+
name: 'parent-pkg',
306+
version: '2.0.0',
307+
location: 'node_modules/parent-pkg',
308+
dependents: [],
309+
},
310+
}, 2, noColor), 'explainEdge without seen parameter')
311+
271312
// make sure that we show the last one if it's the only one that would
272313
// hit the ...
273314
cases.manyDeps.dependents.pop()

workspaces/arborist/lib/override-set.js

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,8 +195,29 @@ class OverrideSet {
195195
}
196196
}
197197

198-
// The override sets are incomparable. Neither one contains the other.
199-
log.silly('Conflicting override sets', first, second)
198+
// The override sets are incomparable (e.g. siblings like the "react" and "react-dom" children of the root override set). Check if they have semantically conflicting rules before treating this as an error.
199+
if (this.haveConflictingRules(first, second)) {
200+
log.silly('Conflicting override sets', first, second)
201+
return undefined
202+
}
203+
204+
// The override sets are structurally incomparable but have compatible rules. Fall back to their nearest common ancestor so the node still has a valid override set.
205+
return this.findCommonAncestor(first, second)
206+
}
207+
208+
static findCommonAncestor (first, second) {
209+
const firstAncestors = []
210+
for (const ancestor of first.ancestry()) {
211+
firstAncestors.push(ancestor)
212+
}
213+
for (const secondAnc of second.ancestry()) {
214+
for (const firstAnc of firstAncestors) {
215+
if (firstAnc.isEqual(secondAnc)) {
216+
return firstAnc
217+
}
218+
}
219+
}
220+
return null
200221
}
201222

202223
static doOverrideSetsConflict (first, second) {

workspaces/arborist/test/override-set.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,78 @@ t.test('constructor', async (t) => {
511511
})
512512
})
513513

514+
t.test('findSpecificOverrideSet returns common ancestor for compatible siblings', async (t) => {
515+
// Regression test for https://github.com/npm/cli/issues/9109
516+
// When two top-level overrides (e.g. react + react-dom) share a transitive dep (e.g. loose-envify), the dep's node gets override sets from both siblings. findSpecificOverrideSet must return their common ancestor instead of undefined.
517+
const root = new OverrideSet({
518+
overrides: {
519+
react: '18.3.1',
520+
'react-dom': '18.3.1',
521+
},
522+
})
523+
524+
const reactChild = root.children.get('react')
525+
const reactDomChild = root.children.get('react-dom')
526+
527+
t.ok(reactChild, 'react child exists')
528+
t.ok(reactDomChild, 'react-dom child exists')
529+
530+
// These are siblings - neither is an ancestor of the other
531+
const result = OverrideSet.findSpecificOverrideSet(reactChild, reactDomChild)
532+
t.ok(result, 'findSpecificOverrideSet should not return undefined for compatible siblings')
533+
t.ok(result.isEqual(root), 'should return the common ancestor (root)')
534+
535+
const reverse = OverrideSet.findSpecificOverrideSet(reactDomChild, reactChild)
536+
t.ok(reverse, 'reverse order should also return a result')
537+
t.ok(reverse.isEqual(root), 'reverse order should also return the root')
538+
})
539+
540+
t.test('findCommonAncestor', async (t) => {
541+
const root = new OverrideSet({
542+
overrides: {
543+
foo: '1.0.0',
544+
bar: '2.0.0',
545+
},
546+
})
547+
548+
const fooChild = root.children.get('foo')
549+
const barChild = root.children.get('bar')
550+
551+
const ancestor = OverrideSet.findCommonAncestor(fooChild, barChild)
552+
t.ok(ancestor, 'should find a common ancestor')
553+
t.ok(ancestor.isEqual(root), 'common ancestor should be the root')
554+
555+
// Two unrelated roots have no common ancestor
556+
const other = new OverrideSet({ overrides: { baz: '3.0.0' } })
557+
const noAncestor = OverrideSet.findCommonAncestor(fooChild, other)
558+
t.equal(noAncestor, null, 'unrelated sets have no common ancestor')
559+
})
560+
561+
t.test('findSpecificOverrideSet returns undefined for truly conflicting siblings', async (t) => {
562+
// Siblings with conflicting rules should still return undefined
563+
const root = new OverrideSet({
564+
overrides: {
565+
foo: {
566+
'.': '1.0.0',
567+
shared: '1.0.0',
568+
},
569+
bar: {
570+
'.': '2.0.0',
571+
shared: '99.0.0',
572+
},
573+
},
574+
})
575+
576+
const fooChild = root.children.get('foo')
577+
const barChild = root.children.get('bar')
578+
579+
t.ok(fooChild, 'foo child exists')
580+
t.ok(barChild, 'bar child exists')
581+
582+
const result = OverrideSet.findSpecificOverrideSet(fooChild, barChild)
583+
t.equal(result, undefined, 'truly conflicting siblings should return undefined')
584+
})
585+
514586
t.test('coverage for isEqual edge cases', async t => {
515587
t.test('isEqual with null/undefined other', async t => {
516588
const overrides = new OverrideSet({ overrides: { foo: '1.0.0' } })

0 commit comments

Comments
 (0)