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 <noreply@anthropic.com>
This commit is contained in:
claude-bot-andresmgsl 2026-07-25 13:09:37 +00:00
parent 4debf53872
commit 7d29acdeb0
2 changed files with 42 additions and 3 deletions

View file

@ -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

View file

@ -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 '<dir>/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