Skip to content

fix: don't let a null comparator set mask the minimum of another union branch - #896

Open
maximilliangrand wants to merge 1 commit into
npm:mainfrom
maximilliangrand:fix/min-version-union-null-set
Open

fix: don't let a null comparator set mask the minimum of another union branch#896
maximilliangrand wants to merge 1 commit into
npm:mainfrom
maximilliangrand:fix/min-version-union-null-set

Conversation

@maximilliangrand

Copy link
Copy Markdown

Minimal reproduction

Default options, no prereleases, one line:

const semver = require('semver')            // 7.8.5

semver.satisfies('3.0.0', '^1 ^2 || >=3')   // true  -- the range plainly has matching versions
semver.minVersion('^1 ^2 || >=3')           // null  <-- expected SemVer 3.0.0
semver.minVersion('>=3')                    // 3.0.0 -- the second branch on its own is fine

minVersion(range) is documented as "Return the lowest version that can match", so null means
"nothing can possibly match this range". Here a version clearly does match.

Same shape with any null-set first branch:

semver.minVersion('>=2 <1 || >=3')   // null, expected 3.0.0
semver.minVersion('1.x 2.x || >=3')  // null, expected 3.0.0
semver.minVersion('>=3 || ^1 ^2')    // null, expected 3.0.0  (order does not matter)

The rule: whenever a union contains a comparator set that is a null set (^1 ^2, >=2 <1,
1.x 2.x) and that null set's computed lower bound is lower than the lower bound of every
satisfiable branch, minVersion returns null for a range that has matching versions.

Reproduces on 7.8.5, 7.3.8 and 6.3.1, so it has been present since minVersion was introduced in #241.

Root cause

ranges/min-version.js:52-61. The loop derives a per-set candidate setMin and keeps the global
minimum across all sets, then validates once, at the end:

    if (setMin && (!minver || gt(minver, setMin))) {   // line 52
      minver = setMin
    }
  }

  if (minver && range.test(minver)) {                  // line 57 -- single, final validation
    return minver
  }

  return null                                          // line 61
}

setMin is computed while ignoring < / <= comparators ("Ignore maximum versions", line 45), so
an unsatisfiable set still yields a candidate: ^1 ^2 expands to
>=1.0.0 <2.0.0-0 >=2.0.0 <3.0.0-0 and produces 2.0.0. That candidate is lower than the 3.0.0
from the satisfiable >=3 branch, so it wins the gt() comparison, fails the single range.test()
at line 57, and the function returns null -- the usable branch's candidate was already discarded.

Worth noting: the existing suite already covers null sets inside a union, but only in the direction
where the null set's candidate is higher and therefore loses the gt() race:

['>=1.1.1 <2 || >=2.2.2 <2', '1.1.1'],
['>=2.2.2 <2 || >=1.1.1 <2', '1.1.1'],

This is the same situation with the inequality reversed, which is why it slipped through.

This is distinct from the earlier minVersion null bugs #330 / #340 (fixed by #341), which were
about ordering within a single comparator set. Those remain fixed --
minVersion('6 >=6.2.0 || 8 || >=9.3.0') returns 6.2.0 and minVersion('^2.16.2 ^2.16') returns
2.16.2 both before and after this change.

The fix

Validate each candidate against the range before it becomes the running minimum, instead of once
after the minimum has been picked:

    if (setMin && (!minver || gt(minver, setMin)) && range.test(setMin)) {
      minver = setMin
    }
  }

  return minver

The result is exactly the minimum over valid candidates, independent of set order: the gt guard
only ever skips candidates that are already >= the running minimum, and those can never become the
new minimum. Short-circuit ordering keeps this to at most one range.test() per comparator set, and
only when the candidate would actually lower the running minimum.

Genuinely impossible ranges still return null, because every candidate then fails range.test
('>4 <3', '^1 ^2 || >4 <3' -- both covered by tests).

Tests

Five tuples added to the existing table in test/ranges/min-version.js. On the unmodified main
source, assertions 43-46 fail and 47-48 pass:

    ok 42 - minVersion(>2 || >1.0.0-beta, false) = 1.0.0-beta.0
not ok 43 - minVersion(^1 ^2 || >=3, false) = 3.0.0
not ok 44 - minVersion(>=2 <1 || >=3, false) = 3.0.0
not ok 45 - minVersion(1.x 2.x || >=3, false) = 3.0.0
not ok 46 - minVersion(>=3 || ^1 ^2, false) = 3.0.0
    ok 47 - minVersion(>4 <3, false) = null
    ok 48 - minVersion(^1 ^2 || >4 <3, false) = null
# failed 1 test

With the fix all 48 pass. ['^1 ^2 || >4 <3', null] passes both ways -- it is a characterization
test guarding the no-false-positive direction, not a fail-then-pass case.

Full suite: 51/51 test files pass, 0 failures, on both the untouched baseline and the patched tree,
so there are no pre-existing failures to separate out. Coverage stays at 100% for
ranges/min-version.js and all files. npm run lint (eslint + template-oss-check) is clean.

How it surfaced

A property/differential harness rather than manual inspection. satisfies() is the spec-defining,
exhaustively tested core, so I used it as an oracle and brute-forced it over a fixed 512-version
universe (major/minor/patch in 0..3 x 8 prerelease tags). A deterministic LCG generated random
ranges (x-ranges, ^, ~, hyphen, bare/=/</<=/>/>=, 1-3 AND terms, 1-2 OR branches) and
checked the derived range algebra against that oracle -- among other properties: minVersion(r)
must satisfy r, must be <= every version that matches r, and must be non-null whenever any
version matches.

