Skip to content

Commit 9e08077

Browse files
committed
fix(arborist): resolve sibling override sets via common ancestor
Sibling override sets (e.g. react + react-dom children of root) were treated as conflicting because findSpecificOverrideSet only checked ancestor relationships. Now checks haveConflictingRules and falls back to the nearest common ancestor when rules are compatible. Also caps ERESOLVE report depth to 16 and adds cycle detection in explain-dep to prevent RangeError: Invalid string length in large trees.
1 parent 8afa3bd commit 9e08077

File tree

6 files changed

+191
-17
lines changed

6 files changed

+191
-17
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 }

lib/utils/explain-eresolve.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ const report = (expl, chalk, noColorChalk) => {
5454

5555
return {
5656
explanation: `${explain(expl, chalk, 4)}\n\n${fix}`,
57-
file: `# npm resolution error report\n\n${explain(expl, noColorChalk, Infinity)}\n\n${fix}`,
57+
file: `# npm resolution error report\n\n${explain(expl, noColorChalk, 16)}\n\n${fix}`,
5858
}
5959
}
6060

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+
cycle-a@1.0.0
10+
node_modules/cycle-a
11+
cycle-a@"1.x" from cycle-b@2.0.0
12+
node_modules/cycle-b
13+
cycle-b@"2.x" from cycle-a@1.0.0
14+
node_modules/cycle-a
15+
cycle-a@"1.x" from cycle-b@2.0.0
16+
node_modules/cycle-b
17+
`
18+
19+
exports[`test/lib/utils/explain-dep.js TAP basic > circular dependency from other side 1`] = `
20+
cycle-b@2.0.0
21+
node_modules/cycle-b
22+
cycle-b@"2.x" from cycle-a@1.0.0
23+
node_modules/cycle-a
24+
cycle-a@"1.x" from cycle-b@2.0.0
25+
node_modules/cycle-b
26+
cycle-b@"2.x" from cycle-a@1.0.0
27+
node_modules/cycle-a
28+
`
29+
830
exports[`test/lib/utils/explain-dep.js TAP basic > ellipses test one 1`] = `
931
manydep@1.0.0
1032
manydep@"1.0.0" from prod-dep@1.2.3
@@ -21,6 +43,11 @@ manydep@1.0.0
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 parent-pkg@2.0.0
48+
node_modules/parent-pkg
49+
`
50+
2451
exports[`test/lib/utils/explain-dep.js TAP basic bundled > explain color deep 1`] = `
2552
bundle-of-joy@1.0.0 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)