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 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/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/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). diff --git a/changelog.d/262.md b/changelog.d/262.md new file mode 100644 index 0000000..89e90fa --- /dev/null +++ b/changelog.d/262.md @@ -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). 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). diff --git a/lib/changelog.sh b/lib/changelog.sh index 974b4a8..fb229a1 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,15 @@ changelog_fragment_problem() { sub(/ $/, "", e) len = length(e) 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 } + kind = cite_problem(e) + if (kind != "" && cite_kind == "") { + cite_kind = kind + cite_excerpt = excerpt(e) + } return 0 } /^### / { if (flush()) exit; next } @@ -182,12 +228,37 @@ changelog_fragment_problem() { } /^[[:space:]]*$/ { next } 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" )" 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 } diff --git a/test/changelog-armed.test.sh b/test/changelog-armed.test.sh index 66c0f88..f9bb57f 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 @@ -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 @@ -310,7 +342,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 +354,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 +374,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 +389,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 +408,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 +425,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 +449,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 @@ -484,7 +516,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" \ diff --git a/test/changelog-assemble.test.sh b/test/changelog-assemble.test.sh index de008b9..f87a00f 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,45 @@ 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).' + +# --- 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 <"$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 "" \ "$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 ------------------------------- @@ -292,7 +324,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 +436,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..79a69f1 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 @@ -318,7 +318,7 @@ 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 '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..47e9ac9 100755 --- a/test/changelog.test.sh +++ b/test/changelog.test.sh @@ -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,19 +205,19 @@ 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" @@ -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" @@ -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 "" \ changelog_section_problem "$ROOT/CHANGELOG.md" 0.2.0 +# --- the terminal issue cite (#262) ------------------------------------------ + +# cite_case — 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) ---------------------------------------------------- assert_assemble() { @@ -347,11 +492,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 +506,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 +545,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 +574,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 +596,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 +604,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 +629,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 +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 "" \ 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 +657,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 +703,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 +724,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"