Skip to content

fix: classify version requirements by Cargo's compatibility rules - #11

Merged
upbqdn merged 1 commit into
mainfrom
fix/semver-req-classification
Jul 31, 2026
Merged

fix: classify version requirements by Cargo's compatibility rules#11
upbqdn merged 1 commit into
mainfrom
fix/semver-req-classification

Conversation

@upbqdn

@upbqdn upbqdn commented Jul 31, 2026

Copy link
Copy Markdown
Member

Motivation

Three classes of version requirement were misread, and each failed the same way: a
dependency change a consumer has to react to was reported as compatible, so a crate
re-exposing that dependency in its public API was not flagged.

  1. A 0.0.x bump was classified as patch. Cargo keys compatibility to the leftmost
    nonzero component, so ^0.0.1 admits only 0.0.1 and 0.0.1 -> 0.0.2 is breaking. Only
    the 0.x case was handled.
  2. A requirement carrying anything but a bare version was parsed as if the prefix were
    part of the number.
    cargo metadata reports req verbatim and only a leading caret
    was stripped, so ~0.29 left ~0 in the major component. That never equals 0, which
    defeated every zero-major test: ~0.29 -> ~0.30 came back minor.
  3. A requirement can change without changing the version it starts at, and that was
    read as a patch bump. Narrowing >=0.29,<0.31 to >=0.29,<0.30 drops 0.30 from the
    set a consumer may resolve to; >=0.29 -> >0.29 drops 0.29 itself.

The second is not hypothetical for this tool's own target: a zcash_primitives 0.29 ->
0.30 bump is exactly the shape that must produce a lockstep-upgrade entry.

Solution

req_norm and req_version reduce a requirement to the version it starts at — dropping
whitespace, a leading caret (bare and caret are the same set), pre-release and build
metadata, and any comparison operator, and taking a range by its floor.

Deciding case 3 needs resolved versions, which a comparison of requirement text does not
have. So rather than guess, classify_bump gains a fourth result, unknown, for a
requirement that changed without moving that version:

requirement change classified why
0.0.1 -> 0.0.2 major leftmost nonzero component moved
~0.29 -> ~0.30, =0.0.1 -> =0.0.2 major the operator is not part of the version
>=0.29,<0.31 -> >=0.30,<0.32 major the floor moved
0.29.* -> 0.30.* major a wildcard reduces to its numeric prefix
1.0.0-rc1 -> 1.0.0, 0.30.0-pre.0 -> 0.30.0 patch metadata does not move the triple
0.29 -> ^0.29 patch the same set, so no change
>=0.29,<0.31 -> >=0.29,<0.30 unknown narrowing drops 0.30 from the resolvable set
>=0.29,<0.31 -> >=0.29,<0.32 unknown widening admits one more
>=0.29 -> >0.29 unknown 0.29 itself leaves the set
* -> * patch names no version, so it cannot bump

Callers treat unknown asymmetrically, deliberately. The dependency verdict treats it as
not-breaking, so no run flips to BREAKING because a ceiling widened. The
public-dependency join treats it as a candidate, because there it is filtered by whether
the dependency is reachable in the crate's public API — and reports it as a review item
("check whether downstream users are affected") rather than asserting a break, since an
unknown must not trigger a major version bump.

Tests

Classification is pure, so every shape above is pinned by a table extracted and sourced
from the script. Two integration cases check that a re-exposed 0.0.x dependency and an
operator-prefixed requirement reach the public-dependency join.

Ran locally: shellcheck -S warning zc tests/run.sh, bash -n zc, bash -n tests/run.sh, tests/run.sh (131 tests, 0 failures).

Three classes of requirement were misread, and each failed the same way: a
dependency change that a consumer has to react to was reported as compatible, so
a crate re-exposing that dependency in its public API was not flagged.

A 0.0.x bump was classified as `patch`. Cargo keys compatibility to the leftmost
nonzero component, so `^0.0.1` admits only 0.0.1 and 0.0.1 -> 0.0.2 is breaking.
Only the 0.x case was handled.

A requirement carrying anything but a bare version was parsed as if the prefix
were part of the number. `cargo metadata` reports `req` verbatim and only a
leading caret was stripped, so `~0.29` left `~0` in the major component, which
never equals `0` and so defeated every zero-major test.

A requirement can also change without changing the version it starts at, and the
old code read that as a patch bump. Narrowing `>=0.29,<0.31` to `>=0.29,<0.30`
drops 0.30 from the set a consumer may resolve to, and `>=0.29` -> `>0.29` drops
0.29 itself. Deciding either needs the resolved versions, which a comparison of
requirement text does not have.

Add `req_norm` and `req_version` to reduce a requirement to the version it starts
at — dropping whitespace, a leading caret, pre-release and build metadata, and any
comparison operator, and taking a range by its floor — and give `classify_bump` a
fourth result, `unknown`, for a requirement that changed without moving that
version. Callers that must not raise a false alarm treat `unknown` as
not-breaking, so no verdict flips on a widened ceiling; the public-dependency join
treats it as a candidate, where reachability in the public API filters it, and
reports it as a review item rather than asserting a break.

Classification is pure, so each shape is now pinned by a table, alongside two
integration cases covering a re-exposed 0.0.x and operator-prefixed dependency.
@upbqdn
upbqdn merged commit 9ac7389 into main Jul 31, 2026
2 checks passed
@upbqdn
upbqdn deleted the fix/semver-req-classification branch July 31, 2026 16:42
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