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:
parent
9008e03b03
commit
6c746364af
6 changed files with 150 additions and 5 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
10
CHANGELOG.md
10
CHANGELOG.md
|
|
@ -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
5
changelog.d/167.md
Normal 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).
|
||||||
|
|
@ -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
|
||||||
|
# 316–789-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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue