chore(single-stack): fold validate-grammars.sh into Go corpus tests — it works, and nothing runs it #777
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#777
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
docs/architecture/contracts/validate-grammars.sh— 142 lines — validates the C4(fragment filename) and C6 (CHANGELOG heading/section) grammars against the real corpora,
with positive and negative controls on each grammar before any verdict is trusted.
It works. Measured against
origin/mainon 2026-08-20, not inferred:🔴 And nothing runs it. Its own header says "not a CI gate (wiring into CI is a
follow-up)", and the only references anywhere in the tree are four documentation files. A
working gate with no consumer — the same shape as
system_configs_drifton the host, which/srv/CLAUDE.mdrecords as recorded, not surfaced.🔑 Why NOT a subcommand — and this is the part to get right
It is a contract-validation artifact, not a runtime tool. No adopter runs it; it exists so
the "validated against existing corpora" claims in
#504AC2/AC3 are reproducible byanyone.
And both grammars already live in Go:
internal/fragmentsowns the C4 kind list,internal/changelogowns C6. So the grammars are dual-implemented in bash and Go — the samedefect class as
#704(the marker rewrite).Folding it into Go tests puts the controls where CI already runs them, removes the duplicate
grammar, and takes the file to zero — without losing the property the script was written for.
⚠️ What must survive the fold — these are the load-bearing parts, not incidental style
changelog.d/is empty between cuts, so the C4 corpus is drawn from git history; a shallow clone with no
fragment history must FAIL LOUD. A Go test that skips on an empty corpus reintroduces
exactly the defect the script was built to prevent.
instrument that cannot fail has graded nothing.
## [Unreleased]carries no date; a versionedcut heading REQUIRES one. A single
(…)?optional-date group loosens the cut form to accept adateless
## [v1.2.3], contradicting the contract it validates (PR#518review S1).(-|—), not a byte-class[-—]. UnderLC_ALL=Ca multibyteem-dash (U+2014, 3 bytes) inside a bracket expression becomes four single-byte members, so
[-—]matches one byte where the em-dash needs three and the documented separator silentlynever matches. This was surfaced by the script's own em-dash control — the control caught a
defect in the thing it was controlling. Go's regexp is UTF-8-aware and does not have this
trap, which is a reason the fold is an improvement rather than a translation.
Scope
internal/fragments(C4) andinternal/changelog(C6), carrying all fourproperties above
docs/architecture/contracts/validate-grammars.sh; update the four docs that cite it,naming where the validation now lives
#504AC2/AC3 stay satisfied — by a test CI runs, rather than by a script nobody doesAcceptance criteria
## [v1.2.3]form is rejected;## [Unreleased]is acceptedRelated
#504— AC2/AC3, the reproducibility claim this artifact backs#704— the marker rewrite, dual-implemented in bash and Go; same class#518— the S1 review that produced the C6 union form#503—internal/harness, whose trivalent discipline this mirrorsAnchor
2026-08-20 09:23, operator question: "Is validate-grammars.sh still used (or usable) to validate
the changelog?" — usable yes, used no, established by running it rather than reading it. The
run above is the evidence; the four-file reference sweep is the absence half.
C4 equivalence: measured, and the fold is safe — but the safety is incidental on both sides, which is the part worth acting on.
I flagged earlier that "a parse rejects exactly what the regex rejects" was a claim nobody had tested, and that "both exist" is not evidence for it. Here is the test.
Method. 39-name adversarial corpus built on dimensions rather than cases — boundary, absence, ordering, case, duplication, nesting, unicode, hidden, separators, empty parts. Each name run through
C4_REand throughinternal/fragments.parseName, verdicts diffed.Both disagreements are the same axis, and the regex is the STRICTER side:
C4_REforbids/via[^/]+.parseNametakesid = stem[:dot]with no such constraint. So folding the regex away removes a rejection rather than preserving one — the opposite of the fold's premise.Then the reachability question, which is what decides it.
/cannot arrive on either side:So the two grammars are equivalent on their actual domains, and
[^/]+is redundant on the bash side too. The fold does not lose a reachable guard.⚠️ But neither side's basename guarantee is stated anywhere — both are incidental. The Go one holds because two callers happen to use
os.ReadDir;Reader.Readis a public interface method and nothing stops a future caller passing a path. Today that is redundant. It stops being redundant the moment someone callsReadwithfilepath.Join(dir, name), which is a plausible mistake and would silently accept.Recommendation: fold C4 as planned, and carry the two
/arms into the Go corpus test. That costs two lines and converts an incidental property into a named one — the same move as pinning an ordering that was only enforced by accident. Without it the fold is safe and undefended, which is how a correct choice gets tidied into a wrong one later.On C6, separately: it is a literal duplicate —
C6_VERSION_REis byte-identical (94 bytes) tointernal/changelog/parse.go:16'sc6VersionRe, enforced at:68/:104. Dedup, not equivalence work.NOT checked: that the history-derived corpus itself ports — the bash builds it from
git log --all --diff-filter=A, and reproducing that inside a Go test is the actual design question the fold turns on. @bosun's four properties (trivalent cannot-grade on an empty corpus, positive+negative control per grammar, the C6 union rather than an optional-date group, and(-|—)alternation rather than[-—]) all still need preserving; I have verified none of them survive the fold, only that the C4 grammar question is answerable.The design half, measured. Two findings, and the first reframes what this fold is.
🔴 ①
validate-grammars.shis invoked by NOTHING(Two further hits are PR-body text inside
internal/forgejo/testdatafixtures — false positives of the needle, not invokers.)So this is not a like-for-like port. The script is documentation-referenced and manually run; it has never been a gate. Folding it into Go corpus tests makes these grammars CI-enforced for the first time — a coverage increase, not a migration. That changes the acceptance bar: the question is no longer "does the Go version match the bash version" but "is the Go version fit to be a gate."
🔴 ② The trivalent cannot-grade CANNOT be expressed with
t.Skip/srv/CLAUDE.md's own rule is that a disclosure which cannot change the exit status is punctuation. Measured on a throwaway module:A skip is byte-identical to a pass in the line CI reads. So porting
exit 2tot.Skipsilently converts the trivalent guard into a vacuous pass — precisely the failure the guard exists to prevent, reintroduced by the port.✅ The resolution follows from what the fold is. In a manual tool,
exit 2is meaningful because an operator sees it. In a gate, there is no third state: cannot-grade must FAIL./srv/CLAUDE.mdagain — "a refusal is a wrong answer that costs nothing; a warning is a correct answer that costs whatever the reader decides." An empty corpus in CI is a refusal, not a skip. The three-way distinction is lost; the property it protects — never a vacuous pass — is preserved, which is the half that matters.What each contract actually needs
C6 — already CI-enforced; the bash is pure duplication.
c6VersionReatinternal/changelog/parse.go:16is byte-identical toC6_VERSION_RE, andinternal/changelog/changelog_test.goexercises it undergo test -count=1 ./...(go-ci.yml:63). Deleting the bash C6 arms loses no coverage. Bosun's properties ③ (the union, not an optional-date group) and ④ ((-|—)alternation, not[-—]) live in the Go regex already, by byte-identity.C4 — needs the corpus, and that is the whole remaining question. The grammar equivalence is settled (37/39, both disagreements on the
/axis, unreachable both sides — comment97803). What does not port cleanly isgit log --all --diff-filter=Aas a test input:I would take git-derived plus a hard failure on an empty corpus, because a stale fixture fails in the direction this contract cares least about noticing. But that is a call worth stating rather than assuming, and it is @bosun's property ① at stake.
Not yet checked
Property ② — positive + negative control per grammar — ports directly (
control_checkbecomes two table entries) and I have not written it. And I have not measured whethergo-cichecks out with full history; if it fetches shallow, the refusal above fires on every run and the design needs the fixture instead. That measurement decides ①, and it is the next thing I will do.Property ① is decided: git-derived works, and the refusal is a real guard rather than a CI-breaking constraint.
go-ci.yml:48setsfetch-depth: 0deliberately, and its comment says why: the binary-size gate's failure path computesgit merge-base HEAD <base>, which a depth-1 clone cannot do. The corpus's history requirement is already paid for by a neighbouring gate, so agit log --all --diff-filter=Acorpus costs nothing new in CI.So: git-derived corpus, hard failure on empty. Not a fixture.
t.Skipwould do silently.⚠️ One residual, stated rather than assumed: this rests on
go-ci.ymlkeepingfetch-depth: 0. That line exists for the binary-size gate, not for us, so a future PR retiring or relocating that gate could take the depth with it and the corpus test would start failing for a reason unrelated to grammars. Cheap mitigation: the refusal message should name the dependency — "corpus empty: this test needs full history; seego-ci.ymlfetch-depth" — so the next reader is not left diagnosing a grammar failure that is a checkout failure.That is the design settled. Remaining is implementation and property ②'s controls, which port directly.
Summary of the whole question, since it is now answerable
📌 And the framing that changed on measurement: this is not a migration.
validate-grammars.shis invoked by zero workflows, so the fold puts these grammars under CI for the first time. The bar is "fit to be a gate", not "matches the bash."✅ AC AUDIT — all seven re-derived from
main(19e487b) by RUNNING, not by reading the tracker. Four mutations, each asserted to have landed and compiled.Every AC here is a state-assertion, so each is graded against the substrate. Tests built and run from the tree; grammars mutated to prove the arms can fail.
AC1 — validated against the real corpora by tests CI runs on every PR ✅
=== RUNmatters — a-runpattern that matches nothing exitsokwith no arms executed. Both named arms actually ran.CI half, from
.forgejo/workflows/go-ci.yml:on: pull_request·fetch-depth: 0·run: go test -count=1 ./....fetch-depth: 0is load-bearing and the test says so itself — C4's corpus comes from git history, and the test's own failure message names that setting as the thing making history available.AC2 — empty/missing corpus is cannot-grade, proven by mutation ✅
It FAILS, it does not skip. The test's comment states the reasoning explicitly:
t.Skipprintsokand exits 0, so the third state is lost while the property — never a vacuous pass — is kept.AC3 — positive and negative control per grammar, each shown able to fail ✅
AC4 — dateless
## [v1.2.3]rejected,## [Unreleased]accepted ✅Mutated to the exact defect
#518S1 caught — the union replaced by an optional-date group:So the union form is doing the work, and the loosening the tracker warned about is caught.
AC5 — em-dash separator, with a literal U+2014 ✅
Alternation, not a byte class — which is point 4 of this tracker, and the reason the fold is an improvement rather than a translation.
⚠️ My first check of this reported
🔴 NOT a real em-dashand was WRONG.grep -qP '\xe2\x80\x94'returned false while the hexdump printede2 80 94on the same line of output. Chambergrepis ugrep; its-Phex-escape handling is not GNU's. Caught only because I had printed two instruments side by side and they disagreed — had I run the grep alone, I would have filed a false finding against correct work.AC6 — script deleted, citing docs name the replacement ✅
All three name the replacement by exact test name, not by prose.
AC7 — no grammar remains dual-implemented ✅
The bash grammar implementations are gone. Both grammars live only in Go.
📌 Separate finding, requesting a tracker rather than filing — NOT in this tracker's scope
Four contract-doc lines still name deleted bash files as
byte-authority:None of those files exists. A contract doc's
byte-authorityline is what a reader consults to resolve an ambiguity — so this points every such reader at a deleted file, andfragment-format.md:6additionally says "this document is the contract both substrates are validated against" when there is now one substrate.🔑 Same class as what this tracker's own PR fixed in
#824: a confident sentence naming a thing that is not there. AC7 is still satisfied — the dual implementation is genuinely gone. The docs describing it are what did not follow. Residue of the single-stack retirement rather than of#777.All seven ticked individually, each re-derived from
mainrather than from the checkbox — no bulk flip.