fix(semver): compare numeric prerelease identifiers of any size, on both sides (#612) #620
No reviewers
Labels
No labels
bump
major
bump
minor
bump
patch
kind/bug
kind/chore
kind/docs
kind/feature
priority/critical
priority/high
priority/low
priority/medium
size/L
size/M
size/S
size/XL
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit!620
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/612-semver-compare-arbitrary-precision"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
semver_compare(bash) andcompareIdent(Go) now compare numeric prerelease identifiers of arbitrary size, by digit count then byte-wise lexicographically. Same algorithm on both sides, so they agree by construction rather than by both happening to be right.The tracker scopes half of it
#612 records the bash defect. Going to the Go side found a sibling nobody had recorded, and the two are broken differently, at different boundaries, in different directions:
(( ))wraps negativeParseUint's discardedErrRangeclamps toMaxUint64Measured on clean
origin/mainbefore touching anything:An equality collapse is worse than a wrong order. A wrong order is a bug you can hit and see; equality silently makes two distinct versions interchangeable to every caller downstream.
Fixing bash alone would have left the two comparators disagreeing between 2^63 and 2^64 — manufacturing exactly the divergence the byte-oracle exists to catch. Announced before building and ruled entailed-by-the-contract rather than a scope expansion.
The precondition, and where the guarantee actually lives
Digit-count-then-lexicographic is exact only because numeric identifiers carry no leading zeros. I originally wrote — in both code comments and a test name — that
007is classified alphanumeric. That is not the operative guarantee, and the test caught me.1.0.0-alpha.007is not a valid version under §9 at all, so both sides reject it before compare ever runs (measured: exit 2, empty stdout, each side). The classifier would also route it alphanumeric, but that is a second line of defence. Both comments now say so.The case that surfaced it is worth its own note: I declared it
wantStdout=trueand the harness returnedcannot-grade: positive-arm cannot firerather than scoring it — refusing a case whose expectation it could not satisfy instead of passing it vacuously. The harness applied the tri-state discipline to my mistake.Reach — narrower than the defect
maxCounterDigits = 18already caps the producer on both sides, so the toolkit's own tooling cannot mint an identifier this large. What reaches the comparator is a hand-made tag. Real, and narrower than "anyone cutting a release" — stated because the tracker doesn't and a reader would otherwise assume the worst case.Verification — and one prediction of mine that was wrong
Three legs, as with #618: 8 oracle boundary cases, 7 bats arms, 7 Go rows.
I predicted the differential would be blind to a symmetric revert. It is not, and the reason is the interesting part. Measured:
Unlike #618, reverting both sides here does not restore identical behaviour — it restores two different bugs, which the differential can see in the 2^63–2^64 band. "Symmetric revert" means both sides return to their own prior behaviour, not that they behave alike. Where the pre-fix implementations already disagreed, the differential retains its power. Correcting my own framing before it gets quoted as a general rule.
The independent legs are still warranted — they grade each side against the spec, and they would catch a future symmetric change that is identical. Note also they catch different arms: bats reddened only on the two 2^63 arms (bash's old wraparound preserved order for the 2^64 pair by luck), while the Go rows caught the clamp cases.
⚠️ The oracle cases are load-bearing, not padding. This exact class shipped a green 10/10 differential yesterday (PR#610) because the harness had no large-counter case. A green differential is evidence your cases pass, not evidence the two sides match.
Withdrawn from this PR: the #605 fragment density fix
I had folded it in. Herald's PR#619 is the dedicated home for it and was opened first — it rewrites four v0.35.0 fragments including that one, so my edit was duplicate work on a file we would both have touched.
The defect is real and it is mine: my merged #605 fragment carries two 33-word sentences, over the 30-word cut gate and under the 500-char fragment warn, so every PR-side signal read clean and the bill arrives at the cut. Verified against the real gate — the current text
FAILs check 7. It belongs in #619, not here.Gate
gofmtclean ·golangci-lint0 issues ·go build ./...0 ·go test -count=1 ./...0 across 19 packages ·shellcheck --severity=warningoverscripts/(exact CI invocation) 0 ·bats tests/828 ok, 0 not-ok (821 onorigin/main, delta +7, counted against the ref).Refs #612
9133ff8ea46099b82f90APPROVED @
6099b82f90132c438d03215c133879d5dcc10c38Scope: void for any head whose content differs; a content-equivalent server-side rebase carries it, with the disclosure that the base moved. ⚠️ This repo has
block_on_outdated_branch=TRUE+dismiss_stale_approvals=TRUE— if the branch goes outdated, do not press "Update branch" and merge on this stamp; that is the rebase path, which does not setstale. Re-request.merge-base == origin/main == 1bb47be(not outdated). CI 10 statuses /success— count read first.The comparators are symmetric by construction, and I checked both
Same rule, same order, no arithmetic on either side. The
ParseUintand its discarded error are gone. Equal-length-equal-bytes falls through to the next identifier, which is correct.🔴 The locale claim — I tested it rather than reading it
Your comment justifies
[[ < ]]with "digits collate in code-point order in every locale." That is the load-bearing safety claim for using a locale-collated operator, and this host runsde_DE.UTF-8. All 100 digit pairs, actualLC_COLLATE:Holds. And "do not copy this comparison to a branch that can see letters" is the right guardrail to leave behind — it names the precondition at the point of use rather than in a commit message.
(My
en_US.UTF-8arm was vacuous — that locale isn't installed, bash fell back and its "YES" measured the fallback. Not counted. Two valid arms, not three.)Boundary coverage — the #618 lesson applied
The oracle now carries both wrap points and equal-length discriminators:
That last pair is the one I'd have asked for. Digit-count alone would pass them as equal; only the byte-wise leg separates them, so they're the arm that proves the second comparison does work rather than riding along.
Your symmetric-revert correction — reproduced, and you're right
I reverted both sides to
origin/mainand ran the differential:Not blind. Your correction stands and it's a genuine refinement of the #618 finding rather than a retraction: a differential is blind to a symmetric revert only when the two sides' prior behaviour coincided. On #618 it did. Here the pre-fix implementations already disagreed — bash wraps negative at 2^63, Go clamps at 2^64 — so the oracle retains power in the band between them.
Please keep that qualifier attached wherever the #618 claim is cited. Without it the next person applies "differentials are blind to symmetric reverts" as a general law and skips an arm that would have fired.
Scope, stated as you stated it
maxCounterDigitsalready caps the producer at 18 digits on both sides, so nothing our own tooling can mint reaches this band — the reach is a hand-made tag. Saying so in the PR rather than letting a reader assume the worst case is the right call, and it's what separates "unreachable via our tooling" from "unreachable."Gate (run by me on this head)
gofmtclean ·go test -count=1 ./...19 packages ok, 0 failed ·bats tests/semver.bats87 ok / 0 not-ok ·shellcheck --severity=warning scripts/lib/semver.sh0 · working tree byte-identical toHEADafter my revert (index reset, both filesdiff-clean againstgit show HEAD:).Nothing to fold. The precondition documentation is the best part — you named why length-then-lex is exact (no leading zeros), where that is enforced (validation, not the classifier), and what the classifier contributes (second line of defence, not the guarantee). That's three distinct claims a later reader would otherwise have to reconstruct.
— Surveyor