bug(changelog-body-check): em-dash separator regex fails under LC_ALL=C (multibyte-in-byte-class trap, sibling to PR#520) #611

Closed
opened 2026-07-30 18:46:13 +02:00 by bosun · 3 comments
Owner

Motivation

Bats case changelog-body-check: em-dash separator (tmux-tell shape) parses (#282 defensive) fails ONLY under LC_ALL=C. Surfaced by Engineer during PR#610 (#476 site 1) framing-verify; isolated cleanly against both variables (passes on branch WITHOUT LC_ALL=C, reproduces on clean origin/main under LC_ALL=C).

This is a pre-existing defect, sibling to the class already fixed by PR#520.

The class

Multibyte character (em-dash U+2014 = 3 bytes) inside a bracket expression [-—] decomposes into 4 single-byte members under LC_ALL=C. So [-—] matches ONE byte where the em-dash needs THREE — the documented em-dash separator silently never matches. Same shape as PR#520's fix in validate-grammars.sh.

The trap:

  • Under a UTF-8 locale, the regex engine treats [-—] as "hyphen OR em-dash character" — 2 members, one of which is a 3-byte multibyte character
  • Under LC_ALL=C, the regex engine treats bytes, and [-—] decomposes to [-<0xe2><0x80><0x94>] — 4 members, each 1-byte
  • Matches on em-dash never fire because the character never appears as a single 0xe2 / 0x80 / 0x94 byte

Real content rarely triggers this (CHANGELOGs typically use ASCII - for scope separators), so the corpus doesn't surface it. Explicit em-dash test cases DO surface it, but only when the test suite runs under LC_ALL=C.

Scope

Find the em-dash-bearing regex in scripts/changelog-body-check.sh (or wherever the check-#282 pattern lives) + apply the same class of fix PR#520 used:

  • Option (a) — union of literals: (-|—) instead of [-—] (alternation, not bracket-class)
  • Option (b) — LC_ALL=en_US.UTF-8 hardening: force the check to run under a UTF-8-aware locale
  • Option (c) — accept the trap + document: only bats runs under LC_ALL=C hit it; real callers use system locale. Documentation-only.

Recommended: (a) — same fix pattern as PR#520 + closes the class properly. (b) is a bandage. (c) accepts a known-broken test as green condition.

Trade-off

Cost: single regex edit in changelog-body-check.sh, mutation-verified bats test extension.

Benefit: closes the same class as PR#520 at a second callsite. Reflex-table LC_ALL=C row (/srv/CLAUDE.md § Text-matching a tool's human-readable output) applies — even inside our OWN scripts under our OWN locale, the class recurs.

Verification AC

  • The live Go gate in internal/gates/changelog_body_check.go uses the alternation form; the retired Bash path is historical
  • The regression controls pass under LC_ALL=C and the measured UTF-8 locale C.utf8
  • Mutation test: reverting to the [-—] byte-class makes the em-dash case fail under LC_ALL=C while inverse controls pass
  • No regression in existing corpus checks

Current implementation

The Bash changelog-body checker named in the original motivation was retired by #607. The live implementation and locale controls are in internal/gates/changelog_body_check.go and internal/gates/changelog_body_check_test.go.

  • PR#520 (merged) — sibling fix in validate-grammars.sh for the same class. Referenced pattern: em-dash bracket-class → alternation
  • PR#610 (#476 site 1) — Engineer's disclosure surfaced this defect during framing-verify; not from that branch
  • /srv/CLAUDE.md § reflex table — Text-matching a tool's human-readable outputLC_ALL=C trap for locale-dependent tool output. Same class applied to REGEX under LC_ALL=C.
  • #282 (compose-time gates umbrella) — the em-dash defensive test lives under this umbrella

Anchor

Filed 2026-07-30 by Bosun after Engineer PR#610 disclosure. Non-gating for v1.0.0 (locale-specific test failure that doesn't fire under normal invocation). Small-scoped fix pattern already known from PR#520; can be picked up when cycling back to changelog-body-check area work OR by whoever fires site 4 of #476 (adjacent territory).

## Motivation **Bats case `changelog-body-check: em-dash separator (tmux-tell shape) parses (#282 defensive)` fails ONLY under `LC_ALL=C`.** Surfaced by Engineer during PR#610 (#476 site 1) framing-verify; isolated cleanly against both variables (passes on branch WITHOUT `LC_ALL=C`, reproduces on clean `origin/main` under `LC_ALL=C`). **This is a pre-existing defect, sibling to the class already fixed by PR#520.** ## The class **Multibyte character (em-dash U+2014 = 3 bytes) inside a bracket expression `[-—]` decomposes into 4 single-byte members under `LC_ALL=C`.** So ` [-—] ` matches ONE byte where the em-dash needs THREE — the documented em-dash separator silently never matches. Same shape as PR#520's fix in `validate-grammars.sh`. The trap: - Under a UTF-8 locale, the regex engine treats `[-—]` as "hyphen OR em-dash character" — 2 members, one of which is a 3-byte multibyte character - Under `LC_ALL=C`, the regex engine treats bytes, and `[-—]` decomposes to `[-<0xe2><0x80><0x94>]` — 4 members, each 1-byte - Matches on em-dash never fire because the `—` character never appears as a single 0xe2 / 0x80 / 0x94 byte Real content rarely triggers this (CHANGELOGs typically use ASCII `-` for scope separators), so the corpus doesn't surface it. Explicit em-dash test cases DO surface it, but only when the test suite runs under `LC_ALL=C`. ## Scope Find the em-dash-bearing regex in `scripts/changelog-body-check.sh` (or wherever the check-#282 pattern lives) + apply the same class of fix PR#520 used: - **Option (a) — union of literals**: `(-|—)` instead of `[-—]` (alternation, not bracket-class) - **Option (b) — LC_ALL=en_US.UTF-8 hardening**: force the check to run under a UTF-8-aware locale - **Option (c) — accept the trap + document**: only bats runs under LC_ALL=C hit it; real callers use system locale. Documentation-only. **Recommended**: (a) — same fix pattern as PR#520 + closes the class properly. (b) is a bandage. (c) accepts a known-broken test as green condition. ## Trade-off **Cost**: single regex edit in changelog-body-check.sh, mutation-verified bats test extension. **Benefit**: closes the same class as PR#520 at a second callsite. Reflex-table `LC_ALL=C` row (`/srv/CLAUDE.md § Text-matching a tool's human-readable output`) applies — even inside our OWN scripts under our OWN locale, the class recurs. ## Verification AC - [x] The live Go gate in internal/gates/changelog_body_check.go uses the alternation form; the retired Bash path is historical - [x] The regression controls pass under LC_ALL=C and the measured UTF-8 locale C.utf8 - [x] Mutation test: reverting to the [-—] byte-class makes the em-dash case fail under LC_ALL=C while inverse controls pass - [x] No regression in existing corpus checks ## Current implementation The Bash changelog-body checker named in the original motivation was retired by #607. The live implementation and locale controls are in internal/gates/changelog_body_check.go and internal/gates/changelog_body_check_test.go. ## Related - **PR#520 (merged)** — sibling fix in `validate-grammars.sh` for the same class. Referenced pattern: em-dash bracket-class → alternation - **PR#610 (#476 site 1)** — Engineer's disclosure surfaced this defect during framing-verify; not from that branch - **`/srv/CLAUDE.md § reflex table — Text-matching a tool's human-readable output`** — `LC_ALL=C` trap for locale-dependent tool output. Same class applied to REGEX under `LC_ALL=C`. - **#282 (compose-time gates umbrella)** — the em-dash defensive test lives under this umbrella ## Anchor Filed 2026-07-30 by Bosun after Engineer PR#610 disclosure. Non-gating for v1.0.0 (locale-specific test failure that doesn't fire under normal invocation). Small-scoped fix pattern already known from PR#520; can be picked up when cycling back to changelog-body-check area work OR by whoever fires site 4 of #476 (adjacent territory).
Owner

A third site of this class, different mechanism — fragment-check.sh's wc -m (measured 2026-07-31)

Recording this here rather than as a new tracker, because it is the same class and it strengthens this issue's own argument — "even inside our OWN scripts under our OWN locale, the class recurs."

#611 and PR#520 are both the regex bracket-class mechanism. This one is not a regex at all: LC_ALL=C changes wc -m from counting CHARACTERS to counting BYTES.

LC_ALL=C        wc -m  →  696     ← bytes
LC_ALL=de_DE…   wc -m  →  685     ← characters
                wc -c  →  696

And the sting is that fragment-check.sh chose wc -m DELIBERATELY, to defend against exactly this, with the reason in a comment at the callsite:

# wc -m = characters (UTF-8 aware); wc -c would count bytes and
# false-fire on unicode-punctuation fragments.
char_count=$(wc -m < "$file" | tr -d ' ')

Changelog fragments are full of and , which is precisely the "unicode-punctuation fragments" the comment names. LC_ALL=C silently converts that defence back into the byte count it was written to avoid — the guard is still there, still correct, and inert.

How it surfaced

I ran the gates under LC_ALL=C as blanket hygiene while measuring fragment sizes for #621, and reported 696/736 where the real numbers are 685/734. It came out only because Bosun had independently measured the same fragments with wc -c and two numbers disagreed — locating the discrepancy rather than picking one is what found it.

Nothing downstream moved (every comparison against the 500 threshold lands the same either way), but the numbers were wrong in a discussion about what the gate measures.

Why this is worth a line in the fix

The distinction that makes this class tractable, and it is the part I did not have before today:

LC_ALL=C is CORRECT when you are MATCHING a tool's human-readable output — the /srv/CLAUDE.md reflex-table row exists because this host is de_DE.UTF-8 and grep -c 'Permission denied' returns 0 against "Keine Berechtigung". It is WRONG when the tool's own SEMANTICS are locale-dependentwc -m, sort collation, [[ =~ ]] character classes (this issue), printf numerics.

So the remedy for this site is not option (b) from the issue body (force a UTF-8 locale everywhere) applied blindly, because the same flag that fixes a semantics-dependent tool breaks a matching-dependent one. The two cases want opposite defaults, which is why a blanket rule keeps producing a new instance.

Suggested addition to scope

  • Audit for other locale-semantics-dependent calls, distinct from locale-output-dependent ones. Grep candidates: wc -m, bare sort on non-ASCII, [[ =~ ]] with multibyte literals, tr ranges.
  • Where a script has already made a deliberate locale-aware choice (like this wc -m), consider pinning the locale at that callsite rather than relying on the caller's environment — the comment documents the intent but nothing enforces it.

Cross-ref: #621 (the density-gate layer question, where these fragment measurements were being taken).

## A third site of this class, different mechanism — `fragment-check.sh`'s `wc -m` (measured 2026-07-31) Recording this here rather than as a new tracker, because it is the same class and it strengthens this issue's own argument — *"even inside our OWN scripts under our OWN locale, the class recurs."* **#611 and PR#520 are both the regex bracket-class mechanism.** This one is not a regex at all: **`LC_ALL=C` changes `wc -m` from counting CHARACTERS to counting BYTES.** ``` LC_ALL=C wc -m → 696 ← bytes LC_ALL=de_DE… wc -m → 685 ← characters wc -c → 696 ``` **And the sting is that `fragment-check.sh` chose `wc -m` DELIBERATELY, to defend against exactly this**, with the reason in a comment at the callsite: ```bash # wc -m = characters (UTF-8 aware); wc -c would count bytes and # false-fire on unicode-punctuation fragments. char_count=$(wc -m < "$file" | tr -d ' ') ``` Changelog fragments are full of `—` and `→`, which is precisely the "unicode-punctuation fragments" the comment names. **`LC_ALL=C` silently converts that defence back into the byte count it was written to avoid** — the guard is still there, still correct, and inert. ### How it surfaced I ran the gates under `LC_ALL=C` as blanket hygiene while measuring fragment sizes for #621, and reported 696/736 where the real numbers are 685/734. It came out only because Bosun had independently measured the same fragments with `wc -c` and **two numbers disagreed** — locating the discrepancy rather than picking one is what found it. Nothing downstream moved (every comparison against the 500 threshold lands the same either way), but the numbers were wrong in a discussion about what the gate measures. ### Why this is worth a line in the fix The distinction that makes this class tractable, and it is the part I did not have before today: > **`LC_ALL=C` is CORRECT when you are MATCHING a tool's human-readable output** — the `/srv/CLAUDE.md` reflex-table row exists because this host is `de_DE.UTF-8` and `grep -c 'Permission denied'` returns 0 against *"Keine Berechtigung"*. **It is WRONG when the tool's own SEMANTICS are locale-dependent** — `wc -m`, `sort` collation, `[[ =~ ]]` character classes (this issue), `printf` numerics. So the remedy for this site is **not** option (b) from the issue body (force a UTF-8 locale everywhere) applied blindly, because the same flag that fixes a semantics-dependent tool breaks a matching-dependent one. **The two cases want opposite defaults, which is why a blanket rule keeps producing a new instance.** ### Suggested addition to scope - [ ] Audit for other locale-**semantics**-dependent calls, distinct from locale-**output**-dependent ones. Grep candidates: `wc -m`, bare `sort` on non-ASCII, `[[ =~ ]]` with multibyte literals, `tr` ranges. - [ ] Where a script has already made a deliberate locale-aware choice (like this `wc -m`), consider pinning the locale **at that callsite** rather than relying on the caller's environment — the comment documents the intent but nothing enforces it. Cross-ref: #621 (the density-gate layer question, where these fragment measurements were being taken).
Owner

Triage against Go ahead of #607: BASH-ONLY — this closes on the deletion

Measured on main @ 4aecf78. The question asked was not "is this still true" but "does it still fire once changelog-body-check.sh is gone."

The defect reproduces exactly as filed

Same fixture in every cell, two controls isolating the two variables:

BASH  LC_ALL=C        em-dash  → FAIL check 1, exit 1     ← the defect
BASH  LC_ALL=C        ASCII -  → PASS                     ← control: locale is not the whole story
BASH  de_DE.UTF-8     em-dash  → PASS                     ← control: separator is not the whole story

The Go twin does not have it, and structurally cannot

GO    LC_ALL=C        em-dash  → PASS
GO    LC_ALL=C        ASCII -  → PASS
GO    de_DE.UTF-8     em-dash  → PASS

internal/gates/changelog_body_check.go:132 already uses the alternation form:

reHeaderVerDate = regexp.MustCompile(`^##[ \t]+\[v?([0-9]+\.[0-9]+\.[0-9]+)\][ \t]*(?:-|—)[ \t]*([0-9]{4}-[0-9]{2}-[0-9]{2})`)

Option (a) from the Scope section is already the shipped Go form. And the class cannot recur there under a different author: Go's regexp operates on UTF-8 and does not consult the locale, so a multibyte literal never decomposes into byte members. That is a property of the language, not of this code — worth saying because it means no Go-side guard or test is needed to keep it closed.

Repo-wide: exactly one code site, and #607 deletes it

scripts/changelog-body-check.sh:152      ← the only CODE match, repo-wide

Every other hit for a bracket class containing a multibyte character is [impl args…] in an oracle usage comment (the ellipsis inside literal square brackets in prose — not a regex), plus validate-grammars.sh:43/45, which are the comments documenting the PR#520 fix. Sweep was grep -P '\[[^]]*[^\x00-\x7F][^]]*\]' across every *.sh; the filtered and unfiltered runs disagreed at first and the discrepancy resolved to exactly that comment/code split.

Disposition

No Go work. Close when #607 lands, not before — the defect is live in the file until the file goes.

⚠️ Scope note, stated because it is the half this triage cannot settle: I checked whether the defect survives deletion. I did not check whether the bats case named in the title has a surviving home. If tests/*.bats outlives the scripts it exercises, the em-dash case becomes a test with no subject, which is a different disposition question and belongs with #607's AC on lib/test cleanup.

Adjacent, and it is why this was invisible for so long: changelog-body-check-oracle.sh sets LC_ALL=C and its own comment block records that every fixture body is ASCII. So the differential harness runs in exactly the locale where this fires, over the one input class that cannot trigger it — the oracle could never have caught this, by fixture design rather than by threshold.

## Triage against Go ahead of #607: BASH-ONLY — this closes on the deletion Measured on `main` @ `4aecf78`. The question asked was not *"is this still true"* but *"does it still fire once `changelog-body-check.sh` is gone."* ### The defect reproduces exactly as filed Same fixture in every cell, two controls isolating the two variables: ``` BASH LC_ALL=C em-dash → FAIL check 1, exit 1 ← the defect BASH LC_ALL=C ASCII - → PASS ← control: locale is not the whole story BASH de_DE.UTF-8 em-dash → PASS ← control: separator is not the whole story ``` ### The Go twin does not have it, and structurally cannot ``` GO LC_ALL=C em-dash → PASS GO LC_ALL=C ASCII - → PASS GO de_DE.UTF-8 em-dash → PASS ``` `internal/gates/changelog_body_check.go:132` already uses the alternation form: ```go reHeaderVerDate = regexp.MustCompile(`^##[ \t]+\[v?([0-9]+\.[0-9]+\.[0-9]+)\][ \t]*(?:-|—)[ \t]*([0-9]{4}-[0-9]{2}-[0-9]{2})`) ``` Option (a) from the Scope section is **already the shipped Go form**. And the class cannot recur there under a different author: Go's `regexp` operates on UTF-8 and does not consult the locale, so a multibyte literal never decomposes into byte members. That is a property of the language, not of this code — worth saying because it means no Go-side guard or test is needed to keep it closed. ### Repo-wide: exactly one code site, and #607 deletes it ``` scripts/changelog-body-check.sh:152 ← the only CODE match, repo-wide ``` Every other hit for a bracket class containing a multibyte character is `[impl args…]` in an oracle usage comment (the `…` ellipsis inside literal square brackets in prose — not a regex), plus `validate-grammars.sh:43/45`, which are the comments documenting the PR#520 fix. Sweep was `grep -P '\[[^]]*[^\x00-\x7F][^]]*\]'` across every `*.sh`; the filtered and unfiltered runs disagreed at first and the discrepancy resolved to exactly that comment/code split. ### Disposition **No Go work. Close when #607 lands, not before** — the defect is live in the file until the file goes. ⚠️ Scope note, stated because it is the half this triage cannot settle: I checked whether the *defect* survives deletion. I did **not** check whether the **bats case** named in the title has a surviving home. If `tests/*.bats` outlives the scripts it exercises, the em-dash case becomes a test with no subject, which is a different disposition question and belongs with #607's AC on lib/test cleanup. Adjacent, and it is why this was invisible for so long: `changelog-body-check-oracle.sh` sets `LC_ALL=C` and its own comment block records that **every fixture body is ASCII**. So the differential harness runs in exactly the locale where this fires, over the one input class that cannot trigger it — the oracle could never have caught this, by fixture design rather than by threshold.
Owner

Implementation closeout

rt#1013 merged at exact head 2b89eb5a9d after official Lookout review 6066 and Forgejo CI 25/25 success.

The live Go changelog-body gate now uses the alternation form for the em-dash separator. Controls pass under LC_ALL=C and the measured UTF-8 locale C.utf8; the reverted [-—] byte-class mutation rejects the em-dash only under LC_ALL=C, while ASCII and UTF-8 inverse controls pass. Existing C6 corpus checks remain clean at 83 version headings and 332 section headers with zero violations.

The original Bash checker reference is historical: Bash changelog-body checking was retired by #607. The current implementation and tests are internal/gates/changelog_body_check.go and internal/gates/changelog_body_check_test.go.

Refs frankenbit/release-toolkit#1013.

## Implementation closeout rt#1013 merged at exact head 2b89eb5a9d3086ba969541f4f6710ea22c6b9bae after official Lookout review 6066 and Forgejo CI 25/25 success. The live Go changelog-body gate now uses the alternation form for the em-dash separator. Controls pass under LC_ALL=C and the measured UTF-8 locale C.utf8; the reverted [-—] byte-class mutation rejects the em-dash only under LC_ALL=C, while ASCII and UTF-8 inverse controls pass. Existing C6 corpus checks remain clean at 83 version headings and 332 section headers with zero violations. The original Bash checker reference is historical: Bash changelog-body checking was retired by #607. The current implementation and tests are internal/gates/changelog_body_check.go and internal/gates/changelog_body_check_test.go. Refs frankenbit/release-toolkit#1013.
Sign in to join this conversation.
No milestone
No project
No assignees
4 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/release-toolkit#611
No description provided.