From 72fa3e0b4d5fc97ce466d184aa8e1945a519a421 Mon Sep 17 00:00:00 2001 From: cndgrr <59120057+cndgrr@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:00:48 +0000 Subject: [PATCH] feat(changelog): the terminal issue cite joins the fragment guard Refs #262 --- lib/changelog.sh | 76 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 70 insertions(+), 6 deletions(-) diff --git a/lib/changelog.sh b/lib/changelog.sh index 974b4a8..a054bb9 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,14 @@ changelog_fragment_problem() { sub(/ $/, "", e) len = length(e) if (len > max) { - printf "%d\t%s\n", len, substr(e, 1, 60) + 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 +227,31 @@ changelog_fragment_problem() { } /^[[:space:]]*$/ { next } entry != "" { entry = entry " " $0 } - END { flush() } + END { + if (flush()) exit + if (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 }