Skip to content

Commit e9f0418

Browse files
authored
fix(arborist): improve override conflict detection with semantic comparison (#8689)
Resolves the issue where different override contexts (like Vaadin's $@vaadin/react-components vs $@vaadin/react-components-pro) were incorrectly treated as conflicts by structural comparison. Fixes #8688
1 parent 7f72238 commit e9f0418

File tree

4 files changed

+444
-72
lines changed

4 files changed

+444
-72
lines changed

workspaces/arborist/lib/edge.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -276,9 +276,15 @@ class Edge {
276276
} else if (!this.satisfiedBy(this.#to)) {
277277
this.#error = 'INVALID'
278278
} else if (this.overrides && this.#to.edgesOut.size && OverrideSet.doOverrideSetsConflict(this.overrides, this.#to.overrides)) {
279-
// Any inconsistency between the edge's override set and the target's override set is potentially problematic.
280-
// But we only say the edge is in error if the override sets are plainly conflicting.
281-
// Note that if the target doesn't have any dependencies of their own, then this inconsistency is irrelevant.
279+
// Check for conflicts between the edge's override set and the target node's override set.
280+
// This catches cases where different parts of the tree have genuinely incompatible
281+
// version requirements for the same package.
282+
// The improved conflict detection uses semantic comparison (checking for incompatible
283+
// version ranges) rather than pure structural equality, avoiding false positives from:
284+
// - Reference overrides ($syntax) that resolve to compatible versions
285+
// - Peer dependencies with different but compatible override contexts
286+
// Note: We only check if the target has dependencies (edgesOut.size > 0), since
287+
// override conflicts are only relevant if the target has its own dependencies.
282288
this.#error = 'INVALID'
283289
} else {
284290
this.#error = 'OK'

workspaces/arborist/lib/override-set.js

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,8 +201,82 @@ class OverrideSet {
201201

202202
static doOverrideSetsConflict (first, second) {
203203
// If override sets contain one another then we can try to use the more specific one.
204-
// If neither one is more specific, then we consider them to be in conflict.
205-
return (this.findSpecificOverrideSet(first, second) === undefined)
204+
// If neither one is more specific, check for semantic conflicts.
205+
const specificSet = this.findSpecificOverrideSet(first, second)
206+
if (specificSet !== undefined) {
207+
// One contains the other, so no conflict
208+
return false
209+
}
210+
211+
// The override sets are structurally incomparable, but this doesn't necessarily
212+
// mean they conflict. We need to check if they have conflicting version requirements
213+
// for any package that appears in both rulesets.
214+
return this.haveConflictingRules(first, second)
215+
}
216+
217+
static haveConflictingRules (first, second) {
218+
// Get all rules from both override sets
219+
const firstRules = first.ruleset
220+
const secondRules = second.ruleset
221+
222+
// Check each package that appears in both rulesets
223+
for (const [key, firstRule] of firstRules) {
224+
const secondRule = secondRules.get(key)
225+
if (!secondRule) {
226+
// Package only appears in one ruleset, no conflict
227+
continue
228+
}
229+
230+
// Same rule object means no conflict
231+
if (firstRule === secondRule || firstRule.isEqual(secondRule)) {
232+
continue
233+
}
234+
235+
// Both rulesets have rules for this package with different values.
236+
// Check if the version requirements are actually incompatible.
237+
const firstValue = firstRule.value
238+
const secondValue = secondRule.value
239+
240+
// If either value is a reference (starts with $), we can't determine
241+
// compatibility here - the reference might resolve to compatible versions.
242+
// We defer to runtime resolution rather than failing early.
243+
if (firstValue.startsWith('$') || secondValue.startsWith('$')) {
244+
continue
245+
}
246+
247+
// Check if the version ranges are compatible using semver
248+
// If both specify version ranges, they conflict only if they have no overlap
249+
try {
250+
const firstSpec = npa(`${firstRule.name}@${firstValue}`)
251+
const secondSpec = npa(`${secondRule.name}@${secondValue}`)
252+
253+
// For range/version types, check if they intersect
254+
if ((firstSpec.type === 'range' || firstSpec.type === 'version') &&
255+
(secondSpec.type === 'range' || secondSpec.type === 'version')) {
256+
// Check if the ranges intersect
257+
const firstRange = firstSpec.fetchSpec
258+
const secondRange = secondSpec.fetchSpec
259+
260+
// If the ranges don't intersect, we have a real conflict
261+
if (!semver.intersects(firstRange, secondRange)) {
262+
log.silly('Found conflicting override rules', {
263+
package: firstRule.name,
264+
first: firstValue,
265+
second: secondValue,
266+
})
267+
return true
268+
}
269+
}
270+
// For other types (git, file, directory, tag), we can't easily determine
271+
// compatibility, so we conservatively assume no conflict
272+
} catch {
273+
// If we can't parse the specs, conservatively assume no conflict
274+
// Real conflicts will be caught during dependency resolution
275+
}
276+
}
277+
278+
// No conflicting rules found
279+
return false
206280
}
207281
}
208282

workspaces/arborist/test/node.js

Lines changed: 192 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3277,48 +3277,212 @@ t.test('should propagate the new override set to the target node', t => {
32773277
t.end()
32783278
})
32793279

3280-
t.test('should find inconsistency between the edge\'s override set and the target\'s override set', t => {
3281-
const tree = new Node({
3282-
loadOverrides: true,
3283-
path: '/root',
3284-
pkg: {
3285-
name: 'root',
3286-
version: '1.0.0',
3287-
dependencies: {
3288-
mockDep: '1.x',
3280+
t.test('override conflict detection with semantic comparison', t => {
3281+
t.test('non-conflicting different override sets should be valid', t => {
3282+
// Regression test for issue #8688
3283+
// This validates that the improved semantic conflict detection allows
3284+
// structurally different override sets that don't actually conflict.
3285+
3286+
// Create two different override sets (simulating Vaadin's structure)
3287+
// These override different packages, so they don't conflict
3288+
const overridesComponents = new OverrideSet({
3289+
overrides: {
3290+
'@vaadin/react-components': '24.9.2',
32893291
},
3292+
})
3293+
3294+
const overridesComponentsPro = new OverrideSet({
32903295
overrides: {
3291-
mockDep: '2.x',
3296+
'@vaadin/react-components-pro': '24.9.2',
32923297
},
3293-
},
3294-
children: [{
3295-
name: 'mockDep',
3296-
version: '2.0.0',
3298+
})
3299+
3300+
const tree = new Node({
3301+
loadOverrides: true,
3302+
path: '/root',
32973303
pkg: {
3304+
name: 'root',
3305+
version: '1.0.0',
32983306
dependencies: {
3299-
subDep: '1.0.0',
3307+
mockDep: '1.x',
3308+
},
3309+
overrides: {
3310+
mockDep: '2.x',
33003311
},
33013312
},
33023313
children: [{
3303-
name: 'subDep',
3304-
version: '1.0.0',
3305-
pkg: {},
3314+
name: 'mockDep',
3315+
version: '2.0.0',
3316+
pkg: {
3317+
dependencies: {
3318+
subDep: '1.0.0',
3319+
},
3320+
},
3321+
children: [{
3322+
name: 'subDep',
3323+
version: '1.0.0',
3324+
pkg: {},
3325+
}],
33063326
}],
3307-
}],
3327+
})
3328+
3329+
const edge = tree.edgesOut.get('mockDep')
3330+
3331+
// Manually set an override to the edge
3332+
edge.overrides = overridesComponents
3333+
3334+
// Override satisfiedBy so it returns true, ensuring the conflict branch is reached
3335+
edge.satisfiedBy = () => true
3336+
3337+
// Set different but non-conflicting override on the target node
3338+
const mockDep = tree.children.get('mockDep')
3339+
mockDep.overrides = overridesComponentsPro
3340+
3341+
// Force edge to recalculate
3342+
edge.reload(true)
3343+
3344+
// The edge should be valid despite different override contexts
3345+
// because they don't have conflicting version requirements
3346+
t.equal(edge.error, null, 'Edge should be valid with non-conflicting override contexts')
3347+
t.ok(edge.valid, 'Edge.valid should be true')
3348+
3349+
t.end()
33083350
})
33093351

3310-
// Force edge.override to a conflicting object so that it will differ from
3311-
// the computed override coming from the parent's override set.
3312-
const conflictingOverride = new OverrideSet({
3313-
overrides: { mockDep: '1.x' },
3352+
t.test('conflicting override sets should be detected as invalid', t => {
3353+
// This validates that actual conflicts ARE still caught by the semantic detection
3354+
3355+
// Create two override sets with conflicting version requirements for the same package
3356+
const overridesV1 = new OverrideSet({
3357+
overrides: {
3358+
lodash: '1.x',
3359+
},
3360+
})
3361+
3362+
const overridesV4 = new OverrideSet({
3363+
overrides: {
3364+
lodash: '4.x',
3365+
},
3366+
})
3367+
3368+
const tree = new Node({
3369+
loadOverrides: true,
3370+
path: '/root',
3371+
pkg: {
3372+
name: 'root',
3373+
version: '1.0.0',
3374+
dependencies: {
3375+
mockDep: '1.x',
3376+
},
3377+
overrides: {
3378+
mockDep: '2.x',
3379+
},
3380+
},
3381+
children: [{
3382+
name: 'mockDep',
3383+
version: '2.0.0',
3384+
pkg: {
3385+
dependencies: {
3386+
lodash: '1.0.0',
3387+
},
3388+
},
3389+
children: [{
3390+
name: 'lodash',
3391+
version: '1.0.0',
3392+
pkg: {},
3393+
}],
3394+
}],
3395+
})
3396+
3397+
const edge = tree.edgesOut.get('mockDep')
3398+
const mockDep = tree.children.get('mockDep')
3399+
3400+
// Manually set conflicting overrides
3401+
edge.overrides = overridesV1
3402+
mockDep.overrides = overridesV4
3403+
3404+
// Override satisfiedBy so it returns true, ensuring the conflict branch is reached
3405+
edge.satisfiedBy = () => true
3406+
3407+
// Clear the cached error by calling reload(true)
3408+
edge.reload(true)
3409+
3410+
// Re-set the overrides after reload (since reload may have changed them)
3411+
edge.overrides = overridesV1
3412+
mockDep.overrides = overridesV4
3413+
3414+
// The edge should be INVALID due to conflicting override requirements
3415+
t.equal(edge.error, 'INVALID', 'Edge should be invalid with conflicting override versions')
3416+
t.notOk(edge.valid, 'Edge.valid should be false')
3417+
3418+
t.end()
33143419
})
3315-
const edge = tree.edgesOut.get('mockDep')
3316-
edge.overrides = conflictingOverride
33173420

3318-
// Override satisfiedBy so it returns true, ensuring the conflict branch is reached
3319-
edge.satisfiedBy = () => true
3421+
t.test('reference overrides should not cause false positives', t => {
3422+
// This validates that reference overrides ($syntax) don't trigger false positives
3423+
3424+
const overridesRef1 = new OverrideSet({
3425+
overrides: {
3426+
lodash: '$some-reference',
3427+
},
3428+
})
33203429

3321-
t.equal(tree.edgesOut.get('mockDep').error, 'INVALID', 'Edge should be marked INVALID due to conflicting overrides')
3430+
const overridesRef2 = new OverrideSet({
3431+
overrides: {
3432+
lodash: '$another-reference',
3433+
},
3434+
})
3435+
3436+
const tree = new Node({
3437+
loadOverrides: true,
3438+
path: '/root',
3439+
pkg: {
3440+
name: 'root',
3441+
version: '1.0.0',
3442+
dependencies: {
3443+
mockDep: '1.x',
3444+
},
3445+
overrides: {
3446+
mockDep: '2.x',
3447+
},
3448+
},
3449+
children: [{
3450+
name: 'mockDep',
3451+
version: '2.0.0',
3452+
pkg: {
3453+
dependencies: {
3454+
lodash: '4.0.0',
3455+
},
3456+
},
3457+
children: [{
3458+
name: 'lodash',
3459+
version: '4.0.0',
3460+
pkg: {},
3461+
}],
3462+
}],
3463+
})
3464+
3465+
const edge = tree.edgesOut.get('mockDep')
3466+
3467+
// Set reference overrides
3468+
edge.overrides = overridesRef1
3469+
3470+
// Override satisfiedBy so it returns true, ensuring the conflict branch is reached
3471+
edge.satisfiedBy = () => true
3472+
3473+
const mockDep = tree.children.get('mockDep')
3474+
mockDep.overrides = overridesRef2
3475+
3476+
// Force edge to recalculate
3477+
edge.reload(true)
3478+
3479+
// Reference overrides should not cause conflicts because we can't determine
3480+
// their compatibility at this stage - they might resolve to the same version
3481+
t.equal(edge.error, null, 'Edge should be valid with reference overrides')
3482+
t.ok(edge.valid, 'Edge.valid should be true with reference overrides')
3483+
3484+
t.end()
3485+
})
33223486

33233487
t.end()
33243488
})

0 commit comments

Comments
 (0)