8 seeds x 4000 iterations = 32,000 generated range pairs per target, 175,865 property-check
invocations, ~4 min per target, run against published semver@7.8.5 and against the patched tree:

baseline {"minVersion-null-but-matches":84, "minVersion-too-high":354, "simplify-mismatch":277,
          "subset-throw":783, "intersects-throw":783, "subset-true-but-not-subset":12, ...}
patched  {                                  "minVersion-too-high":355, "simplify-mismatch":277,
          "subset-throw":783, "intersects-throw":783, "subset-true-but-not-subset":12, ...}

84 distinct counterexamples for this defect on baseline, 0 with the fix, and every other failure
class byte-identical.

The one delta -- minVersion-too-high 354 -> 355 -- is not a regression. Case-level diffing
shows exactly one case moving buckets, '* >3.2.0 >=1.3.2 || 1.* - 0' under includePrerelease:
baseline returned null (no version at all), the fix returns 3.2.1 (a genuinely matching
version). It is still one notch high because of the separate, already-reported >-bound prerelease
issue in #890, which behaves identically before and after this change
(minVersion('>3.2.0', {includePrerelease: true}) is 3.2.1 on both). So this case went from
completely wrong to correct-modulo-a-different-known-bug.

What I did not verify

  • Only exercised on Node 26 / macOS; I did not run the CI matrix.
  • The harness's version universe is bounded (0..3 per component, 8 prerelease tags), so it proves
    the fix over that space, not universally.
  • Timing of minVersion before/after was noise-dominated in my measurements (medians 425ms vs
    302ms over 7 runs of 100k calls, with overlapping min/max), so I claim no measurable performance
    change in either direction -- not a speedup.
  • The remaining failure classes above are separate defects and deliberately out of scope here. Two
    of them are already covered by open PRs (fix: subset false-positive with a prerelease eq and a differing bound #889, fix: minVersion returns the true minimum for > in includePrerelease mode #890); simplify-mismatch looks like a real and
    separate contract issue that I have not filed.

…n branch

`minVersion` derives a candidate for each comparator set while ignoring
`<` / `<=` comparators, so a set that is a null set (eg `^1 ^2`) still
produces one. The candidate was only validated against the range once,
after the global minimum had already been chosen, so a null set whose
candidate is lower than every satisfiable branch's would win the
comparison, fail the final check, and make the whole call return null:

    semver.satisfies('3.0.0', '^1 ^2 || >=3')  // true
    semver.minVersion('^1 ^2 || >=3')          // null, expected 3.0.0

Validate each candidate before it becomes the running minimum instead.
Ranges that genuinely match nothing still return null, since every
candidate then fails the check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@maximilliangrand
maximilliangrand requested a review from a team as a code owner August 12, 2026 18:51
@maximilliangrand

Copy link
Copy Markdown
Author

Some additional evidence on how often this fires, since a single hand-written repro makes it look like a corner case.

I property-tested the range algebra against published semver 7.8.5, generating 300,000 random range pairs from a grammar over ^ ~ >= <= > < =, x-ranges, hyphen ranges, prerelease tags and || unions, then probing each against a fixed universe of 120 concrete versions. satisfies() is the oracle, and I only counted sound violation directions — ones that cannot be sampling artefacts:

  • minVersion(r) returns a version that does not satisfy r
  • minVersion(r) returns null while a sampled version does satisfy r
  • subset(a,b) === true but some version satisfies a and not b
  • intersects(a,b) === false but some version satisfies both

Result on 7.8.5:

iterations: 300000, versions probed per pair: 120

=== SUBSET unsound:      0 hits,     0 distinct ===
=== INTERSECTS unsound:  0 hits,     0 distinct ===
=== MINVERSION unsound:  19229 hits, 29 distinct ===
=== CRASHES:             0 hits,     0 distinct ===

So subset and intersects came out clean, and every one of the 29 distinct shapes is this same bug: a union in which one branch is a null set whose spurious lower bound outranks the real minimum. A few, verbatim from the run:

{"a":"3.1.2 ^0.2.2 || =3.3.x",           "mv":null, "why":"null but 3.3.0 satisfies"}
{"a":"1.0.0 - 0.2.0 || 3.2.3 - 3.3.2",   "mv":null, "why":"null but 3.2.3 satisfies"}
{"a":">=0.x >=1.3.3 || 1.x ^0.3.0",      "mv":null, "why":"null but 1.3.3 satisfies"}
{"a":"3.0.3 - 2.3.1 || >3.x",            "mv":null, "why":"null but 4.0.0 satisfies"}
{"a":"2.3.1 || =1.x =0.3.0",             "mv":null, "why":"null but 2.3.1 satisfies"}

Note the null-set branch arises several different ways — mutually exclusive carets (3.1.2 ^0.2.2), an inverted hyphen range (1.0.0 - 0.2.0), contradictory = comparators (=1.x =0.3.0) — so this isn't specific to the ^1 ^2 shape in the original report.

Re-running the identical harness against this branch:

=== SUBSET unsound:      0 hits, 0 distinct ===
=== INTERSECTS unsound:  0 hits, 0 distinct ===
=== MINVERSION unsound:  0 hits, 0 distinct ===
=== CRASHES:             0 hits, 0 distinct ===

19,229 → 0, with no new violations introduced in the other three properties. The existing suite is unaffected: tap reports 51/51 suites, 9187 asserts passed, exit 0.

Happy to add the harness under test/ as a seeded regression if you'd want it in-tree; I left it out of the PR since it's slower than the rest of the suite.

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