Skip to content

fix: subset false-positive with a prerelease eq and a differing bound#889

Open
spokodev wants to merge 1 commit into
npm:mainfrom
spokodev:fix/subset-prerelease-eq-false-positive
Open

fix: subset false-positive with a prerelease eq and a differing bound#889
spokodev wants to merge 1 commit into
npm:mainfrom
spokodev:fix/subset-prerelease-eq-false-positive

Conversation

@spokodev

Copy link
Copy Markdown

What

subset(sub, dom) returns true (claims sub ⊆ dom) when it is actually false. It triggers when sub combines an exact prerelease comparator (=X.Y.Z-pre) with a >/>=/</<= bound whose version has a different [major, minor, patch] tuple — a subset false-positive (the dangerous direction):

semver.subset('=1.1.2-alpha <3.1.0', '<1.0.0') // true, must be false

// proof: a version in sub is not in dom, so sub can't be a subset of dom
semver.satisfies('1.1.2-alpha', '=1.1.2-alpha <3.1.0') // true  (in sub)
semver.satisfies('1.1.2-alpha', '<1.0.0')              // false (not in dom)

semver.subset('<3.1.0-0 1.1.2-alpha', '~2.0')  // true, must be false

Adding the <3.1.0 bound (which 1.1.2-alpha satisfies) can only shrink sub, so it should never turn a non-subset into a subset — a monotonicity violation.

Root cause

In simpleSubset, the eqSet-vs-bound checks used satisfies(eq, String(gt), options). satisfies builds a full Range and re-applies node-semver's prerelease-exclusion gating, so a prerelease eq (1.1.2-alpha) is judged not to satisfy a plain bound of a different tuple (<3.1.0). The code then treats the eqSet as inconsistent with sub and returns null (a null set), which subset reports as a subset of everything. In reality the eq version does satisfy sub, because its own =eq comparator admits its prerelease.

This is the same "re-applying full-range prerelease gating on an isolated comparator" issue that PR #867 fixed on the dom side (subset.js:177, :195), but it was left in place on the sub (eqSet) side (:127, :131).

Fix

Test the eq version against the raw bound comparator (gt.test(eq) / lt.test(eq)), mirroring PR #867.

Tests

Added two subset cases (=1.1.2-alpha <3.1.0 ⊄ <1.0.0, <3.1.0-0 1.1.2-alpha ⊄ ~2.0). Both fail on main and pass with the fix; issue #757's case (^10.2.0-beta.2 ⊂ ^10.2.0-beta.1) stays correct and the ranges tests pass.

`subset(sub, dom)` returned `true` when `sub` combined an exact prerelease
comparator (`=X.Y.Z-pre`) with a `>`/`>=`/`<`/`<=` bound of a different
`[major,minor,patch]` tuple, even though `sub` contains a version outside
`dom`:

  subset('=1.1.2-alpha <3.1.0', '<1.0.0')      // true, must be false
  satisfies('1.1.2-alpha', '=1.1.2-alpha <3.1.0') // true  (in sub)
  satisfies('1.1.2-alpha', '<1.0.0')              // false (not in dom)

In `simpleSubset`, the eqSet-vs-bound checks used
`satisfies(eq, String(gt), options)`, which rebuilds a full Range and
re-applies node-semver's prerelease-exclusion gating, so a prerelease `eq`
is judged not to satisfy a plain bound of another tuple. The code then
treats the eqSet as inconsistent and returns `null` (null set), which
`subset` reports as a subset of everything.

Test the eq version against the raw bound comparator instead
(`gt.test(eq)` / `lt.test(eq)`) — the same fix PR npm#867 applied to the
dom-side checks, which this left in place on the eqSet side.
@spokodev
spokodev requested a review from a team as a code owner July 23, 2026 13:05
Sign up for free to 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