From 7d29acdeb0691f6e566644c4d6e2e38cff047ddd Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Sat, 25 Jul 2026 13:09:37 +0000 Subject: [PATCH] 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