Merge pull request #289 from cndgrr/build/262-fragment-cite

feat(changelog): the terminal issue cite joins the fragment guard
This commit is contained in:
Daniel Marin 2026-08-04 13:58:32 +01:00 committed by GitHub
commit 2e11855839
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 415 additions and 106 deletions

View file

@ -119,7 +119,7 @@ jobs:
EOF EOF
mkdir changelog.d 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 '# 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 add VERSION CHANGELOG.md changelog.d
git commit -qm "base" git commit -qm "base"
printf '0.7.0\n' > VERSION printf '0.7.0\n' > VERSION

View file

@ -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 a change genuinely is one. An entry is at most 300 characters — the
fragment guard reds longer (#167) — so a genuinely long change ships fragment guard reds longer (#167) — so a genuinely long change ships
several short entries, never one long one; wrapping an entry over 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 `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.

View file

@ -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 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); characters each, guard-enforced on the PR that writes the fragment (#167);
a genuinely long change ships several short entries, never one long one. 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/<issue>.md` per PR, never 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 an edit to this file — and the release PR assembles them into the next
section here (`bin/changelog-assemble`, #112). section here (`bin/changelog-assemble`, #112).

View file

@ -2,4 +2,4 @@
- Define the doors-unchanged drill record and an executable release-path list, - 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 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).

View file

@ -1,3 +1,3 @@
### Fixed ### 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).

View file

@ -1,3 +1,3 @@
### Added ### 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).

View file

@ -1,3 +1,3 @@
### Added ### 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).

20
changelog.d/262.md Normal file
View file

@ -0,0 +1,20 @@
### 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).
- 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
- `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).

View file

@ -2,4 +2,4 @@
- CONTRIBUTING.md now keeps vendored doctrine self-contained: state the rule, - 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 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).

View file

@ -115,8 +115,22 @@ changelog_fragments() {
# splits the measured history: every healthy entry passes untouched, # splits the measured history: every healthy entry passes untouched,
# the drift cluster does not. mawk's length() counts bytes; prose here # the drift cluster does not. mawk's length() counts bytes; prose here
# is ASCII and the fuzz is acceptable. # 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 '<repo>-<issue>.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() { changelog_fragment_problem() {
local file="$1" base problem local file="$1" base problem kind detail rest
base="${file##*/}" base="${file##*/}"
if ! printf '%s\n' "$base" | grep -qE '^([a-z][a-z0-9-]*-)?[0-9]+\.md$'; then 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 return 1
fi 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="$( problem="$(
awk -v max=300 ' awk -v max=300 '
function flush( len, e) { # cite_problem <entry> — "", "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 if (entry == "") return 0
e = entry e = entry
entry = "" entry = ""
@ -168,9 +208,15 @@ changelog_fragment_problem() {
sub(/ $/, "", e) sub(/ $/, "", e)
len = length(e) len = length(e)
if (len > max) { if (len > max) {
printf "%d\t%s\n", len, substr(e, 1, 60) reported = 1
printf "long\t%d\t%s\n", len, excerpt(e)
return 1 return 1
} }
kind = cite_problem(e)
if (kind != "" && cite_kind == "") {
cite_kind = kind
cite_excerpt = excerpt(e)
}
return 0 return 0
} }
/^### / { if (flush()) exit; next } /^### / { if (flush()) exit; next }
@ -182,12 +228,37 @@ changelog_fragment_problem() {
} }
/^[[:space:]]*$/ { next } /^[[:space:]]*$/ { next }
entry != "" { entry = entry " " $0 } entry != "" { entry = entry " " $0 }
END { flush() } # 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 (!reported && cite_kind != "") printf "%s\t\t%s\n", cite_kind, cite_excerpt
}
' "$file" ' "$file"
)" )"
if [ -n "$problem" ]; then 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" \ kind="${problem%%$'\t'*}"
"$file" "${problem%%$'\t'*}" "${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 return 1
fi fi
} }

View file

@ -275,7 +275,7 @@ fragment_tree fragments-dev-flat 1.2.4-dev <<'EOF'
- The shipped entry. - The shipped entry.
EOF 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" \ check "fragment -dev + well-formed flat fragment passes" 0 "fragment mode" \
in_tree fragments-dev-flat in_tree fragments-dev-flat
@ -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" \ "the bound is 300: split it into multiple '- ' entries in this same fragment" \
in_tree fragments-dev-over-bound 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' fragment_tree fragments-dev-grouped 1.2.4-dev <<'EOF'
# Changelog # Changelog
@ -310,7 +342,7 @@ EOF
cat >"$TMP/fragments-dev-grouped/changelog.d/115.md" <<'EOF' cat >"$TMP/fragments-dev-grouped/changelog.d/115.md" <<'EOF'
### Changed ### Changed
- Added fragment mode. - Added fragment mode (#115).
EOF EOF
check "fragment -dev + well-formed grouped fragment passes" 0 "fragment mode" \ check "fragment -dev + well-formed grouped fragment passes" 0 "fragment mode" \
in_tree fragments-dev-grouped in_tree fragments-dev-grouped
@ -322,11 +354,11 @@ fragment_tree fragments-dev-mixed 1.2.4-dev <<'EOF'
- The shipped entry. - The shipped entry.
EOF 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' cat >"$TMP/fragments-dev-mixed/changelog.d/115.md" <<'EOF'
### Fixed ### Fixed
- Grouped fragment. - Grouped fragment (#115).
EOF EOF
check "fragment mode refuses mixed shapes with the shared assembler diagnosis" 1 \ 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" \ "fragment 'changelog.d/115.md' is grouped but fragment 'changelog.d/114.md' is not" \
@ -342,7 +374,7 @@ EOF
cat >"$TMP/fragments-dev-all-grouped-over-flat/changelog.d/115.md" <<'EOF' cat >"$TMP/fragments-dev-all-grouped-over-flat/changelog.d/115.md" <<'EOF'
### Fixed ### Fixed
- Grouped fragment. - Grouped fragment (#115).
EOF EOF
check "fragment mode refuses an all-grouped set over a flat published section" 1 \ 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'" \ "changelog.d/115.md' is grouped but newest published section '1.2.3'" \
@ -357,7 +389,7 @@ fragment_tree fragments-dev-flat-over-grouped 1.2.4-dev <<'EOF'
- The shipped entry. - The shipped entry.
EOF 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 \ 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'" \ "changelog.d/115.md' is flat but newest published section '1.2.3'" \
in_tree fragments-dev-flat-over-grouped in_tree fragments-dev-flat-over-grouped
@ -376,14 +408,14 @@ printf '%s\n' "grouped" >"$TMP/fragments-dev-flip/changelog.d/shape"
cat >"$TMP/fragments-dev-flip/changelog.d/115.md" <<'EOF' cat >"$TMP/fragments-dev-flip/changelog.d/115.md" <<'EOF'
### Fixed ### Fixed
- Grouped fragment. - Grouped fragment (#115).
EOF EOF
check "fragment mode: 'grouped' sentinel admits the flip tree over a flat published section" 0 \ check "fragment mode: 'grouped' sentinel admits the flip tree over a flat published section" 0 \
"fragment mode" in_tree fragments-dev-flip "fragment mode" in_tree fragments-dev-flip
# Post-flip drift is refused on its own PR: a flat probe fragment atop the # 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. # 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 \ 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" \ "changelog.d/115.md' is grouped but fragment 'changelog.d/116.md' is not" \
in_tree fragments-dev-flip in_tree fragments-dev-flip
@ -393,7 +425,7 @@ rm "$TMP/fragments-dev-flip/changelog.d/116.md"
# holds the shape: an all-flat set under 'grouped' is refused, sentinel # holds the shape: an all-flat set under 'grouped' is refused, sentinel
# named — the published-section inference never gets a say. # named — the published-section inference never gets a say.
rm "$TMP/fragments-dev-flip/changelog.d/115.md" 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 \ 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" \ "changelog.d/116.md' is flat but 'changelog.d/shape' declares grouped" \
in_tree fragments-dev-flip in_tree fragments-dev-flip
@ -417,7 +449,7 @@ EOF
cat >"$TMP/fragments-dev-no-published/changelog.d/115.md" <<'EOF' cat >"$TMP/fragments-dev-no-published/changelog.d/115.md" <<'EOF'
### Fixed ### Fixed
- Grouped fragment. - Grouped fragment (#115).
EOF EOF
check "fragment mode accepts a consistent set with no published section" 0 \ check "fragment mode accepts a consistent set with no published section" 0 \
"fragment mode" in_tree fragments-dev-no-published "fragment mode" in_tree fragments-dev-no-published
@ -484,7 +516,7 @@ check "fragment bare + stamped section + consumed directory passes" 0 \
"fragment mode" in_tree fragments-bare-stamped "fragment mode" in_tree fragments-bare-stamped
cp -R "$TMP/fragments-bare-stamped" "$TMP/fragments-bare-survivor" 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" >"$TMP/fragments-bare-survivor/changelog.d/115.md"
check "fragment bare refuses and lists surviving fragments" 1 \ check "fragment bare refuses and lists surviving fragments" 1 \
"these fragments were not consumed: changelog.d/115.md" \ "these fragments were not consumed: changelog.d/115.md" \

View file

@ -55,13 +55,13 @@ tree flat-one <<EOF
$BASE_CHANGELOG $BASE_CHANGELOG
EOF EOF
frag flat-one 12.md <<'EOF' frag flat-one 12.md <<'EOF'
- Twelve landed. - Twelve landed (#12).
EOF EOF
check "flat: one fragment assembles and stamps" 0 "consumed 1 fragment" \ check "flat: one fragment assembles and stamps" 0 "consumed 1 fragment" \
in_tree flat-one 0.2.0 2026-07-24 in_tree flat-one 0.2.0 2026-07-24
check "flat: preamble and shipped section stay byte-identical around the insert" 0 "" \ check "flat: preamble and shipped section stay byte-identical around the insert" 0 "" \
assert_file "$TMP/flat-one/CHANGELOG.md" \ assert_file "$TMP/flat-one/CHANGELOG.md" \
$'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n- Twelve 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- Twelve landed (#12).\n\n## 0.1.0 — 2026-07-01\n\n- The shipped entry.'
check "flat: the consumed fragment is deleted" 1 "" \ check "flat: the consumed fragment is deleted" 1 "" \
test -e "$TMP/flat-one/changelog.d/12.md" test -e "$TMP/flat-one/changelog.d/12.md"
check "flat: README.md survives consumption" 0 "" \ check "flat: README.md survives consumption" 0 "" \
@ -73,17 +73,17 @@ tree flat-many <<EOF
$BASE_CHANGELOG $BASE_CHANGELOG
EOF EOF
frag flat-many 2.md <<'EOF' frag flat-many 2.md <<'EOF'
- Two. - Two (#2).
EOF EOF
frag flat-many 9.md <<'EOF' frag flat-many 9.md <<'EOF'
- Nine. - Nine (#9).
EOF EOF
frag flat-many 10.md <<'EOF' frag flat-many 10.md <<'EOF'
- Ten. - Ten (#10).
EOF EOF
frag flat-many ceremony-14.md <<'EOF' frag flat-many ceremony-14.md <<'EOF'
- Fourteen crossed over — naïve reflows would mangle this café's - Fourteen crossed over — naïve reflows would mangle this café's
continuation line, so it must survive verbatim. continuation line, so it must survive verbatim (#14).
EOF EOF
assert_check() { assert_check() {
@ -95,7 +95,7 @@ assert_check() {
} }
} }
check "flat: numeric-descending order (10.md before 9.md), cross-repo name beside local" 0 "" \ check "flat: numeric-descending order (10.md before 9.md), cross-repo name beside local" 0 "" \
assert_check flat-many $'- Fourteen crossed over — naïve reflows would mangle this café'"'"$'s\n continuation line, so it must survive verbatim.\n- Ten.\n- Nine.\n- Two.' assert_check flat-many $'- Fourteen crossed over — naïve reflows would mangle this café'"'"$'s\n continuation line, so it must survive verbatim (#14).\n- Ten (#10).\n- Nine (#9).\n- Two (#2).'
# --- grouped write: canonical order, unnamed group appended ------------------ # --- grouped write: canonical order, unnamed group appended ------------------
@ -113,28 +113,28 @@ EOF
frag grouped 21.md <<'EOF' frag grouped 21.md <<'EOF'
### Fixed ### Fixed
- Fixed twenty-one. - Fixed twenty-one (#21).
EOF EOF
frag grouped 20.md <<'EOF' frag grouped 20.md <<'EOF'
### Added ### Added
- Added twenty. - Added twenty (#20).
- Added twenty, second bullet. - Added twenty, second bullet (#20).
### Docs ### Docs
- Docs twenty. - Docs twenty (#20).
EOF EOF
frag grouped 19.md <<'EOF' frag grouped 19.md <<'EOF'
### Security ### Security
- Security nineteen. - Security nineteen (#19).
### Added ### Added
- Added nineteen. - Added nineteen (#19).
EOF EOF
GROUPED_BODY=$'### Added\n\n- Added twenty.\n- Added twenty, second bullet.\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.' GROUPED_BODY=$'### Added\n\n- Added twenty (#20).\n- Added twenty, second bullet (#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).'
check "grouped: --check shows canonical order, multi-bullet group, unnamed group last" 0 "" \ check "grouped: --check shows canonical order, multi-bullet group, unnamed group last" 0 "" \
assert_check grouped "$GROUPED_BODY" assert_check grouped "$GROUPED_BODY"
check "grouped: write mode assembles the same section" 0 "consumed 3 fragment" \ check "grouped: write mode assembles the same section" 0 "consumed 3 fragment" \
@ -156,13 +156,13 @@ printf 'grouped\n' >"$TMP/flip/changelog.d/shape"
frag flip 40.md <<'EOF' frag flip 40.md <<'EOF'
### Added ### Added
- Forty landed. - Forty landed (#40).
EOF EOF
check "sentinel: the flip release assembles grouped over a flat published section" 0 \ 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 "consumed 1 fragment" in_tree flip 0.2.0 2026-07-24
check "sentinel: the written flip section is exact" 0 "" \ check "sentinel: the written flip section is exact" 0 "" \
assert_file "$TMP/flip/CHANGELOG.md" \ 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 "" \ check "sentinel: changelog.d/shape survives consumption" 0 "" \
test -e "$TMP/flip/changelog.d/shape" test -e "$TMP/flip/changelog.d/shape"
@ -171,7 +171,7 @@ $BASE_CHANGELOG
EOF EOF
printf 'grouped\n' >"$TMP/flip-flat-frag/changelog.d/shape" printf 'grouped\n' >"$TMP/flip-flat-frag/changelog.d/shape"
frag flip-flat-frag 41.md <<'EOF' frag flip-flat-frag 41.md <<'EOF'
- Flat forty-one. - Flat forty-one (#41).
EOF EOF
check "sentinel: a flat fragment under 'grouped' refuses, sentinel named" 1 \ 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 "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' frag flip-malformed 42.md <<'EOF'
### Added ### Added
- Forty-two. - Forty-two (#42).
EOF EOF
check "sentinel: a malformed sentinel refuses, file named" 1 \ 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 "changelog.d/shape' declares neither shape" in_tree flip-malformed 0.2.0 2026-07-24
@ -196,13 +196,45 @@ tree preamble-only <<'EOF'
Only preamble so far. Only preamble so far.
EOF EOF
frag preamble-only 1.md <<'EOF' frag preamble-only 1.md <<'EOF'
- The first entry ever. - The first entry ever (#1).
EOF EOF
check "a changelog with no section yet gets the section after the preamble" 0 "" \ check "a changelog with no section yet gets the section after the preamble" 0 "" \
in_tree preamble-only 0.1.0 2026-07-24 in_tree preamble-only 0.1.0 2026-07-24
check "preamble-only write is exact" 0 "" \ check "preamble-only write is exact" 0 "" \
assert_file "$TMP/preamble-only/CHANGELOG.md" \ 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).'
# --- 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 <<EOF
$BASE_CHANGELOG
EOF
frag uncited-release 60.md <<'EOF'
- An entry that never learned to cite its issue.
EOF
check "release time: an uncited fragment refuses the release, fragment named" 1 \
"changelog.d/60.md' has an entry with no issue citation" \
in_tree uncited-release 0.2.0 2026-07-24
check "release time: the uncited refusal survives --check too" 1 \
"has an entry with no issue citation" \
in_tree uncited-release 0.2.0 2026-07-24 --check
check "release time: the refused release wrote nothing" 0 "" \
test -e "$TMP/uncited-release/changelog.d/60.md"
tree misplaced-release <<EOF
$BASE_CHANGELOG
EOF
frag misplaced-release 61.md <<'EOF'
- The citation trails the period. (#61)
EOF
check "release time: a non-terminal citation refuses the release" 1 \
"changelog.d/61.md' has an entry whose issue citation is not terminal" \
in_tree misplaced-release 0.2.0 2026-07-24
# --- --check is provably read-only ------------------------------------------- # --- --check is provably read-only -------------------------------------------
@ -210,10 +242,10 @@ tree check-readonly <<EOF
$BASE_CHANGELOG $BASE_CHANGELOG
EOF EOF
frag check-readonly 5.md <<'EOF' frag check-readonly 5.md <<'EOF'
- Five. - Five (#5).
EOF EOF
cp -R "$TMP/check-readonly" "$TMP/check-readonly.before" 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 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 "" \ check "--check is read-only: the tree is byte-identical before and after" 0 "" \
diff -r "$TMP/check-readonly.before" "$TMP/check-readonly" diff -r "$TMP/check-readonly.before" "$TMP/check-readonly"
@ -229,11 +261,11 @@ check "the defaulted stamp is a UTC date" 0 "" \
mkdir -p "$TMP/flagged/frags" mkdir -p "$TMP/flagged/frags"
printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Shipped.\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.\n' >"$TMP/flagged/frags/2.md" printf -- '- Flagged entry (#2).\n' >"$TMP/flagged/frags/2.md"
check "--changelog and --dir override the defaults" 0 "" \ check "--changelog and --dir override the defaults" 0 "" \
"$TOOL" 0.2.0 2026-07-24 --changelog "$TMP/flagged/NOTES.md" --dir "$TMP/flagged/frags" "$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 "" \ 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 ------------------------------- # --- refusals: each names the file responsible -------------------------------
@ -292,7 +324,7 @@ tree stray-txt <<EOF
$BASE_CHANGELOG $BASE_CHANGELOG
EOF EOF
frag stray-txt 7.md <<'EOF' frag stray-txt 7.md <<'EOF'
- Seven. - Seven (#7).
EOF EOF
frag stray-txt notes.txt <<'EOF' frag stray-txt notes.txt <<'EOF'
A stray scratchpad. A stray scratchpad.
@ -322,12 +354,12 @@ tree mixed <<EOF
$BASE_CHANGELOG $BASE_CHANGELOG
EOF EOF
frag mixed 5.md <<'EOF' frag mixed 5.md <<'EOF'
- Flat five. - Flat five (#5).
EOF EOF
frag mixed 6.md <<'EOF' frag mixed 6.md <<'EOF'
### Added ### Added
- Grouped six. - Grouped six (#6).
EOF EOF
check "grouped + flat mixed refuses, both files named" 1 "6.md" \ check "grouped + flat mixed refuses, both files named" 1 "6.md" \
in_tree mixed 0.2.0 in_tree mixed 0.2.0
@ -340,7 +372,7 @@ EOF
frag grouped-over-flat 6.md <<'EOF' frag grouped-over-flat 6.md <<'EOF'
### Added ### Added
- Grouped six. - Grouped six (#6).
EOF EOF
check "an all-grouped set over a flat published section refuses before assembly" 1 \ check "an all-grouped set over a flat published section refuses before assembly" 1 \
"fragment 'changelog.d/6.md' is grouped but newest published section '0.1.0'" \ "fragment 'changelog.d/6.md' is grouped but newest published section '0.1.0'" \
@ -354,7 +386,7 @@ tree already <<'EOF'
- Already shipped. - Already shipped.
EOF EOF
frag already 4.md <<'EOF' frag already 4.md <<'EOF'
- A late fragment. - A late fragment (#4).
EOF EOF
check "an already-present section refuses — the ceremony was already run" 1 \ check "an already-present section refuses — the ceremony was already run" 1 \
"already has a section for '0.2.0'" \ "already has a section for '0.2.0'" \
@ -370,13 +402,13 @@ tree rc-present <<'EOF'
- The candidate's entry. - The candidate's entry.
EOF EOF
frag rc-present 8.md <<'EOF' frag rc-present 8.md <<'EOF'
- The real release entry. - The real release entry (#8).
EOF EOF
check "an rc section does not block assembling the bare version" 0 "" \ check "an rc section does not block assembling the bare version" 0 "" \
in_tree rc-present 0.2.0 2026-07-24 in_tree rc-present 0.2.0 2026-07-24
mkdir -p "$TMP/no-changelog/changelog.d" mkdir -p "$TMP/no-changelog/changelog.d"
printf -- '- Entry.\n' >"$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" \ check "a missing changelog refuses" 1 "no such file" \
in_tree no-changelog 0.2.0 in_tree no-changelog 0.2.0
@ -404,12 +436,12 @@ frag round-trip 30.md <<'EOF'
### Added ### Added
- Thirty — wraps onto a - Thirty — wraps onto a
continuation line with a naïve café. continuation line with a naïve café (#30).
EOF EOF
frag round-trip 29.md <<'EOF' frag round-trip 29.md <<'EOF'
### Fixed ### Fixed
- Fixed twenty-nine. - Fixed twenty-nine (#29).
EOF EOF
CHECKED="$(in_tree round-trip 0.2.0 2026-07-24 --check)" CHECKED="$(in_tree round-trip 0.2.0 2026-07-24 --check)"
check "round trip: write mode succeeds after --check" 0 "" \ check "round trip: write mode succeeds after --check" 0 "" \

View file

@ -58,8 +58,8 @@ Preamble prose belongs to no section.
- The shipped entry. - The shipped entry.
EOF EOF
printf '0.1.1-dev\n' >"$dir/VERSION" printf '0.1.1-dev\n' >"$dir/VERSION"
printf -- '- Twelve landed.\n' >"$dir/changelog.d/12.md" printf -- '- Twelve landed (#12).\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 -- '- Nine landed, and its prose wraps onto a\n continuation line (#9).\n' >"$dir/changelog.d/9.md"
commit_base "$name" commit_base "$name"
} }
@ -86,8 +86,8 @@ check "faithful flat ceremony: the section is byte-for-byte the assembly" 0 \
seed_flat faithful-grouped seed_flat faithful-grouped
sed -i '/^- The shipped entry/i ### Fixed\\\n' "$TMP/faithful-grouped/CHANGELOG.md" 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 -- '### Fixed\n\n- Fixed twenty-one (#21).\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 -- '### 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" 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" add -A
git -C "$TMP/faithful-grouped" commit -qm regroup 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 ------------------- # --- inapplicable trees: green NOTICE, never a silent skip -------------------
seed_flat ordinary-add 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 commit_head ordinary-add
check "-dev PR adding a fragment: green NOTICE" 0 "NOTICE" run ordinary-add base 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 ## 0.2.0 — 2026-07-24
- Nine landed, and its prose wraps onto a - Nine landed, and its prose wraps onto a
continuation line. continuation line (#9).
- Twelve landed. - Twelve landed (#12).
## 0.1.0 — 2026-07-01 ## 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. # directory is not — only the survivor refusal fires.
seed_flat survivor seed_flat survivor
ceremony survivor 0.2.0 2026-07-24 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 commit_head survivor
check "a surviving fragment with its entry present fails" 1 "STILL PRESENT" \ check "a surviving fragment with its entry present fails" 1 "STILL PRESENT" \
run survivor base run survivor base
@ -318,7 +318,7 @@ init_repo env-tree
mkdir -p "$TMP/env-tree/frags" 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.\n' >"$TMP/env-tree/NOTES.md"
printf '0.1.1-dev\n' >"$TMP/env-tree/VERSION" 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" add -A
git -C "$TMP/env-tree" commit -qm base git -C "$TMP/env-tree" commit -qm base
git -C "$TMP/env-tree" branch fixture-base git -C "$TMP/env-tree" branch fixture-base

View file

@ -182,11 +182,11 @@ printf 'marker\n' >"$FRAG/README.md"
check "fragments: README.md is the directory marker, never a fragment" 0 "" \ check "fragments: README.md is the directory marker, never a fragment" 0 "" \
changelog_fragments "$FRAG" changelog_fragments "$FRAG"
printf -- '- Two.\n' >"$FRAG/2.md" printf -- '- Two (#2).\n' >"$FRAG/2.md"
printf -- '- Nine.\n' >"$FRAG/9.md" printf -- '- Nine (#9).\n' >"$FRAG/9.md"
printf -- '- Ten.\n' >"$FRAG/10.md" printf -- '- Ten (#10).\n' >"$FRAG/10.md"
printf -- '- Cross.\n' >"$FRAG/ceremony-14.md" printf -- '- Cross (#14).\n' >"$FRAG/ceremony-14.md"
printf -- '- Local fourteen.\n' >"$FRAG/14.md" printf -- '- Local fourteen (#14).\n' >"$FRAG/14.md"
assert_fragments_order() { assert_fragments_order() {
local expected="$1" actual local expected="$1" actual
@ -205,19 +205,19 @@ check "fragments: issue number descending (numeric, 10 before 9), filename tie-b
PF="$TMP/frag-problems" PF="$TMP/frag-problems"
mkdir -p "$PF" mkdir -p "$PF"
printf -- '- Fine.\n' >"$PF/7.md" printf -- '- Fine (#7).\n' >"$PF/7.md"
check "fragment predicate: a flat fragment passes" 0 "" \ check "fragment predicate: a flat fragment passes" 0 "" \
changelog_fragment_problem "$PF/7.md" changelog_fragment_problem "$PF/7.md"
cat >"$PF/8.md" <<'EOF' cat >"$PF/8.md" <<'EOF'
### Added ### Added
- Grouped fine. - Grouped fine (#8).
EOF EOF
check "fragment predicate: a grouped fragment passes" 0 "" \ check "fragment predicate: a grouped fragment passes" 0 "" \
changelog_fragment_problem "$PF/8.md" 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 "" \ check "fragment predicate: a cross-repo name passes" 0 "" \
changelog_fragment_problem "$PF/ceremony-14.md" changelog_fragment_problem "$PF/ceremony-14.md"
@ -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" \ "the bound is 300: split it into multiple '- ' entries in this same fragment" \
changelog_fragment_problem "$PF/30.md" 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 "" \ check "length bound: an entry of exactly 300 passes" 0 "" \
changelog_fragment_problem "$PF/31.md" changelog_fragment_problem "$PF/31.md"
{ {
printf -- '- %s\n' "$(mkchars 150)" printf -- '- %s (#32).\n' "$(mkchars 143)"
printf -- '- %s\n' "$(mkchars 150)" printf -- '- %s (#32).\n' "$(mkchars 143)"
printf -- '- %s\n' "$(mkchars 150)" printf -- '- %s (#32).\n' "$(mkchars 143)"
} >"$PF/32.md" } >"$PF/32.md"
check "length bound: several within-bound entries pass though the file totals over 300" 0 "" \ check "length bound: several within-bound entries pass though the file totals over 300" 0 "" \
changelog_fragment_problem "$PF/32.md" 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\n' "$(mkchars 50)" printf ' %s\n' "$(mkchars 50)"
printf ' %s\n' "$(mkchars 50)" printf ' %s (#33).\n' "$(mkchars 50)"
} >"$PF/33.md" } >"$PF/33.md"
check "length bound: a ~250-character entry wrapped over four continuation lines passes" 0 "" \ check "length bound: a ~250-character entry wrapped over four continuation lines passes" 0 "" \
changelog_fragment_problem "$PF/33.md" changelog_fragment_problem "$PF/33.md"
{ {
printf '### Added\n\n' printf '### Added\n\n'
printf -- '- %s\n' "$(mkchars 300)" printf -- '- %s (#34).\n' "$(mkchars 293)"
} >"$PF/34.md" } >"$PF/34.md"
check "length bound: a '### ' heading counts toward no entry — 300 under it still passes" 0 "" \ check "length bound: a '### ' heading counts toward no entry — 300 under it still passes" 0 "" \
changelog_fragment_problem "$PF/34.md" changelog_fragment_problem "$PF/34.md"
@ -331,6 +331,151 @@ check "length bound: published sections stay unvalidated — 0.3.0's over-bound
check "length bound: published sections stay unvalidated — 0.2.0 reds nothing either" 0 "" \ check "length bound: published sections stay unvalidated — 0.2.0 reds nothing either" 0 "" \
changelog_section_problem "$ROOT/CHANGELOG.md" 0.2.0 changelog_section_problem "$ROOT/CHANGELOG.md" 0.2.0
# --- the terminal issue cite (#262) ------------------------------------------
# cite_case <number> <entry-line...> — 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 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)"
} >"$PF/57.md"
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() {
local count
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
# 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.
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) ---------------------------------------------------- # --- the assembler (#114) ----------------------------------------------------
assert_assemble() { assert_assemble() {
@ -347,11 +492,11 @@ mkdir -p "$AF"
printf 'marker\n' >"$AF/README.md" printf 'marker\n' >"$AF/README.md"
cat >"$AF/3.md" <<'EOF' cat >"$AF/3.md" <<'EOF'
- Three — an em dash, and prose that - Three — an em dash, and prose that
wraps onto a continuation line. wraps onto a continuation line (#3).
EOF 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 "" \ 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 "" \ check "assemble: an empty directory is empty output — refusing is the caller's stance" 0 "" \
changelog_assemble "$TMP/no-such-dir" changelog_assemble "$TMP/no-such-dir"
@ -361,36 +506,36 @@ mkdir -p "$AG"
cat >"$AG/21.md" <<'EOF' cat >"$AG/21.md" <<'EOF'
### Fixed ### Fixed
- Fixed twenty-one. - Fixed twenty-one (#21).
EOF EOF
cat >"$AG/20.md" <<'EOF' cat >"$AG/20.md" <<'EOF'
### Added ### Added
- Added twenty. - Added twenty (#20).
### Docs ### Docs
- Docs twenty. - Docs twenty (#20).
EOF EOF
cat >"$AG/19.md" <<'EOF' cat >"$AG/19.md" <<'EOF'
### Security ### Security
- Security nineteen. - Security nineteen (#19).
### Added ### Added
- Added nineteen. - Added nineteen (#19).
EOF EOF
check "assemble: canonical group order, unnamed group appended, fragment order inside a group" 0 "" \ 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" AM="$TMP/assemble-mixed"
mkdir -p "$AM" mkdir -p "$AM"
printf -- '- Flat five.\n' >"$AM/5.md" printf -- '- Flat five (#5).\n' >"$AM/5.md"
cat >"$AM/6.md" <<'EOF' cat >"$AM/6.md" <<'EOF'
### Added ### Added
- Grouped six. - Grouped six (#6).
EOF EOF
check "assemble: mixed shapes refused, grouped side named" 1 "6.md" \ check "assemble: mixed shapes refused, grouped side named" 1 "6.md" \
changelog_assemble "$AM" changelog_assemble "$AM"
@ -400,11 +545,11 @@ check "assemble: mixed shapes refused, flat side named too" 1 "5.md" \
AX="$TMP/assemble-selfmixed" AX="$TMP/assemble-selfmixed"
mkdir -p "$AX" mkdir -p "$AX"
cat >"$AX/7.md" <<'EOF' cat >"$AX/7.md" <<'EOF'
- Ungrouped lead. - Ungrouped lead (#7).
### Added ### Added
- Grouped follow. - Grouped follow (#7).
EOF EOF
check "assemble: one fragment mixing both shapes is refused, file named" 1 \ check "assemble: one fragment mixing both shapes is refused, file named" 1 \
"'$AX/7.md' mixes grouped headings and ungrouped bullets" \ "'$AX/7.md' mixes grouped headings and ungrouped bullets" \
@ -429,14 +574,14 @@ cat >"$SHAPE_CHANGELOG" <<'EOF'
- Older section is grouped. - Older section is grouped.
EOF 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 "" \ check "shape: flat set matches newest flat published section" 0 "" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
cat >"$SHAPE_DIR/1.md" <<'EOF' cat >"$SHAPE_DIR/1.md" <<'EOF'
### Fixed ### Fixed
- Grouped fragment. - Grouped fragment (#1).
EOF EOF
check "shape: grouped set names its conflict with newest flat published section" 1 \ 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" \ "fragment '$SHAPE_DIR/1.md' is grouped but newest published section '2.0.0' in '$SHAPE_CHANGELOG' is flat" \
@ -451,7 +596,7 @@ cat >"$SHAPE_CHANGELOG" <<'EOF'
- Newest section is grouped. - Newest section is grouped.
EOF 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 \ 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" \ "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" changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
@ -459,7 +604,7 @@ check "shape: flat set names its conflict with newest grouped published section"
cat >"$SHAPE_DIR/1.md" <<'EOF' cat >"$SHAPE_DIR/1.md" <<'EOF'
### Fixed ### Fixed
- Grouped fragment. - Grouped fragment (#1).
EOF EOF
check "shape: grouped set matches newest grouped published section" 0 "" \ check "shape: grouped set matches newest grouped published section" 0 "" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
@ -484,7 +629,7 @@ EOF
cat >"$SHAPE_DIR/1.md" <<'EOF' cat >"$SHAPE_DIR/1.md" <<'EOF'
### Fixed ### Fixed
- Grouped fragment. - Grouped fragment (#1).
EOF EOF
printf 'grouped\n' >"$SHAPE_DIR/shape" printf 'grouped\n' >"$SHAPE_DIR/shape"
check "shape: 'grouped' sentinel admits a grouped set over a flat published section" 0 "" \ check "shape: 'grouped' sentinel admits a grouped set over a flat published section" 0 "" \
@ -492,7 +637,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 "" \ check "shape: the sentinel binds with no changelog at all — the assembler's call" 0 "" \
changelog_shape_problem "" "$SHAPE_DIR" 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 \ 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" \ "fragment '$SHAPE_DIR/1.md' is flat but '$SHAPE_DIR/shape' declares grouped" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
@ -512,14 +657,14 @@ check "shape: 'flat' sentinel admits a flat set over a grouped published section
cat >"$SHAPE_DIR/1.md" <<'EOF' cat >"$SHAPE_DIR/1.md" <<'EOF'
### Fixed ### Fixed
- Grouped fragment. - Grouped fragment (#1).
EOF EOF
check "shape: grouped fragment under a 'flat' sentinel refused, fragment and sentinel named" 1 \ 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" \ "fragment '$SHAPE_DIR/1.md' is grouped but '$SHAPE_DIR/shape' declares flat" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
printf 'grouped\n' >"$SHAPE_DIR/shape" 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 \ 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" \ "fragment '$SHAPE_DIR/1.md' is grouped but fragment '$SHAPE_DIR/2.md' is not" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
@ -558,7 +703,7 @@ printf 'grouped\n' >"$SHAPE_DIR/shape"
cat >"$SHAPE_DIR/1.md" <<'EOF' cat >"$SHAPE_DIR/1.md" <<'EOF'
### Fixed ### Fixed
- Grouped fragment. - Grouped fragment (#1).
EOF EOF
assert_fragments_exclude_sentinel() { assert_fragments_exclude_sentinel() {
local out local out
@ -579,17 +724,17 @@ printf 'grouped\n' >"$AS/shape"
cat >"$AS/30.md" <<'EOF' cat >"$AS/30.md" <<'EOF'
### Fixed ### Fixed
- Fixed thirty. - Fixed thirty (#30).
EOF EOF
cat >"$AS/31.md" <<'EOF' cat >"$AS/31.md" <<'EOF'
### Added ### Added
- Added thirty-one. - Added thirty-one (#31).
EOF EOF
check "assemble: the sentinel never assembles, and canonical order holds under it" 0 "" \ 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" 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 \ check "assemble: a flat set under a 'grouped' sentinel refuses to assemble" 1 \
"declares grouped" \ "declares grouped" \
changelog_assemble "$AS" changelog_assemble "$AS"