feat: changelog_fragment_problem bounds entries at 300 characters (#167)

One definition in the fragment predicate; changelog-armed reds the PR
that writes the fragment and the assembler refuses at release, both by
inheritance. Doctrine names the number in BUILDER.md and CHANGELOG.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
claude-bot-andresmgsl 2026-07-24 18:25:46 +00:00
parent 9008e03b03
commit 6c746364af
6 changed files with 150 additions and 5 deletions

View file

@ -141,7 +141,11 @@ triage bug, and the move is to say so on the issue, not to guess.
cross-repo) — the exact prose that will be published, nothing else: `- ` cross-repo) — the exact prose that will be published, nothing else: `- `
bullets, and in a grouped repo the `### Added` / `### Changed` / bullets, and in a grouped repo the `### Added` / `### Changed` /
`### Fixed` headings inside the fragment, creating a rarer kind only when `### Fixed` headings inside the fragment, creating a rarer kind only when
a change genuinely is one. Never edit `CHANGELOG.md` for an entry — the a change genuinely is one. An entry is at most 300 characters — the
fragment guard reds longer (#167) — so a genuinely long change ships
several short entries, never one long one; wrapping an entry over
continuation lines is fine and never counts against it. Never edit
`CHANGELOG.md` for an entry — the
release PR assembles the section from the fragments (#112); the monotonic release PR assembles the section from the fragments (#112); the monotonic
guard still refuses anything that deletes a shipped heading. guard still refuses anything that deletes a shipped heading.
- Follow the repo's conventions file and match the code you touch. Tests are - Follow the repo's conventions file and match the code you touch. Tests are

View file

@ -2,10 +2,12 @@
The curated history of the ceremony itself. Each release's section is The curated history of the ceremony itself. Each release's section is
published verbatim as that release's body (lib/changelog.sh extracts it), published verbatim as that release's body (lib/changelog.sh extracts it),
so entries say what changed, cite the issue, and stop. Entries arrive as so entries say what changed, cite the issue, and stop — at most 300
fragments — one `changelog.d/<issue>.md` per PR, never an edit to this characters each, guard-enforced on the PR that writes the fragment (#167);
file — and the release PR assembles them into the next section here a genuinely long change ships several short entries, never one long one.
(`bin/changelog-assemble`, #112). Entries arrive as fragments — one `changelog.d/<issue>.md` per PR, never
an edit to this file — and the release PR assembles them into the next
section here (`bin/changelog-assemble`, #112).
## 0.3.0 — 2026-07-24 ## 0.3.0 — 2026-07-24

5
changelog.d/167.md Normal file
View file

@ -0,0 +1,5 @@
- `changelog_fragment_problem` bounds every entry at 300 normalized
characters, red on the PR that writes the fragment; the armed guard and
the assembler inherit the one definition (#167).
- BUILDER.md and CHANGELOG.md state the bound and the split rule: a long
change ships several short entries, never one long one (#167).

View file

@ -106,6 +106,15 @@ changelog_fragments() {
# publisher enforces at release time, moved onto the PR; # publisher enforces at release time, moved onto the PR;
# - no '### ' heading without a bullet before the next heading or EOF: # - no '### ' heading without a bullet before the next heading or EOF:
# the dangling grouped heading #98 taught us to refuse. # the dangling grouped heading #98 taught us to refuse.
# - no entry longer than 300 characters (#167): 0.3.0 shipped a cluster of
# 316789-character entries straight through the prose rule, so the
# bound moves onto the PR like every other fragment rule. Measured on
# the normalized entry — continuation lines joined, whitespace runs
# collapsed to one space, the '- '/'* ' marker stripped, the '(#N)'
# citation included — so wrapping alone can never red an entry. 300
# splits the measured history: every healthy entry passes untouched,
# the drift cluster does not. mawk's length() counts bytes; prose here
# is ASCII and the fuzz is acceptable.
changelog_fragment_problem() { changelog_fragment_problem() {
local file="$1" base problem local file="$1" base problem
base="${file##*/}" base="${file##*/}"
@ -147,6 +156,40 @@ changelog_fragment_problem() {
printf "fragment '%s' has an empty heading: '%s'\n" "$file" "$problem" printf "fragment '%s' has an empty heading: '%s'\n" "$file" "$problem"
return 1 return 1
fi fi
problem="$(
awk -v max=300 '
function flush( len, e) {
if (entry == "") return 0
e = entry
entry = ""
gsub(/[[:space:]]+/, " ", e)
sub(/^ /, "", e)
sub(/ $/, "", e)
len = length(e)
if (len > max) {
printf "%d\t%s\n", len, substr(e, 1, 60)
return 1
}
return 0
}
/^### / { if (flush()) exit; next }
/^[[:space:]]*[-*][[:space:]]/ {
if (flush()) exit
entry = $0
sub(/^[[:space:]]*[-*][[:space:]]+/, "", entry)
next
}
/^[[:space:]]*$/ { next }
entry != "" { entry = entry " " $0 }
END { flush() }
' "$file"
)"
if [ -n "$problem" ]; then
printf "fragment '%s' has a %s-character entry — '%s…' — the bound is 300: split it into multiple '- ' entries in this same fragment\n" \
"$file" "${problem%%$'\t'*}" "${problem#*$'\t'}"
return 1
fi
} }
# changelog_shape_problem <changelog> <fragments-dir> # changelog_shape_problem <changelog> <fragments-dir>

View file

@ -279,6 +279,25 @@ printf '%s\n' "- Added fragment mode." >"$TMP/fragments-dev-flat/changelog.d/115
check "fragment -dev + well-formed flat fragment passes" 0 "fragment mode" \ check "fragment -dev + well-formed flat fragment passes" 0 "fragment mode" \
in_tree fragments-dev-flat in_tree fragments-dev-flat
# The entry length bound (#167) reds the PR that writes the fragment, with
# the shared changelog_fragment_problem diagnosis.
fragment_tree fragments-dev-over-bound 1.2.4-dev <<'EOF'
# Changelog
## 1.2.3 — 2026-07-20
- The shipped entry.
EOF
printf -- '- %s\n' \
"$(awk 'BEGIN { s = ""; while (length(s) < 301) s = s "a"; print s }')" \
>"$TMP/fragments-dev-over-bound/changelog.d/115.md"
check "fragment mode refuses an over-bound entry, fragment and length named" 1 \
"115.md' has a 301-character entry" \
in_tree fragments-dev-over-bound
check "fragment mode over-bound refusal names the bound and the split fix" 1 \
"the bound is 300: split it into multiple '- ' entries in this same fragment" \
in_tree fragments-dev-over-bound
fragment_tree fragments-dev-grouped 1.2.4-dev <<'EOF' fragment_tree fragments-dev-grouped 1.2.4-dev <<'EOF'
# Changelog # Changelog

View file

@ -259,6 +259,78 @@ check "fragment predicate: a dangling grouped heading is refused, heading named"
"has an empty heading: '### Added'" \ "has an empty heading: '### Added'" \
changelog_fragment_problem "$PF/22.md" changelog_fragment_problem "$PF/22.md"
# --- the entry length bound (#167) -------------------------------------------
# mkchars <n> — a run of n 'a's, for entries of exact constructed length.
mkchars() {
awk -v n="$1" 'BEGIN { s = ""; while (length(s) < n) s = s "a"; print s }'
}
printf -- '- %s\n' "$(mkchars 301)" >"$PF/30.md"
check "length bound: a 301-character entry is refused, fragment and length named" 1 \
"30.md' has a 301-character entry" \
changelog_fragment_problem "$PF/30.md"
check "length bound: the refusal names the bound and the split fix" 1 \
"the bound is 300: split it into multiple '- ' entries in this same fragment" \
changelog_fragment_problem "$PF/30.md"
printf -- '- %s\n' "$(mkchars 300)" >"$PF/31.md"
check "length bound: an entry of exactly 300 passes" 0 "" \
changelog_fragment_problem "$PF/31.md"
{
printf -- '- %s\n' "$(mkchars 150)"
printf -- '- %s\n' "$(mkchars 150)"
printf -- '- %s\n' "$(mkchars 150)"
} >"$PF/32.md"
check "length bound: several within-bound entries pass though the file totals over 300" 0 "" \
changelog_fragment_problem "$PF/32.md"
{
printf -- '- %s\n' "$(mkchars 50)"
printf ' %s\n' "$(mkchars 50)"
printf ' %s\n' "$(mkchars 50)"
printf ' %s\n' "$(mkchars 50)"
printf ' %s\n' "$(mkchars 50)"
} >"$PF/33.md"
check "length bound: a ~250-character entry wrapped over four continuation lines passes" 0 "" \
changelog_fragment_problem "$PF/33.md"
{
printf '### Added\n\n'
printf -- '- %s\n' "$(mkchars 300)"
} >"$PF/34.md"
check "length bound: a '### ' heading counts toward no entry — 300 under it still passes" 0 "" \
changelog_fragment_problem "$PF/34.md"
{
printf '### Added\n\n'
printf -- '- %s\n' "$(mkchars 301)"
} >"$PF/35.md"
check "length bound: a grouped bullet is bounded the same as a flat one" 1 \
"35.md' has a 301-character entry" \
changelog_fragment_problem "$PF/35.md"
{
printf -- '- %s\n' "$(mkchars 301)"
printf -- '- Short.\n'
} >"$PF/36.md"
assert_single_diagnosis() {
local count
count="$(changelog_fragment_problem "$PF/36.md" | grep -c "301-character")"
[ "$count" = 1 ] || {
printf 'wanted one diagnosis, got %s\n' "$count"
return 1
}
}
check "length bound: an over-bound entry mid-file is diagnosed exactly once" 0 "" \
assert_single_diagnosis
check "length bound: published sections stay unvalidated — 0.3.0's over-bound entries red nothing" 0 "" \
changelog_section_problem "$ROOT/CHANGELOG.md" 0.3.0
check "length bound: published sections stay unvalidated — 0.2.0 reds nothing either" 0 "" \
changelog_section_problem "$ROOT/CHANGELOG.md" 0.2.0
# --- the assembler (#114) ---------------------------------------------------- # --- the assembler (#114) ----------------------------------------------------
assert_assemble() { assert_assemble() {