diff --git a/ranges/subset.js b/ranges/subset.js index a9498323..51dd4f14 100644 --- a/ranges/subset.js +++ b/ranges/subset.js @@ -64,6 +64,11 @@ const subset = (sub, dom, options = {}) => { // then we know this isn't a subset, but if EVERY simple range was null, // then it is a subset. if (sawNonNull) { + // When the dom has multiple OR-branches, check if their union covers + // the sub range even though no single branch does. + if (dom.set.length > 1 && unionCovers(simpleSub, dom.set, options)) { + continue OUTER + } return false } } @@ -246,4 +251,274 @@ const lowerLT = (a, b, options) => { : a } +// Extract the [gt, lt] bounds from a simple comparator set. +// Returns { gt, lt } where gt/lt are comparators or null. +// Returns null if the set is a null set (inconsistent bounds or multi-eq). +const extractBounds = (set, options) => { + if (set.length === 1 && set[0].semver === ANY) { + if (options.includePrerelease) { + return { gt: null, lt: null } + } + set = minimumVersion + } + + const eqSet = new Set() + let gt = null + let lt = null + for (const c of set) { + if (c.operator === '>' || c.operator === '>=') { + gt = higherGT(gt, c, options) + } else if (c.operator === '<' || c.operator === '<=') { + lt = lowerLT(lt, c, options) + } else { + eqSet.add(c.semver) + } + } + + if (gt && lt) { + const cmp = compare(gt.semver, lt.semver, options) + if (cmp > 0) { + return null + } + if (cmp === 0 && (gt.operator !== '>=' || lt.operator !== '<=')) { + return null + } + } + + // eq-pinned sets without gt/lt bounds are skipped (treated as null) since + // they're point intervals handled separately for sub ranges. + // eq sets with inconsistent gt/lt are also null sets. + if (eqSet.size > 0) { + return null + } + + return { gt, lt } +} + +// Check if two intervals are adjacent: the upper bound of interval a touches +// the lower bound of interval b with no gap (in terms of release versions). +// In non-prerelease mode, =X.Y.Z are adjacent because +// no release version exists between them. +const boundsAdjacent = (aLt, bGt, options) => { + if (!bGt) { + return true // dom has no lower bound → always overlaps + } + + const cmp = compare(aLt.semver, bGt.semver, options) + + if (cmp > 0) { + return true // overlap + } + if (cmp < 0) { + return false // gap + } + + // Same version: only < and > leaves a gap (exactly one version) + if (aLt.operator === '<' && bGt.operator === '>') { + return false + } + return true +} + +// In non-prerelease mode, =X.Y.Z are adjacent +// because no release version can exist between them. +const isAdjacentPrerelease = (aLt, bGt, options) => { + if (options.includePrerelease) { + return false + } + // aLt is =V (same major.minor.patch) + if (aLt.operator === '<' && + aLt.semver.prerelease.length === 1 && + aLt.semver.prerelease[0] === 0 && + bGt.operator === '>=' && + bGt.semver.prerelease.length === 0 && + aLt.semver.major === bGt.semver.major && + aLt.semver.minor === bGt.semver.minor && + aLt.semver.patch === bGt.semver.patch) { + return true + } + return false +} + +// Check whether the sub comparator set's lower bound is covered by a dom +// interval's lower bound (i.e., domGt <= subGt). +const gtCovers = (subGt, domGt, options) => { + if (!domGt) { + return true // dom unbounded below + } + if (!subGt) { + return false // sub unbounded below, dom is not + } + const cmp = compare(subGt.semver, domGt.semver, options) + if (cmp > 0) { + return true + } + if (cmp < 0) { + return false + } + // Same version: >= is wider than > + if (domGt.operator === '>=' || subGt.operator === '>') { + return true + } + return subGt.operator === domGt.operator +} + +// Check whether the sub comparator set's upper bound is covered by a dom +// interval's upper bound (i.e., domLt >= subLt). +// Note: in the sweep, domLt (coverage) is never null since we return early +// when dom.lt is null. +const ltCovers = (subLt, domLt, options) => { + if (!subLt) { + return false // sub unbounded above, dom is not + } + const cmp = compare(subLt.semver, domLt.semver, options) + if (cmp < 0) { + return true + } + if (cmp > 0) { + // In non-prerelease mode, { + // Check for eq-pinned sub (e.g., "2.0.0" as a simple range). + // If the eq version doesn't satisfy any single dom set (checked by + // simpleSubset), it won't satisfy the union either. + const subEqs = simpleSub.filter(c => c.operator === '' && c.semver !== ANY) + if (subEqs.length > 0) { + return false + } + + const subBounds = extractBounds(simpleSub, options) + + // Check prerelease admission: if sub has prerelease bounds and we're not in + // includePrerelease mode, verify that dom has matching prerelease tuples + if (!options.includePrerelease) { + if (subBounds.gt && subBounds.gt.semver && + subBounds.gt.semver !== ANY && + subBounds.gt.semver.prerelease && subBounds.gt.semver.prerelease.length) { + return false + } + if (subBounds.lt && subBounds.lt.semver && + subBounds.lt.semver !== ANY && + subBounds.lt.semver.prerelease && subBounds.lt.semver.prerelease.length) { + // exception: { + const aVer = a.gt ? a.gt.semver : null + const bVer = b.gt ? b.gt.semver : null + if (!aVer && !bVer) { + return 0 + } + if (!aVer) { + return -1 + } + if (!bVer) { + return 1 + } + const cmp = compare(aVer, bVer, options) + // If same version, >= sorts before > + if (cmp !== 0) { + return cmp + } + if (a.gt.operator === '>=' && b.gt.operator === '>') { + return -1 + } + /* istanbul ignore next */ + if (a.gt.operator === '>' && b.gt.operator === '>=') { + return 1 + } + /* istanbul ignore next */ + return 0 + }) + + // Sweep: start at sub's lower bound, greedily extend coverage + let coverage = subBounds.gt // current coverage ends here (as a gt bound) + let coverageIsStart = true + + for (const dom of domIntervals) { + // Check if this dom interval's lower bound is within current coverage + if (coverageIsStart) { + // First interval must cover sub's lower bound + if (!gtCovers(subBounds.gt, dom.gt, options)) { + continue + } + } else { + // Subsequent intervals must be adjacent to or overlap with coverage + const adjacent = boundsAdjacent(coverage, dom.gt, options) || + isAdjacentPrerelease(coverage, dom.gt, options) + if (!adjacent) { + // Check if there's a gap + continue + } + } + + // Extend coverage to this dom interval's upper bound + if (!dom.lt) { + // Dom extends to +∞, we're done + return true + } + + if (coverageIsStart) { + coverage = dom.lt + coverageIsStart = false + } else { + // Take the higher upper bound, <= is wider than < + const cmp = compare(dom.lt.semver, coverage.semver, options) + /* istanbul ignore next */ + if (cmp > 0 || + (cmp === 0 && dom.lt.operator === '<=' && coverage.operator === '<')) { + coverage = dom.lt + } + } + + // Check if coverage already covers sub's upper bound + if (ltCovers(subBounds.lt, coverage, options)) { + return true + } + } + + // Check final coverage is handled in the sweep's mid-check + return false +} + module.exports = subset diff --git a/test/ranges/subset.js b/test/ranges/subset.js index c6de3570..c08520b9 100644 --- a/test/ranges/subset.js +++ b/test/ranges/subset.js @@ -104,6 +104,159 @@ const cases = [ ['>=3 >=2 >=1', '>0', true], ['>=3 >=2 >=1', '>=3 >=2 >=1', true], ['>2.0.0', '>=2.0.0', true], + + // Union subset: sub spans multiple dom OR-branches (#703) + ['>=17.2.0', '^17.2.0 || >17', true], + ['>=17.2.0', '^17.2.0 || >=18', true], + ['>=2.0.0', '^2 || ^3 || ^4 || >=5', true], + ['>=2.0.0', '^2 || >=3', true], + ['>=2.0.0 <5.0.0', '^2 || ^3 || ^4', true], + + // Union subset with gap → false + ['>=2.0.0', '^2 || ^4', false], + ['>=2.0.0 <6.0.0', '^2 || ^4 || ^5', false], + + // Union: sub unbounded above, dom union unbounded + ['>=1.0.0', '^1 || >=2.0.0', true], + + // Union: sub bounded, dom union overlapping + ['>=1.0.0 <4.0.0', '^1 || ^2 || ^3', true], + + // Union: dom branches overlap + ['>=1.0.0 <3.0.0', '>=1.0.0 <2.5.0 || >=2.0.0 <3.0.0', true], + + // Union: eq-pinned sub in union dom + ['2.0.0', '^1 || ^2', true], + + // Union: eq-pinned sub NOT in any dom branch + ['5.0.0', '^1 || ^2', false], + + // Union: single dom branch (no union benefit) + ['>=1.0.0', '^1', false], + + // Union: * dom with sub + ['>=1.0.0', '^1 || *', true], + + // Union: sub has prerelease in non-prerelease mode → no union fallback + ['^1.2.3-pre.0', '^1 || ^2', false], + + // Union: sub with lt prerelease -0 (equivalent to plain lt) + ['>=1.0.0 <2.0.0-0', '^1 || ^2', true], + + // Union: includePrerelease mode — gap at 2.0.0 prereleases + ['>=1.0.0', '^1 || >=2.0.0', false, { includePrerelease: true }], + // Union: includePrerelease mode — contiguous + ['>=1.0.0', '^1 || >=2.0.0-0', true, { includePrerelease: true }], + + // Union: null-set dom branches are skipped + ['>=2.0.0', '>5.0.0 <3.0.0 || >=1.0.0', true], + + // Union: sub is null set → subset of everything + ['>=5.0.0 <3.0.0', '^1 || ^2', true], + + // Union: dom has <= and >= that bridge + ['>=1.0.0 <=3.0.0', '>=1.0.0 <=2.0.0 || >=2.0.0 <=3.0.0', true], + + // Union: sub gt prerelease in non-prerelease mode → no union + ['>=1.0.0-beta', '^1 || >=2.0.0', false], + + // Union: sub lt prerelease (non -0) in non-prerelease mode → no union + ['>=1.0.0 <2.0.0-beta', '^1 || ^2', false], + + // Union: dom overlapping intervals + ['>=1.0.0 <5.0.0', '>=1.0.0 <3.0.0 || >=2.0.0 <5.0.0', true], + + // Union: dom intervals in wrong order get sorted + ['>=1.0.0', '>=3.0.0 || ^1 || ^2', true], + + // Union: > and >= at same version sort correctly + ['>=1.0.0 <=3.0.0', '>1.0.0 <3.0.0-0 || >=3.0.0 <=3.0.0', false], + + // Union: eq-pinned sub cannot match via union if simpleSubset failed + ['3.0.0', '^2 || ^4', false], + + // Union: all dom branches are null sets + ['>=1.0.0', '>5.0.0 <3.0.0 || >8.0.0 <6.0.0', false], + + // Union: dom intervals with <= upper bounds + ['>=1.0.0 <=3.0.0', '>=1.0.0 <=2.0.0 || >2.0.0 <=3.0.0', true], + + // Union: eq-pinned sub, not satisfying dom + ['1.5.0', '>2.0.0 || >5.0.0', false], + + // Union: sub null set with eq+gt inconsistency + ['5.0.0 >6.0.0', '^1 || ^2', true], + + // Union: sub with lt that is <= vs dom lt that is < + ['>=1.0.0 <=3.0.0', '^1 || >=2.0.0 <3.0.0', false], + + // Union: final coverage check at end of sweep + ['>=1.0.0 <4.0.0', '^1 || ^2', false], + + // Union: dom with no lower bound (covers gtCovers null path) + ['<4.0.0', '<3.0.0 || >=2.0.0', true], + + // Union: sub with no lower bound, dom intervals have lower bounds + ['<4.0.0', '>=2.0.0 <3.0.0 || >=3.0.0', false], + + // Union: dom intervals with same lower bound version, different operators + ['>=2.0.0 <4.0.0', '>=2.0.0 <3.0.0 || >2.0.0', true], + + // Union: coverage extension with <= at same version as current < + ['>=1.0.0 <=3.0.0', '>=1.0.0 <3.0.0 || >=2.0.0 <=3.0.0', true], + + // Union: sub <=X not covered by dom =1.0.0 <=3.0.0', '>=1.0.0 <3.0.0-0 || >=4.0.0', false], + + // Union: sub unbounded, dom first interval covers lower + ['>=1.0.0', '<3.0.0 || >=2.0.0', true], + + // Union: two dom intervals with no lower bound (covers compareBound both-null) + ['<6.0.0', '<3.0.0 || <5.0.0', false], + + // Union: dom set with eq comparator (skipped as point interval) + ['>=1.0.0 <5.0.0', '^1 || 3.0.0 || ^4', false], + + // Union: dom set with gt=lt=0 inconsistency (cmp=0, wrong operators) + ['>=1.0.0', '>2.0.0 <=2.0.0 || >=1.0.0', true], + + // Union: isAdjacentPrerelease called in includePrerelease mode + ['>=1.0.0', '>=1.0.0 <2.0.0-0 || >=2.0.0', false, { includePrerelease: true }], + + // Union: sub gtCovers same version + ['>=2.0.0', '>=2.0.0 <3.0.0-0 || >=3.0.0', true], + + // Union: * sub in includePrerelease mode (covers extractBounds ANY path) + ['*', '^1 || ^2', false, { includePrerelease: true }], + + // Union: * sub in non-includePrerelease mode (0.x not covered) + ['*', '^1 || >=2.0.0', false], + + // Union: dom with cmp===0 inconsistency (>X <=X is null set, gap remains) + ['>=1.0.0', '>2.0.0 <=2.0.0 || ^1 || >=3.0.0', false], + + // Union: isAdjacentPrerelease in includePrerelease (should not bridge gap) + ['>=1.0.0 <4.0.0', '>=1.0.0 <2.0.0-0 || >=2.0.0 <4.0.0', false, { includePrerelease: true }], + + // Union: gap at exact version with < and > operators + ['>=1.0.0 <5.0.0', '>=1.0.0 <3.0.0 || >3.0.0 <5.0.0', false], + + // Union: dom coverage exceeds sub upper bound (ltCovers cmp < 0) + ['>=1.0.0 <=3.0.0', '>=1.0.0 <=2.0.0 || >=2.0.0 <=4.0.0', true], + + // Union: sort with null-gt dom interval and non-null-gt dom interval + ['<5.0.0', '<4.0.0 || >=3.0.0', true], + // same but reversed dom order — forces sort to encounter null-gt + ['<5.0.0', '>=3.0.0 || <4.0.0', true], + // Union: two dom branches with identical lower bound triggers sort return-0 + ['>=2.0.0 <4.0.0', '>=2.0.0 <3.0.0 || >=2.0.0', true], + // Union: sort tiebreaker where > must sort after >= at same version + ['>2.0.0 <4.0.0', '>2.0.0 <3.0.0 || >=2.0.0', true], + // Union: coverage extension with <= wider than < at same version + ['>=1.0.0 <=3.0.0', '>=1.0.0 <3.0.0 || >=2.0.0 <=3.0.0', true], + + // Union: sort tiebreaker >= before > (reversed dom order) + ['>=2.0.0 <4.0.0', '>2.0.0 || >=2.0.0 <3.0.0', true], ] t.plan(cases.length + 1)