From ab7fe3106134f66a051db10c7c38befc873f08ad Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Sat, 4 Jul 2026 23:47:27 +0200 Subject: [PATCH] fix: intersects() false-negative for <0.0.0- comparators --- classes/comparator.js | 3 +- test/fixtures/comparator-intersection.js | 9 +++++ test/fixtures/range-intersection.js | 6 +++ test/ranges/intersects.js | 49 ++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) diff --git a/classes/comparator.js b/classes/comparator.js index 647c1f09..b9df8d7c 100644 --- a/classes/comparator.js +++ b/classes/comparator.js @@ -100,8 +100,9 @@ class Comparator { (this.value === '<0.0.0-0' || comp.value === '<0.0.0-0')) { return false } + const isNullSet = v => v === '<0.0.0' || v === '<0.0.0-0' if (!options.includePrerelease && - (this.value.startsWith('<0.0.0') || comp.value.startsWith('<0.0.0'))) { + (isNullSet(this.value) || isNullSet(comp.value))) { return false } diff --git a/test/fixtures/comparator-intersection.js b/test/fixtures/comparator-intersection.js index 08c4bc08..9ca7d1a9 100644 --- a/test/fixtures/comparator-intersection.js +++ b/test/fixtures/comparator-intersection.js @@ -41,4 +41,13 @@ module.exports = [ ['<0.1.0', '<0.0.0-0', false], ['<0.0.0-0', '<0.1.0', false, true], ['<0.1.0', '<0.0.0-0', false, true], + // A `<0.0.0-` comparator (with a non-minimal prerelease) is NOT the empty + // set: it matches prereleases below `` such as `0.0.0-0`, so it can + // intersect other ranges. Only `<0.0.0` and `<0.0.0-0` match nothing. See #521. + ['<0.0.0-rc.1', '>=0.0.0-alpha.0', true], + ['<0.0.0-rc.1', '>0.0.0-alpha.0', true], + ['<0.0.0-beta', '>=0.0.0-alpha', true], + ['<0.0.0-alpha', '<0.0.0-rc.1', true], + ['<0.0.0', '>=0.0.0-alpha.0', false], + ['<0.0.0', '>0.0.0-alpha.0', false], ] diff --git a/test/fixtures/range-intersection.js b/test/fixtures/range-intersection.js index 217bee22..41e21fb0 100644 --- a/test/fixtures/range-intersection.js +++ b/test/fixtures/range-intersection.js @@ -58,4 +58,10 @@ module.exports = [ ['1.x', '1.3.0 || <1.0.0 >2.0.0', true], ['*', '*', true], ['x', '', true], + // `<0.0.0-` matches prereleases below ``, so it can intersect ranges + // that also admit those prereleases; only `<0.0.0`/`<0.0.0-0` are empty. #521 + ['<0.0.0-rc.1', '~0.0.0-alpha.0', true], + ['<0.0.0-rc.1 <=0.3.2-0', '~0.0.0-alpha.0 || 1', true], + ['<0.0.0', '0.x', false], + ['<0.0.0', '>=0.0.0-alpha.0', false], ] diff --git a/test/ranges/intersects.js b/test/ranges/intersects.js index e3d9c88c..88df093c 100644 --- a/test/ranges/intersects.js +++ b/test/ranges/intersects.js @@ -2,6 +2,7 @@ const { test } = require('tap') const intersects = require('../../ranges/intersects') +const satisfies = require('../../functions/satisfies') const Range = require('../../classes/range') const Comparator = require('../../classes/comparator') const comparatorIntersection = require('../fixtures/comparator-intersection.js') @@ -58,3 +59,51 @@ test('missing comparator parameter in intersect comparators', (t) => { 'throws type error') t.end() }) + +// Differential oracle: if a concrete version satisfies BOTH ranges, then the +// ranges MUST intersect. This densely samples the near-zero / prerelease space +// where `<0.0.0-` comparators live, guarding against the over-broad +// `startsWith('<0.0.0')` empty-range check that wrongly treated e.g. +// `<0.0.0-rc.1` (which matches `0.0.0-0`, `0.0.0-alpha`, ...) as matching +// nothing, producing false negatives from `intersects`. +test('intersects agrees with pointwise satisfaction', t => { + const nums = [0, 1, 2] + const pres = ['', '-0', '-alpha', '-alpha.0', '-alpha.1', '-beta', '-rc.1', '-rc.2'] + const grid = [] + for (const a of nums) { + for (const b of nums) { + for (const c of nums) { + for (const p of pres) { + grid.push(`${a}.${b}.${c}${p}`) + } + } + } + } + + const anchors = ['0.0.0', '0.0.0-0', '0.0.0-alpha', '0.0.0-rc.1', + '0.1.0-alpha', '1.0.0-alpha', '1.0.0'] + const comparators = [] + for (const op of ['<', '<=', '>', '>=', '']) { + for (const v of anchors) { + comparators.push(`${op}${v}`) + } + } + + let witnessBacked = 0 + for (const opts of [{}, { includePrerelease: true }]) { + for (const a of comparators) { + for (const b of comparators) { + const witness = grid.find(v => satisfies(v, a, opts) && satisfies(v, b, opts)) + if (witness === undefined) { + continue + } + witnessBacked++ + const label = `${JSON.stringify(opts)}: ${witness} satisfies both` + t.equal(intersects(a, b, opts), true, `${a} ∩ ${b} (${label})`) + t.equal(intersects(b, a, opts), true, `${b} ∩ ${a} symmetry (${label})`) + } + } + } + t.ok(witnessBacked > 100, `exercised ${witnessBacked} witness-backed pairs`) + t.end() +})