Skip to content

fix: intersects() misses shared prerelease versions - #884

Closed
MerlijnW70 wants to merge 1 commit into
npm:mainfrom
MerlijnW70:fix/intersects-prerelease-licensing
Closed

fix: intersects() misses shared prerelease versions#884
MerlijnW70 wants to merge 1 commit into
npm:mainfrom
MerlijnW70:fix/intersects-prerelease-licensing

Conversation

@MerlijnW70

Copy link
Copy Markdown

Summary

intersects() reports two ranges as non-overlapping even when they demonstrably share a prerelease version. It contradicts satisfies(): both ranges contain the version, yet intersects() says they don't intersect.

Reproduction

constsemver=require('semver')semver.intersects('^1.2.3-alpha','=1.2.3-alpha')// => false (should be true)semver.satisfies('1.2.3-alpha','^1.2.3-alpha')// => truesemver.satisfies('1.2.3-alpha','=1.2.3-alpha')// => true

1.2.3-alpha satisfies both ranges, so they clearly intersect. The same false negative occurs for:

semver.intersects('~1.2.3-alpha','=1.2.3-alpha')// => false (should be true)semver.intersects('>=1.2.3-alpha <2.0.0-0','1.2.3-alpha')// => false (should be true)

Root cause

Range.intersects (classes/range.js) decomposes the two ranges into independent comparator pairs and requires every pair to intersect:

thisComparators.every((thisComparator)=>rangeComparators.every((rangeComparator)=>thisComparator.intersects(rangeComparator,options)))

Prerelease licensing, however, is a set-level property. ^1.2.3-alpha desugars to >=1.2.3-alpha <2.0.0-0; the >=1.2.3-alpha comparator is what licenses the prerelease 1.2.3-alpha. When the exact comparator =1.2.3-alpha is paired with <2.0.0-0 in isolation, Comparator.intersects evaluates new Range('<2.0.0-0').test('1.2.3-alpha'), which is false (a single-comparator range applies the prerelease-tuple exclusion). The sibling >=1.2.3-alpha that would license the prerelease is not visible in that pair, so the whole intersection is wrongly reported empty.

Fix

The pairwise check only ever under-reports, so it stays as a sufficient condition. When it fails, recover the missed cases: if one set pins an exact version (a bare =x.y.z), the two sets share a version iff that version satisfies the whole other set — tested with testSet, which applies prerelease licensing across all of a set's comparators.

constcomparatorSetsIntersect=(a,b,options)=>{options=parseOptions(options)if(a.every((ac)=>b.every((bc)=>ac.intersects(bc,options)))){returntrue}constaExact=exactVersion(a)if(aExact){returntestSet(b,aExact,options)}constbExact=exactVersion(b)if(bExact){returntestSet(a,bExact,options)}returnfalse}

This is deliberately conservative:

  • Ranges with no exact pin fall straight through to the original pairwise result — unchanged.
  • The * / ANY short-circuit is unchanged.
  • A version differential against unmodified 7.8.5 over a matrix of range pairs (with and without includePrerelease) shows the fix flips only the genuine false negatives to true (each backed by a concrete shared version) and changes nothing else.
  • Soundness holds: across the same matrix, every intersects(...) === false still has no version satisfying both ranges.

Tests

Adds fixture cases in test/fixtures/range-intersection.js (exercised in both argument orderings) for the fixed cases and for the still-correct negatives (e.g. =1.2.3-alpha vs <2.0.0, which has no licensing sibling and must remain false). Full suite passes with 100% coverage and lint clean.

Range.intersects() decomposes two ranges into independent comparator
pairs and requires every pair to intersect. That loses set-level
prerelease licensing, so a range whose lower bound licenses a prerelease
was reported as not intersecting an exact match for that prerelease:
semver.intersects('^1.2.3-alpha', '=1.2.3-alpha') // false
semver.satisfies('1.2.3-alpha', '^1.2.3-alpha') // true
semver.satisfies('1.2.3-alpha', '=1.2.3-alpha') // true
Both ranges contain 1.2.3-alpha, yet intersects() reported no overlap.
The same happens for ~ ranges and the desugared '>=1.2.3-alpha <2.0.0-0'
form.
The pairwise test only ever under-reports, so keep it as a sufficient
condition and, when it fails, recover the missed cases: if one set pins
an exact version, the two sets share a version iff that version satisfies
the whole other set (tested with testSet, which applies prerelease
licensing). Ranges without an exact pin are unaffected, and the `*`
short-circuit is preserved.
@MerlijnW70
MerlijnW70 requested a review from a team as a code ownerJuly 2, 2026 14:32
@MerlijnW70MerlijnW70 closed this by deleting the head repository Jul 11, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@MerlijnW70