^1.2.3, ~1.2, 1.2.x, >=1.2 <2. The same strings go into four package managers. They do not agree about what those strings select.
I implemented all four from their own documented rules over one shared version parser and comparator, so a disagreement in the grid is a disagreement in the rules rather than four parsers diverging by accident.
13 ranges × 19 versions = an enumerated 247-cell grid. Every figure below is a count.
👉 Live, the whole grid computed in your browser: https://dev48v.infy.uk/solve/day79-semver-resolver.html
Only the longhand ranges are portable
| range | npm | Cargo | Composer | pip | all four agree? |
|---|---|---|---|---|---|
^1.2.3 |
5 | 5 | 5 | 0 | no |
^0.2.3 |
2 | 2 | 2 | 0 | no |
^0.0.3 |
1 | 1 | 1 | 0 | no |
^1.0.0-alpha |
11 | 11 | 11 | 0 | no |
~1.2 |
6 | 4 | 6 | 0 | no |
~1.2.3 |
3 | 3 | 3 | 0 | no |
~1 |
7 | 7 | 7 | 0 | no |
1.2.x |
4 | 4 | 4 | 0 | no |
1.x |
7 | 7 | 7 | 0 | no |
>=1.2 <2 |
6 | 6 | 6 | 6 | YES |
>=1.0.0 |
9 | 9 | 9 | 9 | YES |
1.2.3 |
2 | 5 | 2 | 2 | no |
~=1.2.3 |
0 | 0 | 0 | 3 | no |
The two that survive are the two with no shorthand in them. Every caret, every tilde, every .x, and a bare version number is read differently by at least one of the four.
If you want a range to mean the same thing to everybody, you have to write the inequality out.
The agreement number is a trap
Across the whole grid the four give the same answer on 195 of 247 cells — 78.95%. That sounds like broad agreement.
178 of those 195 are cells where all four say no. Only 17 cells in the entire grid are ones where every ecosystem agrees to select the version.
Agreeing to reject 2.1.0 for ^0.0.3 is not agreement worth having.
Where each pair comes apart
| cells that differ | what causes it | ||
|---|---|---|---|
| npm | Composer | 0 / 247 | nothing, on these thirteen forms |
| npm | Cargo | 5 | a bare version is a caret range in Cargo |
| Cargo | Composer | 5 | same |
| npm | pip | 49 | pip has no caret and no .x
|
| Composer | pip | 49 | same |
| Cargo | pip | 50 | same |
npm and Composer agree on every cell of this grid — which is the honest scope, not a claim they are the same in general.
Cargo's five split across two rules, both of which are documented and both of which surprise people:
-
A bare
1.2.3inCargo.tomlis a caret range, meaning>=1.2.3 <2.0.0. npm and Composer read the same string as an exact pin. Three cells. -
Cargo's
~1.2pins the minor (>=1.2.0 <1.3.0) where Composer's~1.2pins only the major (>=1.2.0 <2.0.0). Two cells.
The prerelease rule catches people in both directions
^1.2.3 does NOT select 2.0.0-rc.1 in any of the four
Even though 2.0.0-rc.1 sorts strictly below 2.0.0, which is the range's upper bound. The rule is not about ordering: a prerelease is excluded unless the range itself names one.
And when the range does name one, the door opens wider than expected:
^1.0.0-alpha selects 1.0.0-alpha.1, 1.0.0-beta, 1.0.0, 1.2.0, … (11 of 19)
1.0.0-beta is a prerelease the range never mentions, and 1.2.0 is an ordinary release well past it. The opt-in is for the version tuple you named, not for prereleases in general.
One bug the verifier caught
My first >=1.2 <2 implementation silently lost its ceiling. parse("2") returns null — a bound may be written with one or two components, and only three-component strings are legal versions. So the upper bound became null, !hi was true, and the range happily selected 2.0.0 and 2.1.0.
>=1.2 <2 was reporting 8 selections instead of 6, and it looked entirely reasonable in the output. Pad partial bounds before parsing.
What this covers and what it does not
The four resolvers implement the forms that produce the disagreements above: caret with its zero-major special cases, tilde with its component-count dependence, .x wildcards, explicit inequalities, bare versions, and pip's ~=. They are not complete reimplementations.
Out of scope, each of which is another way for two tools to differ: compound ranges with ||, npm's --include=prerelease, Cargo's --precise, Composer's stability flags, pip's environment markers, and every resolver's behaviour when nothing satisfies the range.
21 in-page checks, 86 verifier assertions, 0 failures. The comparator is checked against the semver 2.0.0 spec's own example ordering before anything else is measured.
Top comments (0)