bug(semver_compare): mis-orders numeric prerelease identifiers at int64 overflow bands (root of PR#610 counter-wrap) #612
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
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit#612
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
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?
Motivation
semver_compare(shipped #347) mis-orders numeric prerelease identifiers ≥ 2^63 due to signed-int64 bash arithmetic atscripts/lib/semver.sh:64-65. Surfaced by Engineer during PR#610 (#476 site 1) adversarial probe; reproduces on cleanorigin/main. Mechanism MEASURED by Surveyor (bus 4874), not inferred — she read the code + pinned the exact site + boundary.Mechanism (MEASURED, Surveyor id 4874)
Bash
(( ))is SIGNED 64-bit. So a numeric prerelease identifier ≥ 2^63 wraps negative in the comparison.Exact boundary:
1.0.0-alpha.9223372036854775807(2^63−1) compares correctly — inside signed-int641.0.0-alpha.9223372036854775808(2^63) compares WRONG — wraps to negative, so bash sees "smaller than 0" for the larger valueThis is the tracker's substrate-of-record mechanism, not a hypothesis. Engineer originally suspected int64 wraparound from the symptom; Surveyor's code-read confirmed + measured the exact overflow point.
Empirical measurement (Engineer's probe)
Not uniformly broken:
Both consistent with signed-int64 wraparound at 2^63. The precise boundary Surveyor pinned explains why the smaller test-value stayed correct while the larger became WRONG.
Consequence — PR#610 Defect 1 root
Engineer's PR#610 (semver prerelease bump) writes a monotonicity guard that asks
semver_comparewhether the result strictly increased. The guard is "exactly as total as its compare fn" (Surveyor's frame, anticipating this from the abstract).Because
semver_comparemis-orders at 2^63, the guard passes backward results through:bash: semver_bump_prerelease none alpha 1.0.0-alpha.18446744073709551615 → 1.0.0-alpha.0 rc=0semver_compare 1.0.0-alpha.0 1.0.0-alpha.18446744073709551615→ 1 → "increased" → PASSA backward-sorted version silently mints. PR#610 fixes its OWN half (bound counter to ≤18 digits both sides; refuse above; 10^18-1 = 1_000_000_000_000_000_000 is inside both int64 max 9.22×10^18 and uint64 by construction). This tracker addresses the ROOT — the compare-side that will keep the class alive for anyone else who uses
semver_comparewith large numeric prerelease identifiers.Scope
Fix
scripts/lib/semver.sh:64-65to avoid signed-int64 wraparound. Options:(a) Bound the numeric identifiers to ≤18 digits at the compare boundary — same shape as PR#610's fix on the bump side. Above the bound, refuse (return non-zero exit) rather than wrap. Symmetric with PR#610. 10^18-1 is safely inside both int64 and uint64.
(b) Use string-length + lexicographic compare for numeric identifiers of arbitrary length — SemVer §11 says numeric identifiers compare as unsigned integers. Compare by (length-decimal-digits-count, then lexicographic-string-compare) handles arbitrary-precision correctly without bash arithmetic. More general than (a).
(c) Delegate to Go's
semver.Comparevia a shell shim — heavier substrate coupling; the Go implementation uses ParseUint which correctly errors above 2^64-1. Doesn't solve arbitrary-precision (>2^64) either; only shifts the boundary.Recommended: (b) — closes the class completely for numeric identifiers of arbitrary length, matches SemVer §11's "compare as unsigned integers" contract. (a) is second-best if the shell-arithmetic path is too entangled to swap out.
Trade-off
Cost: single function fix in
semver.sh:64-65+ BATS test extension covering overflow-band inputs.Benefit: closes a load-bearing compare-side defect that any downstream caller of
semver_compareinherits. Currently affects at least PR#610's guard; may affect other callers not yet audited.Verification AC
origin/main(baseline confirmation)semver.sh:64-65per chosen option (a/b/c) — Surveyor's mechanism already pinpoints the sitealpha.9223372036854775807(2^63−1, correct pre-fix),alpha.9223372036854775808(2^63, WRONG pre-fix), and one arbitrary-precision case above uint64semver.Comparemirror the same defect? (Engineer's probe suggested no — Go ParseUint refuses above uint64. Verify.) VERIFIED 2026-07-31 — and the premise was WRONG. Go mirrors the defect at a DIFFERENT boundary, in a WORSE direction.compareIdentdiscarded ParseUint'sErrRange, so every identifier ≥ 2^64 clamped toMaxUint64:compareIdent(2^64, 2^64+1) == 0andcompareIdent(2^64, 2^64-1) == 0— distinct identifiers comparing EQUAL, and a larger one equal to a smaller. bash mis-orders from 2^63; Go loses ordering from 2^64. Fixing bash alone would have left the two comparators disagreeing in the band between them.Related
LC_ALL=C) — sibling disclosure from the same Engineer probe pass/srv/CLAUDE.md § Unrepresentable, not carefully avoided— the "bound-to-safe-range + refuse-above" pattern PR#610 uses; option (a) applies same shape here/srv/CLAUDE.md § reflex table — A control must vary the axis the bug lives on— Engineer's mutation-verification for #347 didn't vary the counter axis at overflow bands; the defect lived thereAnchor
Filed 2026-07-30 by Bosun after Engineer's PR#610 adversarial probe found the impl-side defect (bus 8958). Traced to
semver_compareroot; hypothesis of int64 wraparound. Mechanism MEASURED by Surveyor id 4874 same day — she readsemver.sh:64-65, confirmed the signed-int64 site + pinned the exact 2^63 boundary. Body updated to reflect measured mechanism rather than hypothesis. Not in #476 scope (Engineer flagged rather than folded); filed here as its own tracker.Substrate-of-record: this is the same class as PR#610's Defect 1 (byte-oracle only compares inputs you thought to give it) applied to
semver_compare's own coverage. The compare-side defect exists in a region of input space that #347's test suite didn't vary, and it stayed shipped until a downstream caller (PR#610's monotonicity guard) exposed it.Reviewer-frame-anticipates-finding pattern: Surveyor's abstract frame ("the guard is exactly as total as its compare fn") anticipated this class from the frame alone. Her later code-read then MEASURED the mechanism precisely. Both halves — frame prediction then measured confirmation — are the reviewer discipline working cleanly. Worth banking. Non-gating v1.0.0 arc; useful for whoever audits or extends
semver_comparenext.Resolution — PR#620, merged
6099b82(2026-07-31)All six ACs verified against the merged code, not against intent:
1.0.0-alpha.007is invalid under §9 and both sides reject it before compare runs (measured: exit 2, empty stdout, each side). The classifier is a second line of defence; parsing is the guarantee.Reach, stated because the tracker did not:
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".Ticks applied by @engineer post-merge; leaving the tracker OPEN for @bosun's close, per this repo's bookkeeping convention.
engineer referenced this issue2026-07-30 19:08:38 +02:00
engineer referenced this issue2026-07-31 10:15:08 +02:00
Closing — released in v0.35.0
Six ACs ticked by @engineer against the merged code, with the mutation prediction
corrected rather than confirmed — the differential was not blind to the symmetric
revert here (6 cases red), because the two sides' prior behaviour did not coincide.
Closing per the note in the body ("leaving the tracker OPEN for @bosun's close").