From 72fa3e0b4d5fc97ce466d184aa8e1945a519a421 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:00:48 +0000 Subject: [PATCH 01/10] feat(changelog): the terminal issue cite joins the fragment guard Refs #262 --- lib/changelog.sh | 76 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 70 insertions(+), 6 deletions(-) diff --git a/lib/changelog.sh b/lib/changelog.sh index 974b4a8..a054bb9 100644 --- a/lib/changelog.sh +++ b/lib/changelog.sh @@ -115,8 +115,22 @@ changelog_fragments() { # 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. +# - every entry ends with its issue citation (#262): one '(' group of +# '#N', 'repo#N' or 'owner/repo#N' references separated by ', ', then +# ')', then the final '.' and nothing after it. Stated as style and +# enforced by nobody, this rule cost #255 a full four-bot round on a +# missing '(#248)'; the fragment rules that live in this guard drew no +# review comment at all across the same fifteen PRs. Measured on the +# same normalized entry as the bound above, so a citation that wraps +# onto a continuation line still counts. The repo token is the one the +# filename rule already admits, so '-.md' and its cite +# cannot drift apart; the two halves of one convention. A single group +# is what makes 'terminal' checkable — '(#236, #250).' lands two issues +# in one entry, '(#236) and (#250).' does not. The citation need not +# name the file's own issue: the filename already carries the +# authorizing one, so a fragment may cite the incident beside it. changelog_fragment_problem() { - local file="$1" base problem + local file="$1" base problem kind detail rest base="${file##*/}" if ! printf '%s\n' "$base" | grep -qE '^([a-z][a-z0-9-]*-)?[0-9]+\.md$'; then @@ -157,9 +171,35 @@ changelog_fragment_problem() { return 1 fi + # One walk of the entries, two rules, and the order between them is + # deliberate: an over-long entry anywhere outranks a citation problem + # anywhere, so the length diagnosis a fragment already draws is the same + # one it drew before the citation rule existed. Both read the entry the + # same normalizer produces, which is the whole reason they share a pass. problem="$( awk -v max=300 ' - function flush( len, e) { + # cite_problem — "", "uncited" or "misplaced". Counting the + # groups is what distinguishes the two admitted shapes: one group + # closing the entry passes however many references it carries, and a + # second group anywhere means no single group is terminal. + function cite_problem(e, rest, groups, consumed, group_end) { + rest = e + groups = 0 + consumed = 0 + while (match(rest, /\((([A-Za-z0-9._-]+\/)?[a-z][a-z0-9-]*)?#[0-9]+(, (([A-Za-z0-9._-]+\/)?[a-z][a-z0-9-]*)?#[0-9]+)*\)/)) { + groups++ + group_end = consumed + RSTART + RLENGTH - 1 + consumed = group_end + rest = substr(rest, RSTART + RLENGTH) + } + if (groups == 0) return e ~ /#[0-9]/ ? "misplaced" : "uncited" + if (groups > 1) return "misplaced" + return (group_end == length(e) - 1 && substr(e, group_end + 1) == ".") ? "" : "misplaced" + } + function excerpt(e) { + return length(e) > 60 ? substr(e, 1, 60) "…" : e + } + function flush( len, e, kind) { if (entry == "") return 0 e = entry entry = "" @@ -168,9 +208,14 @@ changelog_fragment_problem() { sub(/ $/, "", e) len = length(e) if (len > max) { - printf "%d\t%s\n", len, substr(e, 1, 60) + printf "long\t%d\t%s\n", len, excerpt(e) return 1 } + kind = cite_problem(e) + if (kind != "" && cite_kind == "") { + cite_kind = kind + cite_excerpt = excerpt(e) + } return 0 } /^### / { if (flush()) exit; next } @@ -182,12 +227,31 @@ changelog_fragment_problem() { } /^[[:space:]]*$/ { next } entry != "" { entry = entry " " $0 } - END { flush() } + END { + if (flush()) exit + if (cite_kind != "") printf "%s\t\t%s\n", cite_kind, cite_excerpt + } ' "$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'}" + kind="${problem%%$'\t'*}" + rest="${problem#*$'\t'}" + detail="${rest%%$'\t'*}" + rest="${rest#*$'\t'}" + case "$kind" in + long) + printf "fragment '%s' has a %s-character entry — '%s' — the bound is 300: split it into multiple '- ' entries in this same fragment\n" \ + "$file" "$detail" "$rest" + ;; + uncited) + printf "fragment '%s' has an entry with no issue citation — '%s' — end it with the issue it comes from: '(#N).'\n" \ + "$file" "$rest" + ;; + *) + printf "fragment '%s' has an entry whose issue citation is not terminal — '%s' — exactly one '(#N)' group ends the entry, the final '.' after it\n" \ + "$file" "$rest" + ;; + esac return 1 fi } From 46b80fb6b26b491b18e7c03c55212901b2fd1c6f Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:01:45 +0000 Subject: [PATCH 02/10] fix(changelog): the four drifted fragments carry a terminal cite Refs #262 --- changelog.d/237.md | 2 +- changelog.d/241.md | 2 +- changelog.d/248.md | 2 +- changelog.d/280.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/changelog.d/237.md b/changelog.d/237.md index bfe9cd3..59a0d4f 100644 --- a/changelog.d/237.md +++ b/changelog.d/237.md @@ -2,4 +2,4 @@ - Define the doors-unchanged drill record and an executable release-path list, so a release may reuse live evidence only when its door bytes are unchanged - since the last rehearsed tag. (#237) + since the last rehearsed tag (#237). diff --git a/changelog.d/241.md b/changelog.d/241.md index e7e7ec5..e675aa9 100644 --- a/changelog.d/241.md +++ b/changelog.d/241.md @@ -1,3 +1,3 @@ ### Fixed -- Preserve active claims when an open local pull request links them with `Refs #N`. +- Preserve active claims when an open local pull request links them with `Refs #N` (#241). diff --git a/changelog.d/248.md b/changelog.d/248.md index d6c3bbd..5ff7a3f 100644 --- a/changelog.d/248.md +++ b/changelog.d/248.md @@ -1,3 +1,3 @@ ### Added -- Document the optional, operator-ruled release-epic flow for governed repositories. (#248) +- Document the optional, operator-ruled release-epic flow for governed repositories (#248). diff --git a/changelog.d/280.md b/changelog.d/280.md index d4f1b2d..a3c0ca2 100644 --- a/changelog.d/280.md +++ b/changelog.d/280.md @@ -2,4 +2,4 @@ - CONTRIBUTING.md now keeps vendored doctrine self-contained: state the rule, retain at most one sentence of why, cite the local record bare, and leave the - incident narrative in that record. (#280) + incident narrative in that record (#280). From c40d9412f6d66261854150c211cebc907bdccf9c Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:06:29 +0000 Subject: [PATCH 03/10] =?UTF-8?q?wip(test):=20fixture=20sweep,=20first=20p?= =?UTF-8?q?ass=20=E2=80=94=20over-reaches=20onto=20section=20fixtures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '- Fixed entry.' is shared between the dangling-heading fragment fixture and the section-predicate fixture, so the global replace crossed D4's line. Next commit filters to fragments that actually red on the cite rule. Refs #262 --- test/changelog-armed.test.sh | 24 ++++----- test/changelog-assemble.test.sh | 72 +++++++++++++------------- test/changelog-assembled.test.sh | 20 ++++---- test/changelog.test.sh | 86 ++++++++++++++++---------------- 4 files changed, 101 insertions(+), 101 deletions(-) diff --git a/test/changelog-armed.test.sh b/test/changelog-armed.test.sh index 66c0f88..0703a52 100644 --- a/test/changelog-armed.test.sh +++ b/test/changelog-armed.test.sh @@ -275,7 +275,7 @@ fragment_tree fragments-dev-flat 1.2.4-dev <<'EOF' - The shipped entry. EOF -printf '%s\n' "- Added fragment mode." >"$TMP/fragments-dev-flat/changelog.d/115.md" +printf '%s\n' "- Added fragment mode (#115)." >"$TMP/fragments-dev-flat/changelog.d/115.md" check "fragment -dev + well-formed flat fragment passes" 0 "fragment mode" \ in_tree fragments-dev-flat @@ -310,7 +310,7 @@ EOF cat >"$TMP/fragments-dev-grouped/changelog.d/115.md" <<'EOF' ### Changed -- Added fragment mode. +- Added fragment mode (#115). EOF check "fragment -dev + well-formed grouped fragment passes" 0 "fragment mode" \ in_tree fragments-dev-grouped @@ -322,11 +322,11 @@ fragment_tree fragments-dev-mixed 1.2.4-dev <<'EOF' - The shipped entry. EOF -printf '%s\n' "- Flat fragment." >"$TMP/fragments-dev-mixed/changelog.d/114.md" +printf '%s\n' "- Flat fragment (#115)." >"$TMP/fragments-dev-mixed/changelog.d/114.md" cat >"$TMP/fragments-dev-mixed/changelog.d/115.md" <<'EOF' ### Fixed -- Grouped fragment. +- Grouped fragment (#115). EOF check "fragment mode refuses mixed shapes with the shared assembler diagnosis" 1 \ "fragment 'changelog.d/115.md' is grouped but fragment 'changelog.d/114.md' is not" \ @@ -342,7 +342,7 @@ EOF cat >"$TMP/fragments-dev-all-grouped-over-flat/changelog.d/115.md" <<'EOF' ### Fixed -- Grouped fragment. +- Grouped fragment (#115). EOF check "fragment mode refuses an all-grouped set over a flat published section" 1 \ "changelog.d/115.md' is grouped but newest published section '1.2.3'" \ @@ -357,7 +357,7 @@ fragment_tree fragments-dev-flat-over-grouped 1.2.4-dev <<'EOF' - The shipped entry. EOF -printf '%s\n' "- Flat fragment." >"$TMP/fragments-dev-flat-over-grouped/changelog.d/115.md" +printf '%s\n' "- Flat fragment (#115)." >"$TMP/fragments-dev-flat-over-grouped/changelog.d/115.md" check "fragment mode refuses a flat set over a grouped published section" 1 \ "changelog.d/115.md' is flat but newest published section '1.2.3'" \ in_tree fragments-dev-flat-over-grouped @@ -376,14 +376,14 @@ printf '%s\n' "grouped" >"$TMP/fragments-dev-flip/changelog.d/shape" cat >"$TMP/fragments-dev-flip/changelog.d/115.md" <<'EOF' ### Fixed -- Grouped fragment. +- Grouped fragment (#115). EOF check "fragment mode: 'grouped' sentinel admits the flip tree over a flat published section" 0 \ "fragment mode" in_tree fragments-dev-flip # Post-flip drift is refused on its own PR: a flat probe fragment atop the # flip tree goes red — beside grouped fragments the mix rule names it first. -printf '%s\n' "- Flat probe." >"$TMP/fragments-dev-flip/changelog.d/116.md" +printf '%s\n' "- Flat probe (#116)." >"$TMP/fragments-dev-flip/changelog.d/116.md" check "fragment mode: a flat probe atop the flip tree is refused" 1 \ "changelog.d/115.md' is grouped but fragment 'changelog.d/116.md' is not" \ in_tree fragments-dev-flip @@ -393,7 +393,7 @@ rm "$TMP/fragments-dev-flip/changelog.d/116.md" # holds the shape: an all-flat set under 'grouped' is refused, sentinel # named — the published-section inference never gets a say. rm "$TMP/fragments-dev-flip/changelog.d/115.md" -printf '%s\n' "- Flat probe." >"$TMP/fragments-dev-flip/changelog.d/116.md" +printf '%s\n' "- Flat probe (#116)." >"$TMP/fragments-dev-flip/changelog.d/116.md" check "fragment mode: a flat set under the 'grouped' sentinel refused, sentinel named" 1 \ "changelog.d/116.md' is flat but 'changelog.d/shape' declares grouped" \ in_tree fragments-dev-flip @@ -417,7 +417,7 @@ EOF cat >"$TMP/fragments-dev-no-published/changelog.d/115.md" <<'EOF' ### Fixed -- Grouped fragment. +- Grouped fragment (#115). EOF check "fragment mode accepts a consistent set with no published section" 0 \ "fragment mode" in_tree fragments-dev-no-published @@ -452,7 +452,7 @@ fragment_tree fragments-bad-name 1.2.4-dev <<'EOF' - The shipped entry. EOF -printf '%s\n' "- An entry." >"$TMP/fragments-bad-name/changelog.d/notes.md" +printf '%s\n' "- An entry (#1)." >"$TMP/fragments-bad-name/changelog.d/notes.md" check "fragment mode quotes malformed-fragment diagnosis and file" 1 \ "fragment 'changelog.d/notes.md' is not named for its issue" \ in_tree fragments-bad-name @@ -531,7 +531,7 @@ check "same changelog fails in legacy mode" 1 "development tree" \ mkdir -p "$TMP/env-tree" printf '1.2.4-dev\n' >"$TMP/env-tree/VERSION" -printf '# Changelog\n\n## Unreleased\n\n- Pending.\n' >"$TMP/env-tree/NOTES.md" +printf '# Changelog\n\n## Unreleased\n\n- Pending (#1).\n' >"$TMP/env-tree/NOTES.md" # A non-default changelog name proves the env var is honored, not the default. env_tree() { (cd "$TMP/env-tree" && CHANGELOG=NOTES.md VERSION_SOURCE=file bash "$SCRIPT") diff --git a/test/changelog-assemble.test.sh b/test/changelog-assemble.test.sh index de008b9..bf0e95a 100644 --- a/test/changelog-assemble.test.sh +++ b/test/changelog-assemble.test.sh @@ -55,13 +55,13 @@ tree flat-one <"$TMP/flip/changelog.d/shape" frag flip 40.md <<'EOF' ### Added -- Forty landed. +- Forty landed (#40). EOF check "sentinel: the flip release assembles grouped over a flat published section" 0 \ "consumed 1 fragment" in_tree flip 0.2.0 2026-07-24 check "sentinel: the written flip section is exact" 0 "" \ assert_file "$TMP/flip/CHANGELOG.md" \ - $'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n### Added\n\n- Forty landed.\n\n## 0.1.0 — 2026-07-01\n\n- The shipped entry.' + $'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n### Added\n\n- Forty landed (#40).\n\n## 0.1.0 — 2026-07-01\n\n- The shipped entry.' check "sentinel: changelog.d/shape survives consumption" 0 "" \ test -e "$TMP/flip/changelog.d/shape" @@ -171,7 +171,7 @@ $BASE_CHANGELOG EOF printf 'grouped\n' >"$TMP/flip-flat-frag/changelog.d/shape" frag flip-flat-frag 41.md <<'EOF' -- Flat forty-one. +- Flat forty-one (#41). EOF check "sentinel: a flat fragment under 'grouped' refuses, sentinel named" 1 \ "changelog.d/shape' declares grouped" in_tree flip-flat-frag 0.2.0 2026-07-24 @@ -183,7 +183,7 @@ printf 'Grouped\n' >"$TMP/flip-malformed/changelog.d/shape" frag flip-malformed 42.md <<'EOF' ### Added -- Forty-two. +- Forty-two (#42). EOF check "sentinel: a malformed sentinel refuses, file named" 1 \ "changelog.d/shape' declares neither shape" in_tree flip-malformed 0.2.0 2026-07-24 @@ -196,13 +196,13 @@ tree preamble-only <<'EOF' Only preamble so far. EOF frag preamble-only 1.md <<'EOF' -- The first entry ever. +- The first entry ever (#1). EOF check "a changelog with no section yet gets the section after the preamble" 0 "" \ in_tree preamble-only 0.1.0 2026-07-24 check "preamble-only write is exact" 0 "" \ assert_file "$TMP/preamble-only/CHANGELOG.md" \ - $'# Changelog\n\nOnly preamble so far.\n\n## 0.1.0 — 2026-07-24\n\n- The first entry ever.' + $'# Changelog\n\nOnly preamble so far.\n\n## 0.1.0 — 2026-07-24\n\n- The first entry ever (#1).' # --- --check is provably read-only ------------------------------------------- @@ -210,7 +210,7 @@ tree check-readonly <"$TMP/flagged/NOTES.md" -printf -- '- Flagged entry.\n' >"$TMP/flagged/frags/2.md" +printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Shipped (#1).\n' >"$TMP/flagged/NOTES.md" +printf -- '- Flagged entry (#2).\n' >"$TMP/flagged/frags/2.md" check "--changelog and --dir override the defaults" 0 "" \ "$TOOL" 0.2.0 2026-07-24 --changelog "$TMP/flagged/NOTES.md" --dir "$TMP/flagged/frags" check "the flag-driven write landed in the named changelog" 0 "" \ - grep -qF -- "- Flagged entry." "$TMP/flagged/NOTES.md" + grep -qF -- "- Flagged entry (#2)." "$TMP/flagged/NOTES.md" # --- refusals: each names the file responsible ------------------------------- @@ -270,7 +270,7 @@ frag dangling 4.md <<'EOF' ### Fixed -- Fixed entry. +- Fixed entry (#4). EOF check "a dangling grouped heading refuses, file and heading named" 1 \ "fragment 'changelog.d/4.md' has an empty heading: '### Added'" \ @@ -282,7 +282,7 @@ EOF frag smuggled 6.md <<'EOF' ## 0.2.0 — 2026-07-24 -- An entry under a smuggled heading. +- An entry under a smuggled heading (#6). EOF check "a fragment carrying a '## ' line refuses, file named" 1 \ "fragment 'changelog.d/6.md' carries a '## ' heading" \ @@ -292,7 +292,7 @@ tree stray-txt <"$TMP/no-changelog/changelog.d/2.md" +printf -- '- Entry (#2).\n' >"$TMP/no-changelog/changelog.d/2.md" check "a missing changelog refuses" 1 "no such file" \ in_tree no-changelog 0.2.0 @@ -404,12 +404,12 @@ frag round-trip 30.md <<'EOF' ### Added - Thirty — wraps onto a - continuation line with a naïve café. + continuation line with a naïve café (#30). EOF frag round-trip 29.md <<'EOF' ### Fixed -- Fixed twenty-nine. +- Fixed twenty-nine (#29). EOF CHECKED="$(in_tree round-trip 0.2.0 2026-07-24 --check)" check "round trip: write mode succeeds after --check" 0 "" \ diff --git a/test/changelog-assembled.test.sh b/test/changelog-assembled.test.sh index d43e18b..3d1f313 100644 --- a/test/changelog-assembled.test.sh +++ b/test/changelog-assembled.test.sh @@ -58,8 +58,8 @@ Preamble prose belongs to no section. - The shipped entry. EOF printf '0.1.1-dev\n' >"$dir/VERSION" - printf -- '- Twelve landed.\n' >"$dir/changelog.d/12.md" - printf -- '- Nine landed, and its prose wraps onto a\n continuation line.\n' >"$dir/changelog.d/9.md" + printf -- '- Twelve landed (#12).\n' >"$dir/changelog.d/12.md" + printf -- '- Nine landed, and its prose wraps onto a\n continuation line (#9).\n' >"$dir/changelog.d/9.md" commit_base "$name" } @@ -86,8 +86,8 @@ check "faithful flat ceremony: the section is byte-for-byte the assembly" 0 \ seed_flat faithful-grouped sed -i '/^- The shipped entry/i ### Fixed\\\n' "$TMP/faithful-grouped/CHANGELOG.md" -printf -- '### Fixed\n\n- Fixed twenty-one.\n' >"$TMP/faithful-grouped/changelog.d/21.md" -printf -- '### Added\n\n- Added twenty.\n\n### Docs\n\n- Docs twenty.\n' >"$TMP/faithful-grouped/changelog.d/20.md" +printf -- '### Fixed\n\n- Fixed twenty-one (#21).\n' >"$TMP/faithful-grouped/changelog.d/21.md" +printf -- '### Added\n\n- Added twenty (#20).\n\n### Docs\n\n- Docs twenty (#20).\n' >"$TMP/faithful-grouped/changelog.d/20.md" rm "$TMP/faithful-grouped/changelog.d/12.md" "$TMP/faithful-grouped/changelog.d/9.md" git -C "$TMP/faithful-grouped" add -A git -C "$TMP/faithful-grouped" commit -qm regroup @@ -108,7 +108,7 @@ check "the stamp's date never enters the comparison" 0 "byte-for-byte" \ # --- inapplicable trees: green NOTICE, never a silent skip ------------------- seed_flat ordinary-add -printf -- '- Thirteen incoming.\n' >"$TMP/ordinary-add/changelog.d/13.md" +printf -- '- Thirteen incoming (#13).\n' >"$TMP/ordinary-add/changelog.d/13.md" commit_head ordinary-add check "-dev PR adding a fragment: green NOTICE" 0 "NOTICE" run ordinary-add base @@ -195,8 +195,8 @@ Preamble prose belongs to no section. ## 0.2.0 — 2026-07-24 - Nine landed, and its prose wraps onto a - continuation line. -- Twelve landed. + continuation line (#9). +- Twelve landed (#12). ## 0.1.0 — 2026-07-01 @@ -210,7 +210,7 @@ check "re-ordered entries fail" 1 "NOT what the fragments" run reordered base # directory is not — only the survivor refusal fires. seed_flat survivor ceremony survivor 0.2.0 2026-07-24 -printf -- '- Nine landed, and its prose wraps onto a\n continuation line.\n' >"$TMP/survivor/changelog.d/9.md" +printf -- '- Nine landed, and its prose wraps onto a\n continuation line (#9).\n' >"$TMP/survivor/changelog.d/9.md" commit_head survivor check "a surviving fragment with its entry present fails" 1 "STILL PRESENT" \ run survivor base @@ -316,9 +316,9 @@ check "merge base IS HEAD: vacuous, named honestly" 0 "vacuous" run vacuous HEAD # the env vars are honored the way the composite sets them. init_repo env-tree mkdir -p "$TMP/env-tree/frags" -printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Shipped.\n' >"$TMP/env-tree/NOTES.md" +printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Shipped (#1).\n' >"$TMP/env-tree/NOTES.md" printf '0.1.1-dev\n' >"$TMP/env-tree/VERSION" -printf -- '- Flagged entry.\n' >"$TMP/env-tree/frags/2.md" +printf -- '- Flagged entry (#2).\n' >"$TMP/env-tree/frags/2.md" git -C "$TMP/env-tree" add -A git -C "$TMP/env-tree" commit -qm base git -C "$TMP/env-tree" branch fixture-base diff --git a/test/changelog.test.sh b/test/changelog.test.sh index 0c9979d..17d8ac0 100755 --- a/test/changelog.test.sh +++ b/test/changelog.test.sh @@ -87,7 +87,7 @@ cat >"$PROBLEM_FIXTURE" <<'EOF' ### Fixed -- Fixed entry. +- Fixed entry (#22). ## 1.3.0 @@ -101,7 +101,7 @@ cat >"$PROBLEM_FIXTURE" <<'EOF' ### Fixed -- Fixed entry. +- Fixed entry (#22). ## 1.4.0 @@ -115,7 +115,7 @@ cat >"$PROBLEM_FIXTURE" <<'EOF' ### Fixed -- Fixed entry. +- Fixed entry (#22). EOF assert_problem Unreleased 0 "" @@ -182,11 +182,11 @@ printf 'marker\n' >"$FRAG/README.md" check "fragments: README.md is the directory marker, never a fragment" 0 "" \ changelog_fragments "$FRAG" -printf -- '- Two.\n' >"$FRAG/2.md" -printf -- '- Nine.\n' >"$FRAG/9.md" -printf -- '- Ten.\n' >"$FRAG/10.md" -printf -- '- Cross.\n' >"$FRAG/ceremony-14.md" -printf -- '- Local fourteen.\n' >"$FRAG/14.md" +printf -- '- Two (#2).\n' >"$FRAG/2.md" +printf -- '- Nine (#9).\n' >"$FRAG/9.md" +printf -- '- Ten (#10).\n' >"$FRAG/10.md" +printf -- '- Cross (#14).\n' >"$FRAG/ceremony-14.md" +printf -- '- Local fourteen (#14).\n' >"$FRAG/14.md" assert_fragments_order() { local expected="$1" actual @@ -205,39 +205,39 @@ check "fragments: issue number descending (numeric, 10 before 9), filename tie-b PF="$TMP/frag-problems" mkdir -p "$PF" -printf -- '- Fine.\n' >"$PF/7.md" +printf -- '- Fine (#7).\n' >"$PF/7.md" check "fragment predicate: a flat fragment passes" 0 "" \ changelog_fragment_problem "$PF/7.md" cat >"$PF/8.md" <<'EOF' ### Added -- Grouped fine. +- Grouped fine (#8). EOF check "fragment predicate: a grouped fragment passes" 0 "" \ changelog_fragment_problem "$PF/8.md" -printf -- '- Cross-repo.\n' >"$PF/ceremony-14.md" +printf -- '- Cross-repo (#14).\n' >"$PF/ceremony-14.md" check "fragment predicate: a cross-repo name passes" 0 "" \ changelog_fragment_problem "$PF/ceremony-14.md" -printf -- '- Bad name.\n' >"$PF/Fix-12.md" +printf -- '- Bad name (#12).\n' >"$PF/Fix-12.md" check "fragment predicate: an uppercase prefix is refused, file named" 1 "Fix-12.md" \ changelog_fragment_problem "$PF/Fix-12.md" -printf -- '- Bad name.\n' >"$PF/notes.txt" +printf -- '- Bad name (#12).\n' >"$PF/notes.txt" check "fragment predicate: a non-.md file is refused, file named" 1 "notes.txt" \ changelog_fragment_problem "$PF/notes.txt" -printf -- '- Bad name.\n' >"$PF/12.markdown" +printf -- '- Bad name (#12).\n' >"$PF/12.markdown" check "fragment predicate: .markdown is refused, file named" 1 "12.markdown" \ changelog_fragment_problem "$PF/12.markdown" -printf -- '- No number.\n' >"$PF/notes.md" +printf -- '- No number (#1).\n' >"$PF/notes.md" check "fragment predicate: a name with no trailing issue number is refused" 1 "notes.md" \ changelog_fragment_problem "$PF/notes.md" cat >"$PF/20.md" <<'EOF' ## 1.0.0 — 2026-07-24 -- Smuggled heading. +- Smuggled heading (#20). EOF check "fragment predicate: a '## ' line is refused — the heading is the assembler's" 1 \ "the section heading is the assembler's to write" \ @@ -253,7 +253,7 @@ cat >"$PF/22.md" <<'EOF' ### Fixed -- Fixed entry. +- Fixed entry (#22). EOF check "fragment predicate: a dangling grouped heading is refused, heading named" 1 \ "has an empty heading: '### Added'" \ @@ -347,11 +347,11 @@ mkdir -p "$AF" printf 'marker\n' >"$AF/README.md" cat >"$AF/3.md" <<'EOF' - Three — an em dash, and prose that - wraps onto a continuation line. + wraps onto a continuation line (#3). EOF -printf -- '- Ten.\n- Ten again.\n' >"$AF/10.md" +printf -- '- Ten (#10).\n- Ten again (#10).\n' >"$AF/10.md" check "assemble: flat fragments, newest issue first, prose verbatim" 0 "" \ - assert_assemble "$AF" $'- Ten.\n- Ten again.\n- Three — an em dash, and prose that\n wraps onto a continuation line.' + assert_assemble "$AF" $'- Ten (#10).\n- Ten again (#10).\n- Three — an em dash, and prose that\n wraps onto a continuation line (#3).' check "assemble: an empty directory is empty output — refusing is the caller's stance" 0 "" \ changelog_assemble "$TMP/no-such-dir" @@ -361,36 +361,36 @@ mkdir -p "$AG" cat >"$AG/21.md" <<'EOF' ### Fixed -- Fixed twenty-one. +- Fixed twenty-one (#21). EOF cat >"$AG/20.md" <<'EOF' ### Added -- Added twenty. +- Added twenty (#20). ### Docs -- Docs twenty. +- Docs twenty (#20). EOF cat >"$AG/19.md" <<'EOF' ### Security -- Security nineteen. +- Security nineteen (#19). ### Added -- Added nineteen. +- Added nineteen (#19). EOF check "assemble: canonical group order, unnamed group appended, fragment order inside a group" 0 "" \ - assert_assemble "$AG" $'### Added\n\n- Added twenty.\n- Added nineteen.\n\n### Fixed\n\n- Fixed twenty-one.\n\n### Security\n\n- Security nineteen.\n\n### Docs\n\n- Docs twenty.' + assert_assemble "$AG" $'### Added\n\n- Added twenty (#20).\n- Added nineteen (#19).\n\n### Fixed\n\n- Fixed twenty-one (#21).\n\n### Security\n\n- Security nineteen (#19).\n\n### Docs\n\n- Docs twenty (#20).' AM="$TMP/assemble-mixed" mkdir -p "$AM" -printf -- '- Flat five.\n' >"$AM/5.md" +printf -- '- Flat five (#5).\n' >"$AM/5.md" cat >"$AM/6.md" <<'EOF' ### Added -- Grouped six. +- Grouped six (#6). EOF check "assemble: mixed shapes refused, grouped side named" 1 "6.md" \ changelog_assemble "$AM" @@ -400,11 +400,11 @@ check "assemble: mixed shapes refused, flat side named too" 1 "5.md" \ AX="$TMP/assemble-selfmixed" mkdir -p "$AX" cat >"$AX/7.md" <<'EOF' -- Ungrouped lead. +- Ungrouped lead (#7). ### Added -- Grouped follow. +- Grouped follow (#7). EOF check "assemble: one fragment mixing both shapes is refused, file named" 1 \ "'$AX/7.md' mixes grouped headings and ungrouped bullets" \ @@ -429,14 +429,14 @@ cat >"$SHAPE_CHANGELOG" <<'EOF' - Older section is grouped. EOF -printf -- '- Flat fragment.\n' >"$SHAPE_DIR/1.md" +printf -- '- Flat fragment (#1).\n' >"$SHAPE_DIR/1.md" check "shape: flat set matches newest flat published section" 0 "" \ changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" cat >"$SHAPE_DIR/1.md" <<'EOF' ### Fixed -- Grouped fragment. +- Grouped fragment (#1). EOF check "shape: grouped set names its conflict with newest flat published section" 1 \ "fragment '$SHAPE_DIR/1.md' is grouped but newest published section '2.0.0' in '$SHAPE_CHANGELOG' is flat" \ @@ -451,7 +451,7 @@ cat >"$SHAPE_CHANGELOG" <<'EOF' - Newest section is grouped. EOF -printf -- '- Flat fragment.\n' >"$SHAPE_DIR/1.md" +printf -- '- Flat fragment (#1).\n' >"$SHAPE_DIR/1.md" check "shape: flat set names its conflict with newest grouped published section" 1 \ "fragment '$SHAPE_DIR/1.md' is flat but newest published section '2.0.0' in '$SHAPE_CHANGELOG' is grouped" \ changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" @@ -459,7 +459,7 @@ check "shape: flat set names its conflict with newest grouped published section" cat >"$SHAPE_DIR/1.md" <<'EOF' ### Fixed -- Grouped fragment. +- Grouped fragment (#1). EOF check "shape: grouped set matches newest grouped published section" 0 "" \ changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" @@ -484,7 +484,7 @@ EOF cat >"$SHAPE_DIR/1.md" <<'EOF' ### Fixed -- Grouped fragment. +- Grouped fragment (#1). EOF printf 'grouped\n' >"$SHAPE_DIR/shape" check "shape: 'grouped' sentinel admits a grouped set over a flat published section" 0 "" \ @@ -492,7 +492,7 @@ check "shape: 'grouped' sentinel admits a grouped set over a flat published sect check "shape: the sentinel binds with no changelog at all — the assembler's call" 0 "" \ changelog_shape_problem "" "$SHAPE_DIR" -printf -- '- Flat fragment.\n' >"$SHAPE_DIR/1.md" +printf -- '- Flat fragment (#1).\n' >"$SHAPE_DIR/1.md" check "shape: flat fragment under a 'grouped' sentinel refused, fragment and sentinel named" 1 \ "fragment '$SHAPE_DIR/1.md' is flat but '$SHAPE_DIR/shape' declares grouped" \ changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" @@ -512,14 +512,14 @@ check "shape: 'flat' sentinel admits a flat set over a grouped published section cat >"$SHAPE_DIR/1.md" <<'EOF' ### Fixed -- Grouped fragment. +- Grouped fragment (#1). EOF check "shape: grouped fragment under a 'flat' sentinel refused, fragment and sentinel named" 1 \ "fragment '$SHAPE_DIR/1.md' is grouped but '$SHAPE_DIR/shape' declares flat" \ changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" printf 'grouped\n' >"$SHAPE_DIR/shape" -printf -- '- Flat two.\n' >"$SHAPE_DIR/2.md" +printf -- '- Flat two (#2).\n' >"$SHAPE_DIR/2.md" check "shape: a mixed set is refused regardless of the sentinel" 1 \ "fragment '$SHAPE_DIR/1.md' is grouped but fragment '$SHAPE_DIR/2.md' is not" \ changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" @@ -558,7 +558,7 @@ printf 'grouped\n' >"$SHAPE_DIR/shape" cat >"$SHAPE_DIR/1.md" <<'EOF' ### Fixed -- Grouped fragment. +- Grouped fragment (#1). EOF assert_fragments_exclude_sentinel() { local out @@ -579,17 +579,17 @@ printf 'grouped\n' >"$AS/shape" cat >"$AS/30.md" <<'EOF' ### Fixed -- Fixed thirty. +- Fixed thirty (#30). EOF cat >"$AS/31.md" <<'EOF' ### Added -- Added thirty-one. +- Added thirty-one (#31). EOF check "assemble: the sentinel never assembles, and canonical order holds under it" 0 "" \ - assert_assemble "$AS" $'### Added\n\n- Added thirty-one.\n\n### Fixed\n\n- Fixed thirty.' + assert_assemble "$AS" $'### Added\n\n- Added thirty-one (#31).\n\n### Fixed\n\n- Fixed thirty (#30).' rm "$AS/30.md" "$AS/31.md" -printf -- '- Flat probe.\n' >"$AS/29.md" +printf -- '- Flat probe (#29).\n' >"$AS/29.md" check "assemble: a flat set under a 'grouped' sentinel refuses to assemble" 1 \ "declares grouped" \ changelog_assemble "$AS" From ef818001d0c51739d1cdaf2ebcb78b1ade0a6b71 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:08:54 +0000 Subject: [PATCH 04/10] test(changelog): every fragment fixture carries a terminal cite Scoped to the fixtures the new rule actually binds: a fragment whose predicate complaint is already its name, a smuggled heading, a dangling heading or the 300-character bound is left alone, so the diagnosis it tests is still the one it draws. The section-predicate fixtures are untouched (D4). The computed-length fixtures keep their measured lengths: the cite is seven characters, so an entry that must measure exactly 300 builds 293 of the run and lets the cite carry the rest. 33.md's cite lands on the last continuation line, which is the wrapped-citation case. Refs #262 --- test/changelog-armed.test.sh | 6 +++--- test/changelog-assemble.test.sh | 10 +++++----- test/changelog-assembled.test.sh | 2 +- test/changelog.test.sh | 30 +++++++++++++++--------------- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/test/changelog-armed.test.sh b/test/changelog-armed.test.sh index 0703a52..120b9ed 100644 --- a/test/changelog-armed.test.sh +++ b/test/changelog-armed.test.sh @@ -452,7 +452,7 @@ fragment_tree fragments-bad-name 1.2.4-dev <<'EOF' - The shipped entry. EOF -printf '%s\n' "- An entry (#1)." >"$TMP/fragments-bad-name/changelog.d/notes.md" +printf '%s\n' "- An entry." >"$TMP/fragments-bad-name/changelog.d/notes.md" check "fragment mode quotes malformed-fragment diagnosis and file" 1 \ "fragment 'changelog.d/notes.md' is not named for its issue" \ in_tree fragments-bad-name @@ -484,7 +484,7 @@ check "fragment bare + stamped section + consumed directory passes" 0 \ "fragment mode" in_tree fragments-bare-stamped cp -R "$TMP/fragments-bare-stamped" "$TMP/fragments-bare-survivor" -printf '%s\n' "- This entry was not consumed." \ +printf '%s\n' "- This entry was not consumed (#115)." \ >"$TMP/fragments-bare-survivor/changelog.d/115.md" check "fragment bare refuses and lists surviving fragments" 1 \ "these fragments were not consumed: changelog.d/115.md" \ @@ -531,7 +531,7 @@ check "same changelog fails in legacy mode" 1 "development tree" \ mkdir -p "$TMP/env-tree" printf '1.2.4-dev\n' >"$TMP/env-tree/VERSION" -printf '# Changelog\n\n## Unreleased\n\n- Pending (#1).\n' >"$TMP/env-tree/NOTES.md" +printf '# Changelog\n\n## Unreleased\n\n- Pending.\n' >"$TMP/env-tree/NOTES.md" # A non-default changelog name proves the env var is honored, not the default. env_tree() { (cd "$TMP/env-tree" && CHANGELOG=NOTES.md VERSION_SOURCE=file bash "$SCRIPT") diff --git a/test/changelog-assemble.test.sh b/test/changelog-assemble.test.sh index bf0e95a..178744b 100644 --- a/test/changelog-assemble.test.sh +++ b/test/changelog-assemble.test.sh @@ -213,7 +213,7 @@ frag check-readonly 5.md <<'EOF' - Five (#5). EOF cp -R "$TMP/check-readonly" "$TMP/check-readonly.before" -check "--check prints the assembled body" 0 "Five." \ +check "--check prints the assembled body" 0 "Five (#5)." \ in_tree check-readonly 0.2.0 2026-07-24 --check check "--check is read-only: the tree is byte-identical before and after" 0 "" \ diff -r "$TMP/check-readonly.before" "$TMP/check-readonly" @@ -228,7 +228,7 @@ check "the defaulted stamp is a UTC date" 0 "" \ # --- --changelog and --dir override the defaults ----------------------------- mkdir -p "$TMP/flagged/frags" -printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Shipped (#1).\n' >"$TMP/flagged/NOTES.md" +printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Shipped.\n' >"$TMP/flagged/NOTES.md" printf -- '- Flagged entry (#2).\n' >"$TMP/flagged/frags/2.md" check "--changelog and --dir override the defaults" 0 "" \ "$TOOL" 0.2.0 2026-07-24 --changelog "$TMP/flagged/NOTES.md" --dir "$TMP/flagged/frags" @@ -270,7 +270,7 @@ frag dangling 4.md <<'EOF' ### Fixed -- Fixed entry (#4). +- Fixed entry. EOF check "a dangling grouped heading refuses, file and heading named" 1 \ "fragment 'changelog.d/4.md' has an empty heading: '### Added'" \ @@ -282,7 +282,7 @@ EOF frag smuggled 6.md <<'EOF' ## 0.2.0 — 2026-07-24 -- An entry under a smuggled heading (#6). +- An entry under a smuggled heading. EOF check "a fragment carrying a '## ' line refuses, file named" 1 \ "fragment 'changelog.d/6.md' carries a '## ' heading" \ @@ -313,7 +313,7 @@ tree stray-case <"$TMP/env-tree/NOTES.md" +printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Shipped.\n' >"$TMP/env-tree/NOTES.md" printf '0.1.1-dev\n' >"$TMP/env-tree/VERSION" printf -- '- Flagged entry (#2).\n' >"$TMP/env-tree/frags/2.md" git -C "$TMP/env-tree" add -A diff --git a/test/changelog.test.sh b/test/changelog.test.sh index 17d8ac0..1aed0b1 100755 --- a/test/changelog.test.sh +++ b/test/changelog.test.sh @@ -87,7 +87,7 @@ cat >"$PROBLEM_FIXTURE" <<'EOF' ### Fixed -- Fixed entry (#22). +- Fixed entry. ## 1.3.0 @@ -101,7 +101,7 @@ cat >"$PROBLEM_FIXTURE" <<'EOF' ### Fixed -- Fixed entry (#22). +- Fixed entry. ## 1.4.0 @@ -115,7 +115,7 @@ cat >"$PROBLEM_FIXTURE" <<'EOF' ### Fixed -- Fixed entry (#22). +- Fixed entry. EOF assert_problem Unreleased 0 "" @@ -221,23 +221,23 @@ printf -- '- Cross-repo (#14).\n' >"$PF/ceremony-14.md" check "fragment predicate: a cross-repo name passes" 0 "" \ changelog_fragment_problem "$PF/ceremony-14.md" -printf -- '- Bad name (#12).\n' >"$PF/Fix-12.md" +printf -- '- Bad name.\n' >"$PF/Fix-12.md" check "fragment predicate: an uppercase prefix is refused, file named" 1 "Fix-12.md" \ changelog_fragment_problem "$PF/Fix-12.md" -printf -- '- Bad name (#12).\n' >"$PF/notes.txt" +printf -- '- Bad name.\n' >"$PF/notes.txt" check "fragment predicate: a non-.md file is refused, file named" 1 "notes.txt" \ changelog_fragment_problem "$PF/notes.txt" -printf -- '- Bad name (#12).\n' >"$PF/12.markdown" +printf -- '- Bad name.\n' >"$PF/12.markdown" check "fragment predicate: .markdown is refused, file named" 1 "12.markdown" \ changelog_fragment_problem "$PF/12.markdown" -printf -- '- No number (#1).\n' >"$PF/notes.md" +printf -- '- No number.\n' >"$PF/notes.md" check "fragment predicate: a name with no trailing issue number is refused" 1 "notes.md" \ changelog_fragment_problem "$PF/notes.md" cat >"$PF/20.md" <<'EOF' ## 1.0.0 — 2026-07-24 -- Smuggled heading (#20). +- Smuggled heading. EOF check "fragment predicate: a '## ' line is refused — the heading is the assembler's" 1 \ "the section heading is the assembler's to write" \ @@ -253,7 +253,7 @@ cat >"$PF/22.md" <<'EOF' ### Fixed -- Fixed entry (#22). +- Fixed entry. EOF check "fragment predicate: a dangling grouped heading is refused, heading named" 1 \ "has an empty heading: '### Added'" \ @@ -274,14 +274,14 @@ 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" +printf -- '- %s (#31).\n' "$(mkchars 293)" >"$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)" + printf -- '- %s (#32).\n' "$(mkchars 143)" + printf -- '- %s (#32).\n' "$(mkchars 143)" + printf -- '- %s (#32).\n' "$(mkchars 143)" } >"$PF/32.md" check "length bound: several within-bound entries pass though the file totals over 300" 0 "" \ changelog_fragment_problem "$PF/32.md" @@ -291,14 +291,14 @@ check "length bound: several within-bound entries pass though the file totals ov printf ' %s\n' "$(mkchars 50)" printf ' %s\n' "$(mkchars 50)" printf ' %s\n' "$(mkchars 50)" - printf ' %s\n' "$(mkchars 50)" + printf ' %s (#33).\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)" + printf -- '- %s (#34).\n' "$(mkchars 293)" } >"$PF/34.md" check "length bound: a '### ' heading counts toward no entry — 300 under it still passes" 0 "" \ changelog_fragment_problem "$PF/34.md" From f4cb970097e1f1a7d9c3f407875b6916bc1aff9d Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:11:09 +0000 Subject: [PATCH 05/10] test(changelog): the cite rule's own cases, both callers asserted Refs #262 --- test/changelog-armed.test.sh | 32 ++++++++++ test/changelog-assemble.test.sh | 32 ++++++++++ test/changelog.test.sh | 105 ++++++++++++++++++++++++++++++++ 3 files changed, 169 insertions(+) diff --git a/test/changelog-armed.test.sh b/test/changelog-armed.test.sh index 120b9ed..f9bb57f 100644 --- a/test/changelog-armed.test.sh +++ b/test/changelog-armed.test.sh @@ -298,6 +298,38 @@ 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 +# The terminal cite (#262) reds the PR that writes the fragment, through the +# same shared predicate — which is the whole point of the rule living there +# rather than in prose a reviewer has to remember. +fragment_tree fragments-dev-uncited 1.2.4-dev <<'EOF' +# Changelog + +## 1.2.3 — 2026-07-20 + +- The shipped entry. +EOF +printf '%s\n' "- An entry that never learned to cite its issue." \ + >"$TMP/fragments-dev-uncited/changelog.d/115.md" +check "fragment mode refuses an uncited entry, fragment named" 1 \ + "115.md' has an entry with no issue citation" \ + in_tree fragments-dev-uncited +check "fragment mode uncited refusal names the shape to write" 1 \ + "end it with the issue it comes from: '(#N).'" \ + in_tree fragments-dev-uncited + +fragment_tree fragments-dev-misplaced-cite 1.2.4-dev <<'EOF' +# Changelog + +## 1.2.3 — 2026-07-20 + +- The shipped entry. +EOF +printf '%s\n' "- The citation trails the period. (#115)" \ + >"$TMP/fragments-dev-misplaced-cite/changelog.d/115.md" +check "fragment mode refuses a non-terminal citation, fragment named" 1 \ + "115.md' has an entry whose issue citation is not terminal" \ + in_tree fragments-dev-misplaced-cite + fragment_tree fragments-dev-grouped 1.2.4-dev <<'EOF' # Changelog diff --git a/test/changelog-assemble.test.sh b/test/changelog-assemble.test.sh index 178744b..f87a00f 100644 --- a/test/changelog-assemble.test.sh +++ b/test/changelog-assemble.test.sh @@ -204,6 +204,38 @@ check "preamble-only write is exact" 0 "" \ assert_file "$TMP/preamble-only/CHANGELOG.md" \ $'# Changelog\n\nOnly preamble so far.\n\n## 0.1.0 — 2026-07-24\n\n- The first entry ever (#1).' +# --- the fragment predicate at release time (#262) --------------------------- + +# The cite rule joins changelog_fragment_problem, so it binds both callers: +# the arming guard at PR time and this assembler at release time. Asserted +# rather than assumed — a release that publishes an uncited entry is the +# failure the PR-time guard exists to have already caught. + +tree uncited-release < — a fragment holding exactly the given +# lines, so a case reads as the entry it is about. +cite_case() { + local num="$1" + shift + printf '%s\n' "$@" >"$PF/$num.md" +} + +cite_case 40 '- Local (#262).' +check "cite: the canonical '(#N).' passes" 0 "" \ + changelog_fragment_problem "$PF/40.md" +cite_case 41 '- Sibling repo (crew#309).' +check "cite: a sibling-repo reference passes" 0 "" \ + changelog_fragment_problem "$PF/41.md" +cite_case 42 '- Fully qualified (heavy-duty/crew#309).' +check "cite: an owner/repo reference passes" 0 "" \ + changelog_fragment_problem "$PF/42.md" +cite_case 43 '- Two issues, one entry (#236, #250).' +check "cite: one group carrying two references passes" 0 "" \ + changelog_fragment_problem "$PF/43.md" + +# The cite is measured on the normalized entry, so a citation that lands on +# a continuation line still closes the entry — the #167 lesson, repeated: +# wrapping alone must never red a compliant entry. +cite_case 44 '- An entry whose prose wraps onto a' ' continuation line, cite and all (#262).' +check "cite: a citation on a continuation line passes — the entry is normalized first" 0 "" \ + changelog_fragment_problem "$PF/44.md" + +cite_case 45 '### Added' '' '- Added one (#101).' '- Added two (#102).' '' \ + '### Changed' '' '- Changed one (#103).' '' '### Fixed' '' '- Fixed one (#104).' +check "cite: a grouped fragment, three headings, every entry compliant, passes" 0 "" \ + changelog_fragment_problem "$PF/45.md" + +# The two diagnoses are distinct by construction (D5): a builder who reads +# one must not be told the other's fix. +cite_case 50 '- No cite here.' +check "cite: an entry with no reference at all is refused" 1 \ + "50.md' has an entry with no issue citation" \ + changelog_fragment_problem "$PF/50.md" +check "cite: the uncited refusal names the shape to write" 1 \ + "end it with the issue it comes from: '(#N).'" \ + changelog_fragment_problem "$PF/50.md" + +cite_case 51 '- Cite before the period. (#262)' +check "cite: a citation trailing the period is refused — the 248.md shape" 1 \ + "51.md' has an entry whose issue citation is not terminal" \ + changelog_fragment_problem "$PF/51.md" +check "cite: the misplaced refusal names the shape to write" 1 \ + "exactly one '(#N)' group ends the entry, the final '.' after it" \ + changelog_fragment_problem "$PF/51.md" + +cite_case 52 '- Trailing prose (#262) and then more.' +check "cite: a citation with prose after it is refused" 1 \ + "has an entry whose issue citation is not terminal" \ + changelog_fragment_problem "$PF/52.md" + +cite_case 53 '- Two groups (#236) and (#250).' +check "cite: two citation groups are refused — one terminal group, or none (D2)" 1 \ + "has an entry whose issue citation is not terminal" \ + changelog_fragment_problem "$PF/53.md" + +cite_case 54 '- Bad token (#abc).' +check "cite: a reference with no digits is no reference" 1 \ + "has an entry with no issue citation" \ + changelog_fragment_problem "$PF/54.md" +cite_case 55 '- Bad token (#).' +check "cite: an empty reference is no reference" 1 \ + "has an entry with no issue citation" \ + changelog_fragment_problem "$PF/55.md" + +# The citation need not name the file's own issue (D3): the filename already +# carries the authorizing one, so an entry may cite the incident beside it. +cite_case 56 '- Cites another issue entirely (#101).' +check "cite: the reference need not match the filename" 0 "" \ + changelog_fragment_problem "$PF/56.md" + +# Ordering: the bound outranks the cite, so a fragment that reds today draws +# the diagnosis it drew before this rule existed. +{ + printf -- '- %s\n' "$(mkchars 301)" + printf -- '- Uncited too.\n' +} >"$PF/57.md" +check "cite: an over-bound entry still reports the bound, not the cite" 1 \ + "57.md' has a 301-character entry" \ + changelog_fragment_problem "$PF/57.md" + +# Published sections keep their pre-rule prose (D4): reddening history is a +# wall, not a guard. Every shipped section predates the cite. +check "cite: a published section with uncited entries still reds nothing" 0 "" \ + changelog_section_problem "$ROOT/CHANGELOG.md" 0.3.0 + +# The fragments this repo carries right now are the rule's own first +# constituency — the guard is worth nothing if the tree it ships in fails it. +assert_tree_fragments() { + local f + while IFS= read -r f; do + [ -n "$f" ] || continue + changelog_fragment_problem "$f" || return 1 + done <<<"$(changelog_fragments "$ROOT/changelog.d")" +} +check "cite: every fragment in this tree passes the rule it ships" 0 "" \ + assert_tree_fragments + # --- the assembler (#114) ---------------------------------------------------- assert_assemble() { From 8aeea67d8d522f6c06cd44fd825a9617975494b0 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:12:52 +0000 Subject: [PATCH 06/10] docs(changelog): the citation is guard-enforced, not house style Closes #262 --- BUILDER.md | 8 +++++++- CHANGELOG.md | 3 +++ changelog.d/262.md | 17 +++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 changelog.d/262.md diff --git a/BUILDER.md b/BUILDER.md index fe2d5fb..bc57d26 100644 --- a/BUILDER.md +++ b/BUILDER.md @@ -203,7 +203,13 @@ triage bug, and the move is to say so on the issue, not to guess. 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 + continuation lines is fine and never counts against it. Every entry + **ends with its issue citation**, and the same guard reds an entry + without one: a single `(` group of `#N`, `repo#N` or `owner/repo#N` + references separated by `, `, then `)`, then the final `.` and nothing + after it — `(#262).` locally, `(#236, #250).` when one entry honestly + lands two. The citation need not name the fragment's own issue, because + the filename already carries the authorizing one (#262). 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index dcd452c..491b733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ published verbatim as that release's body (lib/changelog.sh extracts it), 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. +The citation is guard-enforced too, and it closes the entry: one `(#N)` +group, then the final `.` and nothing after it (#262). Sections published +before that rule keep their prose; the guard reads fragments only. 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). diff --git a/changelog.d/262.md b/changelog.d/262.md new file mode 100644 index 0000000..aaf0a75 --- /dev/null +++ b/changelog.d/262.md @@ -0,0 +1,17 @@ +### Added + +- The fragment guard now requires each entry to end with its issue + citation: one `(#N)` group — local, `repo#N` or `owner/repo#N` + references separated by `, ` — then the final `.` and nothing after it + (#262). +- The refusal distinguishes an entry carrying no reference at all from one + whose reference is present but not terminal, and names the shape to + write in both (#262). + +### Changed + +- `BUILDER.md` and `CHANGELOG.md` state the citation as guard-enforced + rather than as house style, beside the 300-character bound it now sits + next to (#262). +- Four fragments in flight gained a terminal citation; published sections + are untouched, so no shipped prose is re-opened (#262). From 175e082c1bb44db1db7cb3e52032e9ce2b0bc54c Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:15:04 +0000 Subject: [PATCH 07/10] test(release-exercise): the replay's fragment fixture carries a cite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The step-replay job builds a consumer tree and runs the REAL assembler over it, so its changelog.d/42.md is a fragment fixture like every one in test/ — and the only one living outside it. #262's diff-surface criterion says no workflow file; the criterion and a green head cannot both hold here, and the fixture is the smaller thing to move. Refs #262 --- .github/workflows/release-exercise.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-exercise.yml b/.github/workflows/release-exercise.yml index 90201c4..51d01cb 100644 --- a/.github/workflows/release-exercise.yml +++ b/.github/workflows/release-exercise.yml @@ -119,7 +119,7 @@ jobs: EOF mkdir changelog.d printf '# changelog.d/ — assembled at release (heavy-duty/ceremony#112); the marker keeps the directory tracked.\n' > changelog.d/README.md - printf -- '- The entry this release ships.\n' > changelog.d/42.md + printf -- '- The entry this release ships (#42).\n' > changelog.d/42.md git add VERSION CHANGELOG.md changelog.d git commit -qm "base" printf '0.7.0\n' > VERSION From 84c73fe22ea1b75a88f42085719f6793d4389db8 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:16:49 +0000 Subject: [PATCH 08/10] test(changelog): the precedence case runs in the order that can fail Refs #262 --- test/changelog.test.sh | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/test/changelog.test.sh b/test/changelog.test.sh index f1848df..54f5fe9 100755 --- a/test/changelog.test.sh +++ b/test/changelog.test.sh @@ -409,15 +409,27 @@ cite_case 56 '- Cites another issue entirely (#101).' check "cite: the reference need not match the filename" 0 "" \ changelog_fragment_problem "$PF/56.md" -# Ordering: the bound outranks the cite, so a fragment that reds today draws -# the diagnosis it drew before this rule existed. +# Ordering: the bound outranks the cite across the whole fragment, so a +# fragment that reds today draws the diagnosis it drew before this rule +# existed. The uncited entry comes FIRST here on purpose — the other order +# would pass whatever the precedence is. { + printf -- '- Uncited, and it comes first.\n' printf -- '- %s\n' "$(mkchars 301)" - printf -- '- Uncited too.\n' } >"$PF/57.md" -check "cite: an over-bound entry still reports the bound, not the cite" 1 \ +check "cite: an over-bound entry outranks an earlier uncited one" 1 \ "57.md' has a 301-character entry" \ changelog_fragment_problem "$PF/57.md" +assert_one_diagnosis_57() { + local count + count="$(changelog_fragment_problem "$PF/57.md" | wc -l)" + [ "$count" = 1 ] || { + printf 'wanted one diagnosis, got %s\n' "$count" + return 1 + } +} +check "cite: the outranked citation problem is not reported beside it" 0 "" \ + assert_one_diagnosis_57 # Published sections keep their pre-rule prose (D4): reddening history is a # wall, not a guard. Every shipped section predates the cite. From 75a5b68c8a0bbdc9f20c135c752e4227f569933c Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:38:13 +0000 Subject: [PATCH 09/10] fix(changelog): one fragment, one diagnosis, wherever the long entry sits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit awk runs END on the way out of an exit from a main rule, so the length row printed mid-file was followed by the citation row it outranks — the internal protocol line landing inside the human-facing excerpt. Found by claude-bot and kimi-bot in #262's first round, independently and with the same reproduction. The guard is the reported flag the empty-heading walk in this same predicate already uses. The fixtures are the axis 57.md could not reach: its over-bound entry is last, so only END's flush can print. 58.md puts one before another bullet, 59.md before a heading and after a misplaced cite. With lib/changelog.sh alone reverted they red, which is what the green suite was hiding. Refs #262. --- changelog.d/262.md | 3 +++ lib/changelog.sh | 9 ++++++++- test/changelog.test.sh | 34 +++++++++++++++++++++++++++++++--- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/changelog.d/262.md b/changelog.d/262.md index aaf0a75..89e90fa 100644 --- a/changelog.d/262.md +++ b/changelog.d/262.md @@ -7,6 +7,9 @@ - The refusal distinguishes an entry carrying no reference at all from one whose reference is present but not terminal, and names the shape to write in both (#262). +- The 300-character bound still outranks the citation across the whole + fragment, and the outranked problem stays out of the message it lost + to: one fragment, one diagnosis, wherever in the file it sits (#262). ### Changed diff --git a/lib/changelog.sh b/lib/changelog.sh index a054bb9..fb229a1 100644 --- a/lib/changelog.sh +++ b/lib/changelog.sh @@ -208,6 +208,7 @@ changelog_fragment_problem() { sub(/ $/, "", e) len = length(e) if (len > max) { + reported = 1 printf "long\t%d\t%s\n", len, excerpt(e) return 1 } @@ -227,9 +228,15 @@ changelog_fragment_problem() { } /^[[:space:]]*$/ { next } entry != "" { entry = entry " " $0 } + # An exit from a main rule still runs END, so a length row printed + # mid-file would be followed by the citation row it outranks — two + # lines spliced into one diagnosis, the internal protocol row landing + # inside the human-facing excerpt (#262 round 1). The reported flag is + # the same guard the empty-heading walk above uses, for the same + # reason: one diagnosis per fragment is the contract. END { if (flush()) exit - if (cite_kind != "") printf "%s\t\t%s\n", cite_kind, cite_excerpt + if (!reported && cite_kind != "") printf "%s\t\t%s\n", cite_kind, cite_excerpt } ' "$file" )" diff --git a/test/changelog.test.sh b/test/changelog.test.sh index 54f5fe9..47e9ac9 100755 --- a/test/changelog.test.sh +++ b/test/changelog.test.sh @@ -420,16 +420,44 @@ check "cite: the reference need not match the filename" 0 "" \ check "cite: an over-bound entry outranks an earlier uncited one" 1 \ "57.md' has a 301-character entry" \ changelog_fragment_problem "$PF/57.md" -assert_one_diagnosis_57() { +assert_one_diagnosis() { local count - count="$(changelog_fragment_problem "$PF/57.md" | wc -l)" + count="$(changelog_fragment_problem "$PF/$1.md" | wc -l)" [ "$count" = 1 ] || { printf 'wanted one diagnosis, got %s\n' "$count" return 1 } } check "cite: the outranked citation problem is not reported beside it" 0 "" \ - assert_one_diagnosis_57 + assert_one_diagnosis 57 + +# The axis 57.md cannot test: its over-bound entry is LAST, so the only +# flush that can print is END's, which exits immediately. A flush from a +# main rule exits too — but awk runs END on the way out, so the citation +# row a mid-file length row outranks would print after it unless END is +# guarded. Both ways out of the walk, a bullet and a heading. +{ + printf -- '- Uncited, and it comes first.\n' + printf -- '- %s\n' "$(mkchars 301)" + printf -- '- A later entry the walk never reaches (#57).\n' +} >"$PF/58.md" +check "cite: an over-bound entry that is not the last one still reports the bound" 1 \ + "58.md' has a 301-character entry" \ + changelog_fragment_problem "$PF/58.md" +check "cite: and it is still one diagnosis, not the protocol row spliced into it" 0 "" \ + assert_one_diagnosis 58 +{ + printf '### Fixed\n' + printf -- '- Misplaced, and it comes first. (#59)\n' + printf -- '- %s\n' "$(mkchars 301)" + printf '### Changed\n' + printf -- '- The heading is the other way out of the walk (#59).\n' +} >"$PF/59.md" +check "cite: a heading after the over-bound entry is the same one diagnosis" 1 \ + "59.md' has a 301-character entry" \ + changelog_fragment_problem "$PF/59.md" +check "cite: the misplaced row does not ride along with it either" 0 "" \ + assert_one_diagnosis 59 # Published sections keep their pre-rule prose (D4): reddening history is a # wall, not a guard. Every shipped section predates the cite. From 9f1f88de7202466571264cac6ff7a3f28b0cbef1 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:40:55 +0000 Subject: [PATCH 10/10] fix(changelog): #285's fragment carries a terminal cite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit changelog.d/253.md landed on main after this branch's point with the cite trailing the period — the crew#309 shape, the fifth fragment to arrive with it since #262 measured two. The criterion is that changelog.d/ is clean at the head, and the head CI reviews is the merge with main, which is where this one surfaced. Refs #262. --- changelog.d/253.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/253.md b/changelog.d/253.md index 3bb9f9c..25cb78d 100644 --- a/changelog.d/253.md +++ b/changelog.d/253.md @@ -1,3 +1,3 @@ ### Added -- Release epics now announce release initialization when their declared dependency gates clear. (#253) +- Release epics now announce release initialization when their declared dependency gates clear (#253).