From 3f06bbe8267b4c03cfd60888bbd0bee1bbf78eeb Mon Sep 17 00:00:00 2001 From: magiccwarss Date: Sun, 8 Nov 2026 14:30:38 +0400 Subject: [PATCH 1/3] fix: recognize upper-bound-only subsets Upper-bound-only ranges still have a minimum valid version. Normalize that bound before comparing ranges, and compare exclusive stable upper bounds as prerelease-zero bounds when prereleases are excluded. Fixes #949 --- ranges/subset.js | 47 +++++++++++++++++++++++++------------------ test/ranges/subset.js | 32 +++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 20 deletions(-) diff --git a/ranges/subset.js b/ranges/subset.js index a9498323..e8da7c28 100644 --- a/ranges/subset.js +++ b/ranges/subset.js @@ -23,6 +23,7 @@ const compare = require('../functions/compare.js') // - If EQ is more than one, return true (null set) // - Let GT be the highest > or >= comparator in c // - Let LT be the lowest < or <= comparator in c +// - If LT is set without GT or EQ, use the lowest admitted version for GT // - If GT and LT, and GT.semver > LT.semver, return true (null set) // - If any C is a = range, and GT or LT are set, return false // - If EQ @@ -112,6 +113,19 @@ const simpleSubset = (sub, dom, options) => { return null } + if (lt && !gt && eqSet.size === 0) { + // An omitted lower bound still starts at the lowest admitted version. + const includesZeroPrerelease = options.includePrerelease || + (lt.semver.prerelease.length && lt.semver.major === 0 && + lt.semver.minor === 0 && lt.semver.patch === 0) + gt = (includesZeroPrerelease ? minimumVersionWithPreRelease : minimumVersion)[0] + + // No prereleases of this tuple are admitted by an exclusive stable bound. + if (!options.includePrerelease && lt.operator === '<' && !lt.semver.prerelease.length) { + lt = new Comparator(`${lt.value}-0`, options) + } + } + let gtltComp if (gt && lt) { gtltComp = compare(gt.semver, lt.semver, options) @@ -142,7 +156,7 @@ const simpleSubset = (sub, dom, options) => { } let higher, lower - let hasDomLT, hasDomGT + let hasDomLT // if the subset has a prerelease, we need a comparator in the superset // with the same tuple and a prerelease, or it's not a subset let needDomLTPre = lt && @@ -158,25 +172,22 @@ const simpleSubset = (sub, dom, options) => { } for (const c of dom) { - hasDomGT = hasDomGT || c.operator === '>' || c.operator === '>=' hasDomLT = hasDomLT || c.operator === '<' || c.operator === '<=' - if (gt) { - if (needDomGTPre) { - if (c.semver.prerelease && c.semver.prerelease.length && - c.semver.major === needDomGTPre.major && - c.semver.minor === needDomGTPre.minor && - c.semver.patch === needDomGTPre.patch) { - needDomGTPre = false - } + if (needDomGTPre) { + if (c.semver.prerelease && c.semver.prerelease.length && + c.semver.major === needDomGTPre.major && + c.semver.minor === needDomGTPre.minor && + c.semver.patch === needDomGTPre.patch) { + needDomGTPre = false } - if (c.operator === '>' || c.operator === '>=') { - higher = higherGT(gt, c, options) - if (higher === c && higher !== gt) { - return false - } - } else if (gt.operator === '>=' && !c.test(gt.semver)) { + } + if (c.operator === '>' || c.operator === '>=') { + higher = higherGT(gt, c, options) + if (higher === c && higher !== gt) { return false } + } else if (gt.operator === '>=' && !c.test(gt.semver)) { + return false } if (lt) { if (needDomLTPre) { @@ -208,10 +219,6 @@ const simpleSubset = (sub, dom, options) => { return false } - if (lt && hasDomGT && !gt && gtltComp !== 0) { - return false - } - // we needed a prerelease range in a specific tuple, but didn't get one // then this isn't a subset. eg >=1.2.3-pre is not a subset of >=1.0.0, // because it includes prereleases in the 1.2.3 tuple diff --git a/test/ranges/subset.js b/test/ranges/subset.js index c6de3570..5ebe0b98 100644 --- a/test/ranges/subset.js +++ b/test/ranges/subset.js @@ -24,6 +24,37 @@ const cases = [ ['^1.2.3-pre.0', '*', true, { includePrerelease: true }], ['1 || 2 || 3', '*', true], + // upper-bound-only ranges start at the lowest admitted version + ['<2.0.0', '*', true], + ['<2', '*', true], + ['<2.0.0', '>=0.0.0', true], + ['<2.0.0', '>=0.0.0-0', true], + ['<1.0.0', '0.x', true], + ['<01.0.0', '0.x', true, { loose: true }], + ['<=2.0.0', '*', true], + ['<2.0.0', '>0.0.0', false], + ['<2.0.0', '>=0.0.1', false], + ['<2.0.0', '>=1.0.0', false], + ['<2.0.0', '*', true, { includePrerelease: true }], + ['<2.0.0', '>=0.0.0', false, { includePrerelease: true }], + ['<2.0.0', '>0.0.0-0', false, { includePrerelease: true }], + ['<2.0.0', '>=0.0.0-0', true, { includePrerelease: true }], + ['<1.0.0', '0.x', false, { includePrerelease: true }], + ['<=1.0.0', '0.x', false], + ['<1.0.0', '<1.0.0-0', true], + ['<1.0.1', '0.x', false], + ['<1.0.0-0', '0.x', true], + ['<1.0.0-beta', '<1.0.0-beta.2', true], + ['>=1.0.0-alpha <1.0.0', '0.x', false], + ['<0.0.0-beta', '*', false], + ['<0.0.0-beta', '>=0.0.0-0', true], + ['<0.0.0-beta', '>0.0.0-0', false], + ['<0.0.1-beta', '>=0.0.0-0', false], + ['<0.1.0-beta', '>=0.0.0-0', false], + ['<0.0.0-0', '1.0.0', true], + ['<0.0.0', '1.0.0', true], + ['<=0.0.0', '0.0.0', true], + // prerelease edge cases ['^1.2.3-pre.0', '>=1.0.0', false], ['^1.2.3-pre.0', '>=1.0.0', true, { includePrerelease: true }], @@ -82,6 +113,7 @@ const cases = [ ['>=1.0.0 <2.0.0', '<2.0.0', true], ['>=1.0.0 <2.0.0', '>0.0.0', true], ['>=1.0.0 <=1.0.0', '1.0.0', true], + ['>=1.0.0 <=2.0.0', '1.0.0', false], ['>=1.0.0 <=1.0.0', '2.0.0', false], ['<2.0.0', '>=1.0.0 <2.0.0', false], ['>=1.0.0', '>=1.0.0 <2.0.0', false], From 976dfee382659992db065bf8746152879a5f9164 Mon Sep 17 00:00:00 2001 From: magiccwarss Date: Sun, 8 Nov 2026 14:37:59 +0400 Subject: [PATCH 2/3] fix: ignore empty upper-bound subset branches --- ranges/subset.js | 2 +- test/ranges/subset.js | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/ranges/subset.js b/ranges/subset.js index e8da7c28..a7fa6c2e 100644 --- a/ranges/subset.js +++ b/ranges/subset.js @@ -50,9 +50,9 @@ const subset = (sub, dom, options = {}) => { sub = new Range(sub, options) dom = new Range(dom, options) - let sawNonNull = false OUTER: for (const simpleSub of sub.set) { + let sawNonNull = false for (const simpleDom of dom.set) { const isSub = simpleSubset(simpleSub, simpleDom, options) sawNonNull = sawNonNull || isSub !== null diff --git a/test/ranges/subset.js b/test/ranges/subset.js index 5ebe0b98..8669a3e3 100644 --- a/test/ranges/subset.js +++ b/test/ranges/subset.js @@ -55,6 +55,20 @@ const cases = [ ['<0.0.0', '1.0.0', true], ['<=0.0.0', '0.0.0', true], + // empty upper-bound-only branches do not change a union + ['<1.0.0 || <0.0.0', '<2.0.0', true], + ['<0.0.0 || <1.0.0', '<2.0.0', true], + ['<1.0.0 || <0.0.0', '<0.5.0 || <2.0.0', true], + ['<0.0.0 || <1.0.0', '<0.5.0 || <2.0.0', true], + ['<0.0.0 || <1.0.0 || <0.0.0', '<2.0.0', true], + ['<1.0.0 || <0.0.0', '<0.5.0', false], + ['<0.0.0 || <1.0.0', '<0.5.0', false], + ['<0.0.0 || <0.0.0', '1.0.0', true], + ['<=0.0.0 || <0.0.0', '0.0.0', true], + ['<0.0.0 || <=0.0.0', '0.0.0', true], + ['<01.0.0 || <00.0.0', '<2.0.0', true, { loose: true }], + ['<1.0.0 || <0.0.0', '>=0.0.0 <2.0.0', false, { includePrerelease: true }], + // prerelease edge cases ['^1.2.3-pre.0', '>=1.0.0', false], ['^1.2.3-pre.0', '>=1.0.0', true, { includePrerelease: true }], From fd4f5208b882f49aecdc74be8bd8b3831fb9ab08 Mon Sep 17 00:00:00 2001 From: magiccwarss Date: Sun, 8 Nov 2026 23:58:46 +0400 Subject: [PATCH 3/3] docs: explain subset bound invariants --- ranges/subset.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ranges/subset.js b/ranges/subset.js index a7fa6c2e..7c893d83 100644 --- a/ranges/subset.js +++ b/ranges/subset.js @@ -24,6 +24,8 @@ const compare = require('../functions/compare.js') // - Let GT be the highest > or >= comparator in c // - Let LT be the lowest < or <= comparator in c // - If LT is set without GT or EQ, use the lowest admitted version for GT +// - When not in prerelease mode, replace an exclusive stable LT ` LT.semver, return true (null set) // - If any C is a = range, and GT or LT are set, return false // - If EQ @@ -171,6 +173,8 @@ const simpleSubset = (sub, dom, options) => { needDomLTPre = false } + // EQ cases returned above; ANY and LT-only ranges now have a lower bound, + // so gt is always set here. for (const c of dom) { hasDomLT = hasDomLT || c.operator === '<' || c.operator === '<=' if (needDomGTPre) {