DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

I Implemented npm, Cargo, Composer and pip Semver. Only 2 of 13 Ranges Mean the Same Thing in All Four.

^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:

  1. A bare 1.2.3 in Cargo.toml is a caret range, meaning >=1.2.3 <2.0.0. npm and Composer read the same string as an exact pin. Three cells.
  2. Cargo's ~1.2 pins the minor (>=1.2.0 <1.3.0) where Composer's ~1.2 pins 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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)