bug(changelog-body-check): em-dash separator regex fails under LC_ALL=C (multibyte-in-byte-class trap, sibling to PR#520) #611
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
4 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit#611
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
Bats case
changelog-body-check: em-dash separator (tmux-tell shape) parses (#282 defensive)fails ONLY underLC_ALL=C. Surfaced by Engineer during PR#610 (#476 site 1) framing-verify; isolated cleanly against both variables (passes on branch WITHOUTLC_ALL=C, reproduces on cleanorigin/mainunderLC_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 underLC_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 invalidate-grammars.sh.The trap:
[-—]as "hyphen OR em-dash character" — 2 members, one of which is a 3-byte multibyte characterLC_ALL=C, the regex engine treats bytes, and[-—]decomposes to[-<0xe2><0x80><0x94>]— 4 members, each 1-byte—character never appears as a single 0xe2 / 0x80 / 0x94 byteReal 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 underLC_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:(-|—)instead of[-—](alternation, not bracket-class)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=Crow (/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
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
validate-grammars.shfor the same class. Referenced pattern: em-dash bracket-class → alternation/srv/CLAUDE.md § reflex table — Text-matching a tool's human-readable output—LC_ALL=Ctrap for locale-dependent tool output. Same class applied to REGEX underLC_ALL=C.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).
A third site of this class, different mechanism —
fragment-check.sh'swc -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=Cchangeswc -mfrom counting CHARACTERS to counting BYTES.And the sting is that
fragment-check.shchosewc -mDELIBERATELY, to defend against exactly this, with the reason in a comment at the callsite:Changelog fragments are full of
—and→, which is precisely the "unicode-punctuation fragments" the comment names.LC_ALL=Csilently 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=Cas 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 withwc -cand 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:
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
wc -m, baresorton non-ASCII,[[ =~ ]]with multibyte literals,trranges.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).
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 oncechangelog-body-check.shis gone."The defect reproduces exactly as filed
Same fixture in every cell, two controls isolating the two variables:
The Go twin does not have it, and structurally cannot
internal/gates/changelog_body_check.go:132already uses the alternation form:Option (a) from the Scope section is already the shipped Go form. And the class cannot recur there under a different author: Go's
regexpoperates 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
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), plusvalidate-grammars.sh:43/45, which are the comments documenting the PR#520 fix. Sweep wasgrep -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/*.batsoutlives 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.shsetsLC_ALL=Cand 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.Implementation closeout
rt#1013 merged at exact head
2b89eb5a9dafter 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.