From 7d29acdeb0691f6e566644c4d6e2e38cff047ddd Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Sat, 25 Jul 2026 13:09:37 +0000 Subject: [PATCH 1/4] feat: changelog_shape_problem reads the declarable anchor changelog.d/shape The sentinel pins the set's shape, outranking the newest-published-section inference (#182 D2); malformed content is a red diagnosis naming the file. bin/changelog-assemble skips 'shape' in the stray-file loop so the sentinel survives the consumption (#182 D5). Co-Authored-By: Claude Fable 5 --- bin/changelog-assemble | 9 ++++++--- lib/changelog.sh | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/bin/changelog-assemble b/bin/changelog-assemble index 746f085..5d0eb74 100755 --- a/bin/changelog-assemble +++ b/bin/changelog-assemble @@ -62,12 +62,15 @@ done # Every entry in the directory must be a publishable fragment. A stray file # in a machine-assembled directory is a mistake to surface, never to skip — -# except README.md, the directory's marker (#112 D1). This runs before the -# zero-fragments check so a directory holding only 'notes.txt' names the -# stray file instead of claiming emptiness. +# except README.md, the directory's marker (#112 D1), and 'shape', the +# declared anchor (#182), which changelog_shape_problem validates below and +# which deliberately survives the consumption: it is the declaration a +# reader in the directory finds, and it must still be there after the +# release empties the fragments out. for f in "$dir"/*; do [ -e "$f" ] || continue [ "${f##*/}" = "README.md" ] && continue + [ "${f##*/}" = "shape" ] && continue if ! diagnosis="$(changelog_fragment_problem "$f")"; then refuse "$diagnosis" fi diff --git a/lib/changelog.sh b/lib/changelog.sh index a177665..a333819 100644 --- a/lib/changelog.sh +++ b/lib/changelog.sh @@ -199,9 +199,31 @@ changelog_fragment_problem() { # definition shared by the PR-time guard and the release-time assembler: # fragments may not mix grouped headings with ungrouped bullets, and a # non-empty set must match the newest published section when one exists. +# +# The anchor is declarable (#182): an optional sentinel '/shape', +# holding exactly 'flat' or 'grouped' on one line, pins the set's shape and +# outranks the newest-published-section inference — the door a deliberate +# flip walks through, while undeclared drift stays red (#159). Absent, the +# inference binds unchanged. Any other content — empty, trailing junk, an +# unknown word — is a diagnosis naming the file, never a silent fallback. +# The sentinel lives in the fragments dir so it binds in both callers: the +# assembler calls with changelog="" and still sees it. It is not a fragment +# — changelog_fragments matches *.md only, so 'shape' never enters the list. changelog_shape_problem() { local changelog="$1" dir="$2" local fragments f grouped_in="" ungrouped_in="" published="" published_body="" + local sentinel="$dir/shape" declared="" + + if [ -f "$sentinel" ]; then + declared="$(cat "$sentinel")" + case "$declared" in + flat | grouped) ;; + *) + printf "'%s' declares neither shape — its whole content must be 'flat' or 'grouped', one line\n" "$sentinel" + return 1 + ;; + esac + fi fragments="$(changelog_fragments "$dir")" [ -n "$fragments" ] || return 0 @@ -227,6 +249,20 @@ changelog_shape_problem() { return 1 fi + if [ -n "$declared" ]; then + if [ "$declared" = "grouped" ] && [ -n "$ungrouped_in" ]; then + printf "fragment '%s' is flat but '%s' declares grouped — a repo is one shape or the other\n" \ + "$ungrouped_in" "$sentinel" + return 1 + fi + if [ "$declared" = "flat" ] && [ -n "$grouped_in" ]; then + printf "fragment '%s' is grouped but '%s' declares flat — a repo is one shape or the other\n" \ + "$grouped_in" "$sentinel" + return 1 + fi + return 0 + fi + if [ -f "$changelog" ]; then published="$(awk '$1 == "##" && $2 != "Unreleased" { print $2; exit }' "$changelog")" fi From 2fd9cb7dedab08975b1116b74330d4e34e2ff296 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Sat, 25 Jul 2026 13:12:30 +0000 Subject: [PATCH 2/4] test: sentinel rows across the shape predicate, the armed guard, and the assembler Must-pass and must-fail rows from #182's test plan: the flip shape green under the sentinel, drift and malformed sentinels red with file named, the D3 fragment-list assertion, and D5 sentinel survival through consumption. Co-Authored-By: Claude Fable 5 --- test/changelog-armed.test.sh | 42 ++++++++++++ test/changelog-assemble.test.sh | 45 +++++++++++++ test/changelog.test.sh | 115 ++++++++++++++++++++++++++++++++ 3 files changed, 202 insertions(+) diff --git a/test/changelog-armed.test.sh b/test/changelog-armed.test.sh index df00bdb..fdb3700 100644 --- a/test/changelog-armed.test.sh +++ b/test/changelog-armed.test.sh @@ -362,6 +362,48 @@ 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 +# The declared anchor (#182): the flip tree — a grouped set under a +# 'grouped' sentinel over a flat published section — is green, where the +# same tree minus the sentinel is the all-grouped-over-flat red row above. +fragment_tree fragments-dev-flip 1.2.4-dev <<'EOF' +# Changelog + +## 1.2.3 — 2026-07-20 + +- The shipped entry. +EOF +printf '%s\n' "grouped" >"$TMP/fragments-dev-flip/changelog.d/shape" +cat >"$TMP/fragments-dev-flip/changelog.d/115.md" <<'EOF' +### Fixed + +- Grouped fragment. +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" +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 +rm "$TMP/fragments-dev-flip/changelog.d/116.md" + +# And once the grouped fragments are consumed, the sentinel alone still +# 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" +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 +rm "$TMP/fragments-dev-flip/changelog.d/116.md" + +printf '%s\n' "Grouped" >"$TMP/fragments-dev-flip/changelog.d/shape" +check "fragment mode: a malformed sentinel is refused, file named" 1 \ + "'changelog.d/shape' declares neither shape" \ + in_tree fragments-dev-flip + fragment_tree fragments-dev-no-published 1.2.4-dev <<'EOF' # Changelog diff --git a/test/changelog-assemble.test.sh b/test/changelog-assemble.test.sh index 34f625b..de008b9 100644 --- a/test/changelog-assemble.test.sh +++ b/test/changelog-assemble.test.sh @@ -143,6 +143,51 @@ check "grouped: the written file is exact" 0 "" \ assert_file "$TMP/grouped/CHANGELOG.md" \ $'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n'"$GROUPED_BODY"$'\n\n## 0.1.0 — 2026-07-01\n\n### Fixed\n\n- The shipped entry.' +# --- the declared anchor: the first grouped release over a flat history ------ +# The flip ceremony (#182): a 'grouped' sentinel admits grouped fragments +# over a flat newest published section, the sentinel is never a stray file, +# and it survives the consumption (D5) — the next -dev tree still declares +# its shape. + +tree flip <"$TMP/flip/changelog.d/shape" +frag flip 40.md <<'EOF' +### Added + +- Forty landed. +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.' +check "sentinel: changelog.d/shape survives consumption" 0 "" \ + test -e "$TMP/flip/changelog.d/shape" + +tree flip-flat-frag <"$TMP/flip-flat-frag/changelog.d/shape" +frag flip-flat-frag 41.md <<'EOF' +- Flat forty-one. +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 + +tree flip-malformed <"$TMP/flip-malformed/changelog.d/shape" +frag flip-malformed 42.md <<'EOF' +### Added + +- Forty-two. +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 + # --- a changelog holding only its preamble ----------------------------------- tree preamble-only <<'EOF' diff --git a/test/changelog.test.sh b/test/changelog.test.sh index a04aa74..57f7b74 100755 --- a/test/changelog.test.sh +++ b/test/changelog.test.sh @@ -469,4 +469,119 @@ rm "$SHAPE_DIR/1.md" check "shape: empty fragment set makes the anchor rule vacuous" 0 "" \ changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" +# --- the declarable anchor: /shape (#182) ------------------------------- +# The sentinel pins the set's shape and outranks the newest-published-section +# inference — the door a deliberate flip walks through, while the undeclared +# drift rows above stay red, verbatim. + +cat >"$SHAPE_CHANGELOG" <<'EOF' +# Changelog + +## 2.0.0 — 2026-07-24 + +- Newest section is flat. +EOF +cat >"$SHAPE_DIR/1.md" <<'EOF' +### Fixed + +- Grouped fragment. +EOF +printf 'grouped\n' >"$SHAPE_DIR/shape" +check "shape: 'grouped' sentinel admits a grouped set over a flat published section" 0 "" \ + changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" +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" +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" + +cat >"$SHAPE_CHANGELOG" <<'EOF' +# Changelog + +## 2.0.0 — 2026-07-24 + +### Fixed + +- Newest section is grouped. +EOF +printf 'flat\n' >"$SHAPE_DIR/shape" +check "shape: 'flat' sentinel admits a flat set over a grouped published section" 0 "" \ + changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" +cat >"$SHAPE_DIR/1.md" <<'EOF' +### Fixed + +- Grouped fragment. +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" +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" +rm "$SHAPE_DIR/1.md" "$SHAPE_DIR/2.md" + +check "shape: empty fragment set with a valid sentinel passes" 0 "" \ + changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" + +printf 'Grouped\n' >"$SHAPE_DIR/shape" +check "shape: a capitalized sentinel is refused, file named" 1 \ + "'$SHAPE_DIR/shape' declares neither shape" \ + changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" +: >"$SHAPE_DIR/shape" +check "shape: an empty sentinel is refused — never a silent fallback" 1 \ + "'$SHAPE_DIR/shape' declares neither shape" \ + changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" +printf 'grouped\nflat\n' >"$SHAPE_DIR/shape" +check "shape: a two-line sentinel is refused" 1 \ + "'$SHAPE_DIR/shape' declares neither shape" \ + changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" + +# The sentinel is not a fragment (#182 D3): the *.md glob is the mechanism, +# but the assertion is on the list itself, so a glob change cannot silently +# start publishing the sentinel. +printf 'grouped\n' >"$SHAPE_DIR/shape" +cat >"$SHAPE_DIR/1.md" <<'EOF' +### Fixed + +- Grouped fragment. +EOF +assert_fragments_exclude_sentinel() { + local out + out="$(changelog_fragments "$SHAPE_DIR")" + [ -n "$out" ] || { echo "wanted a non-empty fragment list"; return 1; } + if printf '%s\n' "$out" | grep -q '/shape$'; then + printf 'the sentinel leaked into the fragment list:\n%s\n' "$out" + return 1 + fi +} +check "fragments: the shape sentinel never enters the fragment list" 0 "" \ + assert_fragments_exclude_sentinel +rm "$SHAPE_DIR/1.md" "$SHAPE_DIR/shape" + +AS="$TMP/assemble-sentinel" +mkdir -p "$AS" +printf 'grouped\n' >"$AS/shape" +cat >"$AS/30.md" <<'EOF' +### Fixed + +- Fixed thirty. +EOF +cat >"$AS/31.md" <<'EOF' +### Added + +- Added thirty-one. +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.' +rm "$AS/30.md" "$AS/31.md" +printf -- '- Flat probe.\n' >"$AS/29.md" +check "assemble: a flat set under a 'grouped' sentinel refuses to assemble" 1 \ + "declares grouped" \ + changelog_assemble "$AS" + summary From be666ebed691b6096d42aefc2d7b8700670f6b39 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Sat, 25 Jul 2026 13:14:05 +0000 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20ceremony=20flips=20to=20grouped=20?= =?UTF-8?q?=E2=80=94=20changelog.d/shape=20=3D=20grouped,=20five=20fragmen?= =?UTF-8?q?ts=20converted,=20docs=20per=20D6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 167/175 gain '### Added', 173/178 '### Changed', 180 '### Fixed' — every bullet byte-identical, headings only (the #158 bar, inverted). CONSUMERS.md names the sentinel and the flip procedure; changelog.d/README.md names the sentinel. 182.md is this PR's own fragment, grouped atop the sentinel it ships. Co-Authored-By: Claude Fable 5 --- changelog.d/167.md | 2 ++ changelog.d/173.md | 2 ++ changelog.d/175.md | 2 ++ changelog.d/178.md | 2 ++ changelog.d/180.md | 2 ++ changelog.d/182.md | 10 ++++++++++ changelog.d/README.md | 3 ++- changelog.d/shape | 1 + docs/CONSUMERS.md | 8 +++++++- 9 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 changelog.d/182.md create mode 100644 changelog.d/shape diff --git a/changelog.d/167.md b/changelog.d/167.md index cf450cf..cd68b61 100644 --- a/changelog.d/167.md +++ b/changelog.d/167.md @@ -1,3 +1,5 @@ +### Added + - `changelog_fragment_problem` bounds every entry at 300 normalized characters, red on the PR that writes the fragment; the armed guard and the assembler inherit the one definition (#167). diff --git a/changelog.d/173.md b/changelog.d/173.md index 7874a93..85efe7b 100644 --- a/changelog.d/173.md +++ b/changelog.d/173.md @@ -1 +1,3 @@ +### Changed + - Private-repository label callers document `actions: read` alongside checks and statuses for workflow-run check-rollup nodes (#173). diff --git a/changelog.d/175.md b/changelog.d/175.md index 2448c7b..3797178 100644 --- a/changelog.d/175.md +++ b/changelog.d/175.md @@ -1 +1,3 @@ +### Added + - Add `post-merge` issue state for merged `Refs` work awaiting triage-owned verification. diff --git a/changelog.d/178.md b/changelog.d/178.md index 5709aea..d18421a 100644 --- a/changelog.d/178.md +++ b/changelog.d/178.md @@ -1,3 +1,5 @@ +### Changed + - BUILDER.md: a park declaration stands until its facts change — a nothing-changed resumption posts nothing; only a no-open-PR park owes a refresh, inside the 48-hour reclaim window (#178). diff --git a/changelog.d/180.md b/changelog.d/180.md index 2d619e1..2675b68 100644 --- a/changelog.d/180.md +++ b/changelog.d/180.md @@ -1,3 +1,5 @@ +### Fixed + - `decide_state()` refuses `state:needs-human` while the hand-set `blocked` label stands — the PR falls to `state:addressing`, exactly parallel to the `needs-ruling` exclusion; never emitted by `blockers()` (#180). diff --git a/changelog.d/182.md b/changelog.d/182.md new file mode 100644 index 0000000..3bac154 --- /dev/null +++ b/changelog.d/182.md @@ -0,0 +1,10 @@ +### Added + +- `changelog.d/shape` — an optional one-line sentinel, `flat` or `grouped`, + that pins the fragment set's shape and outranks the newest-published-section + inference; absent, the inference binds unchanged (#182). + +### Changed + +- Ceremony's changelog is grouped from this release forward: the pending + fragments carry `### ` headings under a `grouped` sentinel (#182). diff --git a/changelog.d/README.md b/changelog.d/README.md index 1c529b2..a611192 100644 --- a/changelog.d/README.md +++ b/changelog.d/README.md @@ -7,4 +7,5 @@ published, nothing else — and the release PR folds them all into the next filenames never conflict, which is this directory's whole reason to exist. This README is the marker that keeps the directory tracked when it holds no fragments (#112 D1) — `changelog-armed` refuses a tree without it; do not -delete it. +delete it. The `shape` sentinel beside it declares the set's shape — +`grouped` here, so every fragment carries `### ` headings (#182). diff --git a/changelog.d/shape b/changelog.d/shape new file mode 100644 index 0000000..eaecab2 --- /dev/null +++ b/changelog.d/shape @@ -0,0 +1 @@ +grouped diff --git a/docs/CONSUMERS.md b/docs/CONSUMERS.md index da11abd..3d02e52 100644 --- a/docs/CONSUMERS.md +++ b/docs/CONSUMERS.md @@ -480,7 +480,13 @@ CONTRIBUTING may sharpen it, but this is the floor the guards assume: kind. A repo is grouped or flat, never both (#112 D4). The assembler merges groups in canonical order — Added, Changed, Fixed, Removed, Deprecated, Security, then anything else first-seen — and inside a - group entries read newest issue first (#112 D5). + group entries read newest issue first (#112 D5). Which shape binds is + inferred from the newest published section, unless an optional sentinel + `changelog.d/shape` — one line, exactly `flat` or `grouped` — declares + it and outranks the inference (#182). To flip a repo's shape, land one + PR that adds the sentinel and converts every pending fragment to the + declared shape, bullets byte-identical; the sentinel stays after the + release, as the declaration a reader in the directory finds. - **One line: say what changed, and stop.** Lead with the surface, not the mechanism — "`state:needs-human` is set at handoff" beats "the labels workflow now also wakes on `labeled`". The why and the how From 0b158a6917ade8e496da21f8ff75c714cca5eff6 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Sat, 25 Jul 2026 13:29:02 +0000 Subject: [PATCH 4/4] fix: the sentinel's one-line contract is checked on the file, not the $(cat) word MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Command substitution strips every trailing newline, so 'grouped\n\n' reached the case as a clean word and passed — the round's shared blocker (codex, grok). A line count taken from the file itself now refuses any physically multi-line sentinel before the word check runs, with the existing diagnosis naming the file. Red rows: grouped/flat with a trailing blank line in the unit suite, grouped in the armed suite. Co-Authored-By: Claude Fable 5 --- lib/changelog.sh | 7 +++++++ test/changelog-armed.test.sh | 5 +++++ test/changelog.test.sh | 10 ++++++++++ 3 files changed, 22 insertions(+) diff --git a/lib/changelog.sh b/lib/changelog.sh index a333819..974b4a8 100644 --- a/lib/changelog.sh +++ b/lib/changelog.sh @@ -215,6 +215,13 @@ changelog_shape_problem() { local sentinel="$dir/shape" declared="" if [ -f "$sentinel" ]; then + # The one-line contract is checked on the file itself: command + # substitution strips every trailing newline, so the captured word + # cannot tell 'grouped' from 'grouped' plus blank lines. + if [ "$(wc -l <"$sentinel")" -gt 1 ]; then + printf "'%s' declares neither shape — its whole content must be 'flat' or 'grouped', one line\n" "$sentinel" + return 1 + fi declared="$(cat "$sentinel")" case "$declared" in flat | grouped) ;; diff --git a/test/changelog-armed.test.sh b/test/changelog-armed.test.sh index fdb3700..66c0f88 100644 --- a/test/changelog-armed.test.sh +++ b/test/changelog-armed.test.sh @@ -404,6 +404,11 @@ check "fragment mode: a malformed sentinel is refused, file named" 1 \ "'changelog.d/shape' declares neither shape" \ in_tree fragments-dev-flip +printf 'grouped\n\n' >"$TMP/fragments-dev-flip/changelog.d/shape" +check "fragment mode: a sentinel with a trailing blank line is refused, file named" 1 \ + "'changelog.d/shape' declares neither shape" \ + in_tree fragments-dev-flip + fragment_tree fragments-dev-no-published 1.2.4-dev <<'EOF' # Changelog diff --git a/test/changelog.test.sh b/test/changelog.test.sh index 57f7b74..0c9979d 100755 --- a/test/changelog.test.sh +++ b/test/changelog.test.sh @@ -540,6 +540,16 @@ printf 'grouped\nflat\n' >"$SHAPE_DIR/shape" check "shape: a two-line sentinel is refused" 1 \ "'$SHAPE_DIR/shape' declares neither shape" \ changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" +# Trailing blank lines are the case command substitution launders away: the +# captured word is a clean 'grouped', only the file's line count still knows. +printf 'grouped\n\n' >"$SHAPE_DIR/shape" +check "shape: 'grouped' with a trailing blank line is refused, file named" 1 \ + "'$SHAPE_DIR/shape' declares neither shape" \ + changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" +printf 'flat\n\n' >"$SHAPE_DIR/shape" +check "shape: 'flat' with a trailing blank line is refused, file named" 1 \ + "'$SHAPE_DIR/shape' declares neither shape" \ + changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR" # The sentinel is not a fragment (#182 D3): the *.md glob is the mechanism, # but the assertion is on the list itself, so a glob change cannot silently