From 6c746364af173ec517e96924ea3752391c3dfe0e Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Fri, 24 Jul 2026 18:25:46 +0000 Subject: [PATCH] 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 --- BUILDER.md | 6 ++- CHANGELOG.md | 10 +++-- changelog.d/167.md | 5 +++ lib/changelog.sh | 43 +++++++++++++++++++++ test/changelog-armed.test.sh | 19 ++++++++++ test/changelog.test.sh | 72 ++++++++++++++++++++++++++++++++++++ 6 files changed, 150 insertions(+), 5 deletions(-) create mode 100644 changelog.d/167.md diff --git a/BUILDER.md b/BUILDER.md index 95c3d0d..fdecb62 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -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: `- ` bullets, and in a grouped repo the `### Added` / `### Changed` / `### 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 guard still refuses anything that deletes a shipped heading. - Follow the repo's conventions file and match the code you touch. Tests are diff --git a/CHANGELOG.md b/CHANGELOG.md index ca4ed8e..ab7e915 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,12 @@ The curated history of the ceremony itself. Each release's section is 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 -fragments — one `changelog.d/.md` per PR, never an edit to this -file — and the release PR assembles them into the next section here -(`bin/changelog-assemble`, #112). +so entries say what changed, cite the issue, and stop — at most 300 +characters each, guard-enforced on the PR that writes the fragment (#167); +a genuinely long change ships several short entries, never one long one. +Entries arrive as fragments — one `changelog.d/.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 diff --git a/changelog.d/167.md b/changelog.d/167.md new file mode 100644 index 0000000..cf450cf --- /dev/null +++ b/changelog.d/167.md @@ -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). diff --git a/lib/changelog.sh b/lib/changelog.sh index a484857..a177665 100644 --- a/lib/changelog.sh +++ b/lib/changelog.sh @@ -106,6 +106,15 @@ changelog_fragments() { # publisher enforces at release time, moved onto the PR; # - no '### ' heading without a bullet before the next heading or EOF: # 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() { local file="$1" base problem base="${file##*/}" @@ -147,6 +156,40 @@ changelog_fragment_problem() { printf "fragment '%s' has an empty heading: '%s'\n" "$file" "$problem" return 1 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 diff --git a/test/changelog-armed.test.sh b/test/changelog-armed.test.sh index 07124a2..df00bdb 100644 --- a/test/changelog-armed.test.sh +++ b/test/changelog-armed.test.sh @@ -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" \ 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' # Changelog diff --git a/test/changelog.test.sh b/test/changelog.test.sh index 8754407..a04aa74 100755 --- a/test/changelog.test.sh +++ b/test/changelog.test.sh @@ -259,6 +259,78 @@ check "fragment predicate: a dangling grouped heading is refused, heading named" "has an empty heading: '### Added'" \ changelog_fragment_problem "$PF/22.md" +# --- the entry length bound (#167) ------------------------------------------- + +# mkchars — 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) ---------------------------------------------------- assert_assemble() {