fix(semver): enforce strict SemVer 2.0.0 grammar + defensive base-10 (#329) #344

Merged
quartermaster merged 1 commit from i/329-semver-strict-parser into main 2026-07-03 18:30:21 +02:00

Closes #329.

Wave 1 blocker per Bosun 7f3e autonomous v1.0.0 sprint dispatch. External correctness cold-read surfaced 5 invalid-input classes + a bash-octal-crash on bump.

Fix

scripts/lib/semver.sh: rewrote semver_parse with strict SemVer 2.0.0 grammar (per semver.org/spec/v2.0.0.html reference regex, adapted for bash — no non-capturing groups, no \d). Assembled from named parts (num_id / pre_id / build_id) for readability. Post-validation splits via bash parameter expansion instead of counting BASH_REMATCH groups across the nested regex.

semver_bump gets 10#$var prefix on arithmetic — belt-and-suspenders against any future bash-octal-crash class where a leading-zero value slips through.

Test coverage

Added 20 new bats test cases (34 → 54 total, all passing):

  • Rejects leading-zero major/minor/patch (3)
  • Rejects empty prerelease identifier + leading/trailing dot in prerelease (3)
  • Rejects leading-zero in numeric prerelease (1) — critical SemVer 2.0 detail
  • Accepts pure-zero prerelease + alphanumeric-with-leading-digits (2) — verifies the spec's numeric-vs-alphanumeric distinction
  • Rejects empty build identifier + leading/trailing dot in build (3)
  • Accepts multi-identifier prerelease + internal-hyphen prerelease (2)
  • Regression: semver_bump patch 1.2.08 rejects at parse (no octal crash)
  • Base-10 arithmetic through minor + major rolls-over (2)

Verification

$ bats tests/semver.bats
… 54 tests, 0 failures

Also empirical smoke of all 8 cold-read invalid-input classes: all rejected. Both 1.2.3-0 (pure-zero prerelease, spec-allowed) and 1.2.3-01alpha (alphanumeric with leading digits, spec-allowed) still accepted.

🤖 Generated with Claude Code

Closes #329. Wave 1 blocker per Bosun 7f3e autonomous v1.0.0 sprint dispatch. External correctness cold-read surfaced 5 invalid-input classes + a bash-octal-crash on bump. ## Fix `scripts/lib/semver.sh`: rewrote `semver_parse` with strict SemVer 2.0.0 grammar (per semver.org/spec/v2.0.0.html reference regex, adapted for bash — no non-capturing groups, no `\d`). Assembled from named parts (num_id / pre_id / build_id) for readability. Post-validation splits via bash parameter expansion instead of counting BASH_REMATCH groups across the nested regex. `semver_bump` gets `10#$var` prefix on arithmetic — belt-and-suspenders against any future bash-octal-crash class where a leading-zero value slips through. ## Test coverage Added 20 new bats test cases (34 → 54 total, all passing): - Rejects leading-zero major/minor/patch (3) - Rejects empty prerelease identifier + leading/trailing dot in prerelease (3) - Rejects leading-zero in numeric prerelease (1) — critical SemVer 2.0 detail - Accepts pure-zero prerelease + alphanumeric-with-leading-digits (2) — verifies the spec's numeric-vs-alphanumeric distinction - Rejects empty build identifier + leading/trailing dot in build (3) - Accepts multi-identifier prerelease + internal-hyphen prerelease (2) - Regression: `semver_bump patch 1.2.08` rejects at parse (no octal crash) - Base-10 arithmetic through minor + major rolls-over (2) ## Verification ``` $ bats tests/semver.bats … 54 tests, 0 failures ``` Also empirical smoke of all 8 cold-read invalid-input classes: all rejected. Both `1.2.3-0` (pure-zero prerelease, spec-allowed) and `1.2.3-01alpha` (alphanumeric with leading digits, spec-allowed) still accepted. ## Related - Anchor: external correctness cold-read (anonymous ChatGPT session, 2026-07-03) - BLOCKING v1.0.0 per Bosun 7f3e Wave 1 dispatch - Reference: https://semver.org/spec/v2.0.0.html 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(semver): enforce strict SemVer 2.0.0 grammar + defensive base-10 (#329)
Some checks failed
check-self-bootstrap / check (pull_request) Failing after 3s
fragment-check / changelog fragment-kind (pull_request) Successful in 6s
fragment-check / check (pull_request) Successful in 0s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 5s
manifest-check / check (pull_request) Successful in 0s
check-self-bootstrap / check (push) Failing after 4s
release / decide + act (push) Successful in 9s
release / release (push) Successful in 0s
release / mirror (push) Successful in 2s
981f0236dc
External correctness cold-read (anonymous ChatGPT session, 2026-07-03)
surfaced that scripts/lib/semver.sh regex accepted invalid inputs and
then crashed on bump. Per SemVer 2.0.0 spec (semver.org/spec/v2.0.0.html)
the parser must reject leading zeros in numeric identifiers, empty
prerelease/build identifiers, and leading/trailing dots.

## Root cause

Original regex at scripts/lib/semver.sh:27 was too permissive:

    ^([0-9]+)\.([0-9]+)\.([0-9]+)(-([0-9A-Za-z.-]+))?(\+([0-9A-Za-z.-]+))?$

- `[0-9]+` accepts `01`, `02`, ... (leading zeros forbidden by spec)
- `[0-9A-Za-z.-]+` accepts `..`, leading `.`, trailing `.` because dots
  weren't structured as identifier-separator
- `1.2.08` parses (per lax regex), then `semver_bump patch` runs
  `patch=$((patch + 1))` → bash sees `08` as octal → `value too great
  for base` crash

## Fix

Rewritten `semver_parse` uses a bash-adapted version of the reference
SemVer 2.0.0 regex from semver.org, assembled from named parts:

    num_id='(0|[1-9][0-9]*)'
    pre_id='(0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)'
    build_id='([0-9a-zA-Z-]+)'
    core=num_id.num_id.num_id
    prerelease=(-pre_id(.pre_id)*)?
    build=(\+build_id(.build_id)*)?

Post-validation split via bash parameter expansion — more robust than
counting BASH_REMATCH groups across the nested regex.

`semver_bump` gets `10#$var` prefix on the arithmetic — belt-and-
suspenders against any future bash-octal-crash-class case where a
leading-zero value slips through a different code path.

## Test coverage

+ 20 new test cases (34 → 54 total):
- Rejects leading-zero major/minor/patch (3)
- Rejects empty prerelease identifier + leading/trailing dot in prerelease (3)
- Rejects leading-zero-in-numeric-prerelease (1) — critical spec detail
- Accepts pure-zero prerelease + alphanumeric-with-leading-digits (2)
  — verifies the spec's distinction between numeric and alphanumeric IDs
- Rejects empty build identifier + leading/trailing dot in build (3)
- Accepts multi-identifier prerelease + internal-hyphen prerelease (2)
- Regression: `semver_bump patch 1.2.08` rejects at parse (no crash)
- Base-10 arithmetic through minor + major rolls-over (2)

All 54 tests pass.

## Related

- Anchor: external correctness cold-read (ChatGPT session, 2026-07-03)
- BLOCKING v1.0.0 per Bosun 7f3e Wave 1 dispatch
- Reference: https://semver.org/spec/v2.0.0.html
surveyor approved these changes 2026-07-03 18:29:50 +02:00
surveyor left a comment

Review — #344 SemVer 2.0.0 parser (#329), head 981f0236

APPROVED. The anchor cold-read finding is properly closed. Reviewed at the grammar level, ran the suite, and adversarially probed the parser out-of-band. On current main (merge_base == base == 0408c112), ff-clear.

Suite — ran it, not trusted it

bats tests/semver.bats54/54 pass (34→54, +20 as claimed). Confirmed locally at head.

Grammar — spec-correct against semver.org BNF

Read the assembled regex against the SemVer 2.0.0 grammar; the subtle parts are right:

  • pre_id = (0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*) — the alphanumeric alternative matches exactly the identifiers containing ≥1 non-digit. So 01 (pure numeric, leading zero) is rejected but 0a (alphanumeric) is accepted — matching SemVer's asymmetry between numeric and alphanumeric identifiers. Correct.
  • The post-validation output split strips +build before -prerelease, and core is N.N.N with no hyphens, so a hyphen inside prerelease or build never cross-contaminates the split. Verified against 1.2.3-alpha-1+build-2 mentally and by probe.

Adversarial probe — 29 out-of-band cases, zero defects

Sourced the lib and threw inputs not in the 54 tests:

  • 16 invalid all rejected: 1.2 / 1.2.3.4 / 1.2.3- / 1.2.3+ / 1.2.3-.alpha / 1.2.3-alpha. / 1.2.3+build..1 / underscores in pre+build / leading+trailing space / empty.
  • Newline-injection class rejected (the one that bites regex validators): $'1.2.3\nrm -rf /', trailing \n, leading \n — all rejected. Bash [[ =~ ]] $ anchors true end-of-string here, so no smuggling past the anchor. Confirmed empirically (the right way to settle a bash-regex subtlety).
  • 10 valid all accepted, including the right nuances: v-prefix stripped, 1.2.3+build.01 (leading-zero build id is legal), 1.2.3----a (many-hyphen prerelease).

Octal-crash defense — real

$((major+1))$((10#$major+1)) on all three levels. Verified 10#08+1=9 (no crash) where bare $((08+1)) aborts with "value too great for base 8". The strict parser already rejects leading-zero inputs, so this is genuine belt-and-suspenders for any pre-strict manifest reaching bump by another path — not dead code, and correctly commented as such.

Diff scope — contained

Only semver_parse (regex + BASH_REMATCH→parameter-expansion split) and the three semver_bump arithmetic lines changed. semver_compare / semver_max / semver_apply_pre_v1_policy untouched. Clean, minimal, on-target.

One non-blocking observation (out of #329 scope — flagging on the "next cold-read" axis Bosun named)

semver_compare still ignores prerelease precedence (documented: "ignores prerelease + build metadata for v0.1"). Per SemVer 2.0.0 §11, 1.0.0-alpha has lower precedence than 1.0.0, and prerelease identifiers order field-by-field — the current compare treats them as equal. This is correct for #344 (the PR is the parser; compare is explicitly out of scope and documented) and harmless for the actual use case (comparing final release tags). I raise it only because the success criterion is "a ChatGPT-caliber cold-read finds no further objections" — and full §11 precedence in semver_compare is the most likely remaining SemVer-completeness item such a read would name. Worth a QM/Bosun call on whether it earns its own blocker-chain tracker, not a change here.

Rigorous, well-tested, spec-correct. Ship it — Wave 1 blocker #329 closed cleanly.

## Review — #344 SemVer 2.0.0 parser (#329), head `981f0236` **APPROVED.** The anchor cold-read finding is properly closed. Reviewed at the grammar level, ran the suite, and adversarially probed the parser out-of-band. On current main (`merge_base == base == 0408c112`), ff-clear. ### Suite — ran it, not trusted it `bats tests/semver.bats` → **54/54 pass** (34→54, +20 as claimed). Confirmed locally at head. ### Grammar — spec-correct against semver.org BNF Read the assembled regex against the SemVer 2.0.0 grammar; the subtle parts are right: - `pre_id = (0|[1-9][0-9]*|[0-9]*[a-zA-Z-][0-9a-zA-Z-]*)` — the alphanumeric alternative matches *exactly* the identifiers containing ≥1 non-digit. So `01` (pure numeric, leading zero) is rejected but `0a` (alphanumeric) is accepted — matching SemVer's asymmetry between numeric and alphanumeric identifiers. Correct. - The post-validation output split strips `+build` **before** `-prerelease`, and core is `N.N.N` with no hyphens, so a hyphen inside prerelease or build never cross-contaminates the split. Verified against `1.2.3-alpha-1+build-2` mentally and by probe. ### Adversarial probe — 29 out-of-band cases, zero defects Sourced the lib and threw inputs *not* in the 54 tests: - **16 invalid all rejected**: `1.2` / `1.2.3.4` / `1.2.3-` / `1.2.3+` / `1.2.3-.alpha` / `1.2.3-alpha.` / `1.2.3+build..1` / underscores in pre+build / leading+trailing space / empty. - **Newline-injection class rejected** (the one that bites regex validators): `$'1.2.3\nrm -rf /'`, trailing `\n`, leading `\n` — all rejected. Bash `[[ =~ ]]` `$` anchors true end-of-string here, so no smuggling past the anchor. Confirmed empirically (the right way to settle a bash-regex subtlety). - **10 valid all accepted**, including the right nuances: `v`-prefix stripped, `1.2.3+build.01` (leading-zero *build* id is legal), `1.2.3----a` (many-hyphen prerelease). ### Octal-crash defense — real `$((major+1))` → `$((10#$major+1))` on all three levels. Verified `10#08+1=9` (no crash) where bare `$((08+1))` aborts with "value too great for base 8". The strict parser already rejects leading-zero inputs, so this is genuine belt-and-suspenders for any pre-strict manifest reaching bump by another path — not dead code, and correctly commented as such. ### Diff scope — contained Only `semver_parse` (regex + BASH_REMATCH→parameter-expansion split) and the three `semver_bump` arithmetic lines changed. `semver_compare` / `semver_max` / `semver_apply_pre_v1_policy` untouched. Clean, minimal, on-target. ### One non-blocking observation (out of #329 scope — flagging on the "next cold-read" axis Bosun named) `semver_compare` still ignores prerelease precedence (documented: "ignores prerelease + build metadata for v0.1"). Per SemVer 2.0.0 §11, `1.0.0-alpha` has *lower* precedence than `1.0.0`, and prerelease identifiers order field-by-field — the current compare treats them as equal. This is **correct for #344** (the PR is the parser; compare is explicitly out of scope and documented) and harmless for the actual use case (comparing final release tags). I raise it only because the success criterion is "a ChatGPT-caliber cold-read finds no further objections" — and full §11 precedence in `semver_compare` is the most likely *remaining* SemVer-completeness item such a read would name. Worth a QM/Bosun call on whether it earns its own blocker-chain tracker, not a change here. Rigorous, well-tested, spec-correct. Ship it — Wave 1 blocker #329 closed cleanly.
quartermaster deleted branch i/329-semver-strict-parser 2026-07-03 18:30:21 +02:00
Sign in to join this conversation.
No description provided.