Skip to content

fix(scripts): key the absorbed-heading check on the version, not the line (#2327) - #2341

Merged
kyle-sexton merged 2 commits into
mainfrom
fix/2327-heading-line-false-positive
Aug 12, 2026
Merged

fix(scripts): key the absorbed-heading check on the version, not the line (#2327)#2341
kyle-sexton merged 2 commits into
mainfrom
fix/2327-heading-line-false-positive

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Summary

#2290's absorbed-heading check diffs whole heading lines: changelog_bracket_headings() is grep -E without -o, so it emits the entire line, and comm -23 compares those lines. Any edit to an existing release heading therefore reads as a deletion — including both annotations Keep a Changelog itself prescribes, which preserve the heading they annotate.

Measured against the shipped script on main, copied unmodified into a throwaway repo (#2327 carries the full reproduction):

change heading at head --check-bump on main
mark a pulled release ## [1.0.0] - 2026-01-01 [YANKED] EXIT=1 ABSORBED CHANGELOG HEADING … ## [1.0.0]
date an existing release ## [1.0.0] - 2026-01-01 EXIT=1 ABSORBED CHANGELOG HEADING … ## [1.0.0]

1.0.0 is documented in both. Nothing was absorbed. This is a required merge check, so a false positive here blocks every in-flight PR that touches a heading line, not just its author's.

Fix

Key the comparison on the version each heading names, through the shared changelog_versions extractor, instead of on the rendered line. ## [1.0.0] and ## [1.0.0] - 2014-12-13 [YANKED] are one release, so annotating passes; renaming ## [0.51.8] to ## [0.51.9] still deletes the version 0.51.8, so a relabel is still caught.

changelog_bracket_headings() is dropped. It was a second reader of the same file, which the header's own rendered_lines note argues against — "One tracker, not three, so the modes cannot drift." The failure message still prints ## [<version>], so nothing downstream of it changes.

Two behaviour changes worth naming, both consequences of keying on the version:

  • Reformatting a heading between the bracketed and unbracketed forms is no longer a deletion. The format of a release entry is CHANGELOG FORMAT's concern, not preservation's.
  • An unbracketed heading is now protected. A bracketed-only matcher could not see ## 0.9.0 — 2025-12-01 at all, so absorbing that section was invisible. Both directions have a test.

Deliberately out of scope

All pre-existing from #2290 and tracked in #2327 — a false positive on a required gate is worth a surgical fix, not a redesign in the same change set:

  • The check lives inside --check-bump's loop, so an absorption in a change set that does not bump the manifest is unpoliced (verified EXIT=0).
  • docs/conventions/*/CHANGELOG.md is outside --check-bump's scope entirely, so an absorption there is unpoliced (verified EXIT=0).
  • The 2>/dev/null process substitution on git show is fail-open: a git read that genuinely fails yields an empty fork-point set and reads as "nothing to preserve".

#2327 sketches the fuller alternative (preservation as its own diff mode over every changed changelog under both roots), with a worked implementation on fix/2264-changelog-preservation.

Test plan

Suite: PASS=62 FAIL=0 (57 pre-existing + 5 new), bash scripts/check-changelog-parity.test.sh.

New cases:

  • dating an existing heading is not an absorbed heading — the FP above.
  • marking a release '[YANKED]' preserves its heading and passes --check-bump — the FP above.
  • a relabelled predecessor heading still fails --check-bump — the check must not go soft.
  • reformatting a heading between the two accepted forms is not an absorbed heading.
  • absorbing an unbracketed release section is caught — coverage a bracketed-only matcher could not have.

#2290's own absorption test is untouched and still passes, asserting on ## [0.51.8] in the output.

The new tests are load-bearing — established before the fix existed, by running main's shipped script unmodified against the same two fixtures the new tests use (full transcript in #2327):

fixture main's script this branch
## [1.0.0]## [1.0.0] - 2026-01-01 [YANKED] EXIT=1 ABSORBED CHANGELOG HEADING … ## [1.0.0] EXIT=0
## [1.0.0]## [1.0.0] - 2026-01-01 EXIT=1 ABSORBED CHANGELOG HEADING … ## [1.0.0] EXIT=0

The symmetric in-tree check — restoring only scripts/check-changelog-parity.sh from origin/main and re-running the suite — was not run: the command was refused by a local permission classifier, and it was not reshaped to get around it. The table above is the same evidence by a different route.

Zero false positives on real history. main is squash-merged, so each commit on it is a merged PR. Every one of the 60 most recent commits touching plugins/*/CHANGELOG.md was replayed as its own PR (tree at C, base C^1) in a throwaway local clone, running this branch's --check-bump:

commits replayed 59 (of 60; cf743d61 could not be checked out)
clean 58
fired 1
of which ABSORBED CHANGELOG HEADING 0

The single fire is 78dbb10e, reporting UNDOCUMENTED BUMP and three VERSION REGRESSIONs — none from the check this PR touches.

The zero is honest, and it is itself a finding. The one commit in recent history that really does delete a release heading — 04822fc4, which folded docs-hygiene's never-released ## [0.9.7] into ## [0.10.0] — is in the replay window (verified) and is not caught, because it changed no manifest at all (git diff --name-only 04822fc4^1 04822fc4 lists only ci.yml, that changelog, and the two gate files). Living inside --check-bump's loop, the check never reaches a plugin whose version did not change. That is #2327 item 3, now demonstrated on real history rather than a synthetic fixture. This PR does not change that scoping — it fixes the false positives — so the sweep's role here is to show the fix introduces no new fires across 59 real commits, not to demonstrate detection. Detection is demonstrated by the unit cases.

Replaying a squash commit against its parent is not identical to what CI saw at merge time — CI resolved the fork point of the original branch, which for a stale branch sat further back — so fires from --check-bump's other checks (VERSION REGRESSION, UNDOCUMENTED BUMP) are artifacts of comparing against a parent the branch never saw, plus checks that did not exist when those commits merged. The absorbed-heading check is unaffected: C^1 is an ancestor of C, so the fork point and the parent coincide and the comparison reads exactly the diff that commit introduced.

Other gates, locally: shellcheck --rcfile=.shellcheckrc -x on both files → clean; scripts/check-shell-portability.sh --paths on both files → No unexcused GNU-only constructs in 2 shell file(s).

Conflict-marker sweep, both files: <<<<<<< 0, ||||||| 0, >>>>>>> 0, ======= 4 — all four pre-existing # ==== <section> ==== banner comments in the test file, none introduced here (0 marker-shaped lines among this diff's additions).

Related

Closes #2327 — the live false positives it reports. Its remaining items (the scoping gaps and the fail-open fork-point read, all pre-existing from #2290 and untouched here) were split into #2342 so this close is honest rather than partial.

#2342 carries the remaining scoping work. #2290 introduced the check being repaired; #2264 is the defect it closed. #2154 / #2159 are the SIGPIPE regression in this same gate — the reading discipline is untouched here, since neither changelog_versions nor mapfile exits before EOF.

…line (#2327)

#2290's absorption check diffs whole heading LINES
(`changelog_bracket_headings` is `grep -E` without `-o`, fed to
`comm -23`), so any edit to an existing release heading reads as a
deletion. Both annotations Keep a Changelog itself prescribes red-line
this REQUIRED gate while PRESERVING the heading they annotate — verified
against the shipped script on main, unmodified:

    ## [1.0.0]  ->  ## [1.0.0] - 2026-01-01 [YANKED]   EXIT=1
    ## [1.0.0]  ->  ## [1.0.0] - 2026-01-01            EXIT=1

A false positive on a required merge check blocks every in-flight PR
that touches a heading line, not only its author's.

Keys the comparison on the VERSION each heading names, through the
shared `changelog_versions` extractor, so `## [1.0.0]` and
`## [1.0.0] - 2026-01-01 [YANKED]` are one release. `changelog_bracket_headings`
is dropped: it was a second reader of the same file, which the header's
rendered_lines note argues against ("One tracker, not three, so the
modes cannot drift").

The failure still PRINTS `## [<version>]`, so nothing downstream of the
message changes.

Two behaviour changes worth naming:

- Reformatting a heading between the bracketed and unbracketed forms is
  no longer a deletion — that is CHANGELOG FORMAT's concern.
- An UNBRACKETED heading is now protected. A bracketed-only matcher
  could not see `## 0.9.0 — 2025-12-01` at all, so absorbing that
  section was invisible.

A relabel is still caught: renaming `## [0.51.8]` to `## [0.51.9]`
deletes the version 0.51.8 whatever the line around it looks like.

Out of scope, tracked in #2327: the check lives inside `--check-bump`'s
loop, so an absorption in a change set that does not bump the manifest,
and one in a `docs/conventions/*/CHANGELOG.md`, are still unpoliced; and
the `2>/dev/null` process substitution on `git show` is fail-open. Both
are pre-existing from #2290 and out of scope for a surgical
false-positive fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 12, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 1m 44s —— View job


Security Review

  • Gather context (PR diff, changed files)
  • Read scripts/check-changelog-parity.sh changes
  • Read scripts/check-changelog-parity.test.sh changes
  • Hunt for injection, logic, and trust-boundary issues
  • Post findings

Scope: both changed files — scripts/check-changelog-parity.sh (+30/-12) and scripts/check-changelog-parity.test.sh (+90/-0, test-only additions). No workflow files are touched by this PR, so the dangerous-trigger / permissions-widening / unpinned-action lanes this review normally covers don't apply here (nothing for zizmor to flag either).

No security issues found.

Reasoning, focused on the actual production-code delta (scripts/check-changelog-parity.sh:376-461, replacing changelog_bracket_headings() + comm -23 with changelog_versions + a head_documents associative array):

  • Injection surface: the new code's only "attacker-influenced" input is CHANGELOG.md content from a PR diff. The version tokens it extracts and uses as associative-array keys (head_documents["$v"]=1) are constrained by changelog_versions()'s regex to [0-9A-Za-z.+-] — no shell metacharacters, no eval, no bash -c interpolation anywhere in the new code. $v is always double-quoted where used.
  • Path handling: git show "$merge_base:$changelog" — both operands are quoted; $changelog comes from a glob over the repo's own plugins/*/CHANGELOG.md, not from PR-controlled path strings, and even if it contained odd characters it's passed as a single argument, not interpolated into a shell string.
  • Fail-closed behavior preserved: the rewrite keeps command substitution (not process substitution) for the parts that must fail loud, and the new mapfile/associative-array logic doesn't introduce a silent-pass path — an unresolvable git show still yields an empty fork_heading_versions, which was already called out in the PR description as a known, pre-existing fail-open edge case (out of scope here).
  • Array-reset correctness: head_documents=() is re-initialized inside the per-manifest loop body (check-changelog-parity.sh:378 declares it once, reset per iteration before population), so there's no cross-plugin state leakage between iterations that could mask a real deletion for one plugin because another plugin happened to document the same version string.
  • Test file: all additions are self-contained, running against throwaway local repos created via mk_repo/git_init in a temp directory — no network calls, no execution of PR-supplied content outside the sandboxed fixture repos, nothing that touches secrets or credentials.

Nothing else in this diff touches authorization, tokens/secrets, or CI trust boundaries — it's a pure bug fix to a comparison key inside an existing, already-sandboxed shell check.

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 59s —— View job


Security Review

  • Gather context (PR diff, changed files)
  • Read scripts/check-changelog-parity.sh changes
  • Read scripts/check-changelog-parity.test.sh changes
  • Hunt for injection, logic, and trust-boundary issues
  • Post findings

Scope: both changed files — scripts/check-changelog-parity.sh (+30/-12) and scripts/check-changelog-parity.test.sh (+90/-0, test-only additions). No workflow files are touched, so the dangerous-trigger / permissions-widening / unpinned-action lanes (and anything zizmor already covers) don't apply here.

No security issues found.

Reasoning, focused on the production-code delta at scripts/check-changelog-parity.sh#L376-L461, which drops changelog_bracket_headings() + comm -23 in favor of changelog_versions + a head_documents associative array keyed on version:

  • Injection surface: the only attacker-influenced input is CHANGELOG.md content from a PR diff. Version tokens extracted by changelog_versions() and used as associative-array keys (head_documents["$v"]=1, L452) are constrained by its regex to [0-9A-Za-z.+-] — no shell metacharacters, no eval, nothing that reaches bash -c. $v is consistently double-quoted everywhere it's used, including in the failure message ("## [$v]", L458).
  • Path handling: git show "$merge_base:$changelog" (L448) — both operands quoted; $changelog is derived from a glob over the repo's own plugins/*/.claude-plugin/plugin.jsonplugin_dir/CHANGELOG.md, not from any PR-controlled string.
  • Fail-open edge case is pre-existing, not introduced here: git show ... 2>/dev/null on an unresolvable fork-point read still yields an empty fork_heading_versions (empty array → the loop body simply doesn't run), matching the same fail-open behavior the prior comm-based code had via its own 2>/dev/null process substitution. The PR description already calls this out as a known, deliberately out-of-scope issue tracked in check-changelog-parity: the #2290 absorption check false-positives on any heading-line edit ([YANKED], dates), and leaves the no-bump and convention-changelog surfaces unpoliced #2327 — no regression.
  • Array-reset correctness: head_documents=() is reset per manifest-loop iteration (L450, inside the for manifest in "${manifests[@]}" loop), so there's no cross-plugin state leakage that could mask one plugin's real deletion because another plugin happened to document the same version string.
  • set -u-safe empty-array handling: ${fork_heading_versions[@]+"${fork_heading_versions[@]}"} (L455) is the standard idiom to avoid an unbound-variable error on an empty array under set -u, correctly preserving word-splitting/quoting semantics for the non-empty case — not a vector for anything.
  • Test file: all additions are self-contained, running against throwaway local repos (mk_repo/git_init in a temp dir) — no network calls, no execution of PR-supplied content outside the sandboxed fixture repos, nothing touching secrets or credentials.

Nothing in this diff touches authorization, tokens/secrets, or CI trust boundaries — it's a pure bug-fix to a comparison key inside an already-sandboxed shell check, consistent with the earlier automated review on this PR.

@kyle-sexton
kyle-sexton merged commit bb817c6 into main Aug 12, 2026
36 of 37 checks passed
@kyle-sexton
kyle-sexton deleted the fix/2327-heading-line-false-positive branch August 12, 2026 04:45
@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 5m 9s —— View job


Code Review

  • Gather context (PR diff, changed files)
  • Read scripts/check-changelog-parity.sh changes
  • Read scripts/check-changelog-parity.test.sh changes
  • Check CLAUDE.md / REVIEW.md guidelines
  • Post review findings

Scope: both changed files, per the code-review lane (correctness, design, error handling, test coverage, maintainability). Security is out of scope here — this repo has a dedicated security lane that already reviewed this PR.

Summary: the core fix is correct. Reading through scripts/check-changelog-parity.sh:428-465 line by line:

  • The version-keyed comparison (changelog_versionshead_documents associative array) correctly replaces the old line-level comm -23 diff. Walked through each new test scenario (relabel, date-annotation, [YANKED], bracket↔unbracket reformat, unbracketed absorption) against the logic by hand — all five produce the expected pass/fail.
  • head_documents=() (not declare -A head_documents re-run) inside the per-manifest loop is the right call — re-running declare -A on an already-declared associative array in bash does not clear its contents, only arr=() does. The comment at check-changelog-parity.sh:376-377 correctly states this.
  • Array handling under set -uo pipefail (check-changelog-parity.sh:63) is sound: ${fork_heading_versions[@]+"${fork_heading_versions[@]}"} and the ((${#head_heading_versions[@]} > 0)) guard both correctly avoid an unbound-variable error on an empty array pre-bash-4.4.
  • Confirmed changelog_bracket_headings() has no other callers left in either file after its removal — no dangling references.
  • One inline note posted: the de-dup branch for a version repeated at the fork point (check-changelog-parity.sh:457) has no dedicated test among the five new cases — minor, non-blocking test-coverage gap.

Verification note: I was not able to independently run check-changelog-parity.test.sh or shellcheck against these files — this sandbox requires interactive approval for script execution that wasn't available in this session. My analysis above is from manual reading of the diff and the surrounding file, not from an executed test run. The PR description's own test-plan output (PASS=62 FAIL=0, shellcheck clean, the 59-commit replay sweep) is author-claimed and I have not verified it independently.

No correctness or design issues found beyond the one nit above.

Comment thread scripts/check-changelog-parity.sh
@github-actions

Copy link
Copy Markdown

Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count.

kyle-sexton added a commit that referenced this pull request Aug 12, 2026
…2370)

Fixes #2342

## Summary

- Add `--check-preserved <ref>` mode that checks every **touched**
changelog (plugin and convention) for dropped version headings vs the
fork point.
- Replace the in-loop absorbed-heading check inside `--check-bump`
(which only ran for bumped plugins) with this dedicated mode.
- Wire `--check-preserved` into the CI `changelog-parity-gate` job as an
additional PR-only step.
- Uses `git ls-tree` + command-substitution `git show` (fail-loud on git
errors).

## Test plan

- [x] `bash scripts/check-changelog-parity.test.sh` (76/0)
- Covers: changelog-only absorption, convention changelogs, relabelling,
[YANKED], heading reformat, stale-branch false-positive guard,
whole-file deletion, SIGPIPE/gawk fixture

## Related

- #2264 / #2290 — motivating absorption defect
- #2327 / #2341 — false-positive fixes for heading annotations
- #2324 — git fail-open hardening (overlapping fix, landed separately in
PR #2369)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
@kyle-sexton

kyle-sexton commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Independent verifier verdict — post-hoc, PR already merged as bb817c6c

Method: the script was copied unmodified out of git (git show bb817c6c:scripts/check-changelog-parity.sh, md5 d07b2404…, byte-identical to origin/main at eb9f3386) into throwaway repos under a temp dir. Every fixture was also run against the pre-fix script (git show bb817c6c^:…, md5 c11f74c3…) as a positive control, so an EXIT=0 is distinguishable from "the fixture never reached the check". Every fixture commits the manifest bump 1.0.0 → 1.0.1 and adds a well-formed ## [1.0.1] at head, so bumped_candidate is populated and no other --check-bump check is the variable. Platform: Cygwin bash 5.3.9(1), GNU grep/awk/comm.


ITEM 1: CONFIRMED — both false positives are gone, and the same fixtures still red-line the pre-fix script.

fixture (head heading for the preserved 1.0.0) shipped bb817c6c pre-fix bb817c6c^
(a) ## [1.0.0] - 2026-01-01 [YANKED] EXIT=0 Every plugin whose version changed vs fixture-base has a '## [<version>]' CHANGELOG.md entry. EXIT=1 ABSORBED CHANGELOG HEADING: demo lost release section heading(s) … ## [1.0.0]
(b) ## [1.0.0] - 2026-01-01 EXIT=0 (same message) EXIT=1 (same ABSORBED … ## [1.0.0])

ITEM 2: CONFIRMED — the gate still discriminates.

  • Head deletes ## [1.0.0] and folds its body into ## [1.0.1]EXIT=1, ABSORBED CHANGELOG HEADING: demo lost release section heading(s) vs the fork point (a merge-forward may have fused two releases into one section): / ## [1.0.0] / Restore every '## [<version>]' heading that existed at the fork point; …
  • Relabel ## [1.0.0]## [1.0.2] (heading count unchanged) → EXIT=1, same message naming ## [1.0.0].
  • Cross-plugin state was also checked, since every new test in fix(scripts): key the absorbed-heading check on the version, not the line (#2327) #2341 is single-plugin and head_documents is declare -A'd once outside the manifest loop. a=() clears an associative array cleanly on this bash (count=0 x=unset), and a two-plugin change set (keeper preserves 1.0.0, absorber deletes 1.0.0, both manifests bumped) reports the absorber only, in both glob orderings. Non-finding.

ITEM 3: REFUTED — two live misbehaviours on the inline-linked heading form (plus two pre-existing symptoms of the same blind spot on the sibling gates). Seventeen fixtures; the inline-linked form is the only one that misbehaves.

Behaves correctly (post-fix): ## [Unreleased] added (EXIT=0) and deleted (EXIT=0) — it carries no version, so it is invisible to changelog_versions; state that as a coverage boundary, not a pass. Trailing whitespace (two spaces + tab) on a preserved heading → EXIT=0 (pre-fix: EXIT=1 — a third false positive #2341 fixed but did not name). Reference-link definitions [1.0.0]: https://… present on both sides → EXIT=0; and the discriminating case — heading ## [1.0.0] deleted while [1.0.0]: https://… is kept — still EXIT=1 ABSORBED, so a link definition can neither satisfy nor trigger the check. CRLF throughout, heading preserved+annotated → EXIT=0 (pre-fix EXIT=1); CRLF with the heading absorbed → EXIT=1. Unbracketed ## 1.0.0 - 2025-12-01 preserved → EXIT=0, absorbed → EXIT=1 (pre-fix EXIT=0 — new coverage, as the PR claims). Two-component ## 0.9 absorbed → EXIT=1 naming ## [0.9].

The two that misbehave, both on ## [<v>](<url>) - <date> — the inline-linked heading emitted by conventional-changelog / release-please tooling (verified against googleapis/release-please's own CHANGELOG.md, whose top heading is ## [17.11.1](https://github.com/googleapis/release-please/compare/v17.11.0...v17.11.1) (2026-07-31); Keep a Changelog's own reference changelog uses the plain form, so this is a widespread in-the-wild variant, not a KAC-prescribed one):

  • FALSE NEGATIVE (a coverage regression introduced by this PR). Fork has ## [1.0.0](https://example.com/compare/v0.9.0...v1.0.0) - 2017-06-20; head deletes it entirely and folds the body into ## [1.0.1]. Shipped script: EXIT=0 — a genuine absorption passes. Pre-fix script: EXIT=1 ABSORBED …. fix(scripts): reject absorbed changelog release headings (#2264) #2290's matcher ^## \[<ver>\] was a prefix match and saw linked headings; changelog_versions' …\]?([[:space:]]|$) trailing anchor does not, because ( follows the ]. Keying on the version was right; routing through this extractor silently narrowed what the check can see.
  • FALSE POSITIVE (the same class fix(scripts): key the absorbed-heading check on the version, not the line (#2327) #2341 set out to kill, still live). Fork has plain ## [1.0.0]; head preserves the release and reformats that heading to ## [1.0.0](https://example.com/compare/v0.9.0...v1.0.0) - 2017-06-20. Shipped script: EXIT=1 ABSORBED CHANGELOG HEADING … ## [1.0.0]. Nothing was absorbed. Adding compare links to an existing changelog is ordinary housekeeping, and it red-lines a required gate.

Reachability was checked on all three gates, not just --check-bump, because changelog_versions is shared by all of them. The form is banned by none of them.

fixture result
--check-bump: head documents the bumped version as ## [1.0.1](…) - 2026-01-01 EXIT=0 — accepted, no CHANGELOG FORMAT failure
--check: newest heading linked ## [1.0.1](…), manifest 1.0.1 EXIT=0
--check: newest heading plain ## [1.0.1], manifest 1.0.0 (control) EXIT=1 CHANGELOG AHEAD OF MANIFEST: … documents 1.0.1 but … carries 1.0.0.
--check: same violation, heading linked ## [1.0.1](…), manifest 1.0.0 EXIT=0 — the violation is invisible
--check-order: ## [1.0.0] / ## [2.0.0](…) / ## [0.9.0] EXIT=0
--check-order: same order, all plain (control) EXIT=1 MISORDERED CHANGELOG: … 2.0.0 (below 1.0.0)

So --check-bump blesses a heading form at introduction that its own preservation check cannot see afterwards — an internal inconsistency between two checks in the same script, independent of any external spec — and the other two gates admit it too. Two of those rows are additional symptoms, and they are pre-existing, not #2341's doing: --check and --check-order always read through changelog_versions, so a linked heading has always been able to sit above its manifest or out of order unseen. What #2341 changed is that preservation now shares that blindness. They belong in the same fix, but the blame for them does not sit on this PR.

Why no sweep could have caught this. grep -rEn '^## \[[0-9]+\.[0-9]+(\.[0-9]+)?\]\(' plugins/*/CHANGELOG.md docs/conventions/*/CHANGELOG.md0 hits. The corpus contains no linked heading, so a corpus sweep is quiet on it — the identical blind spot that let #2290 ship. The only two ^## lines in the whole corpus the extractor drops are plugins/repo-hygiene/CHANGELOG.md: ## [Unreleased] and docs/conventions/config-cascade/CHANGELOG.md: ## Renamed — 2026-07-23; both were examined and neither is a version heading, so dropping them is correct.

ITEM 4: CONFIRMED — both gaps are real, unchanged by #2341, deliberate, and tracked. The 04822fc4 instance was verified from primary source, not taken on the PR's word: git diff --name-only 04822fc4^1 04822fc4 lists .github/workflows/ci.yml, plugins/docs-hygiene/CHANGELOG.md, scripts/check-changelog-parity.sh, scripts/check-changelog-parity.test.shno manifest; ## [0.9.7] is present at 04822fc4^1 and absent at 04822fc4. Replayed in a fresh clone (checkout 04822fc4, overwrite only the gate script with the bb817c6c version — safe, since bumped_candidate reads committed state via git diff, not the worktree), --check-bump 04822fc4^1EXIT=0. A real deletion, still unpoliced. Gap (b) likewise: manifests=(plugins/*/.claude-plugin/plugin.json), so docs/conventions/*/CHANGELOG.md is structurally outside --check-bump; a fixture where a plugin bumps cleanly and docs/conventions/foo/CHANGELOG.md absorbs ## 1.0 and ## 1.1EXIT=0. Judgement: deliberate, not a defect of this PR. Open issue #2342 covers both by name (item 1 cites 04822fc4 verbatim; item 2 covers convention changelogs) plus the fail-open fork-point read. Deliberate-and-tracked.

ITEM 5: CONFIRMED — zero false positives on the live corpus, with numbers.

Static, tree 9fe90860: 65 plugin changelogs, 11 convention changelogs, 65 plugin manifests. --check → EXIT=0. --check-order → EXIT=0, All 76 changelog(s) read newest-first with no duplicate versions.

Replay, selection rule verbatim git log --format=%H -n 100 origin/main -- 'plugins/*/CHANGELOG.md' (tip 9fe90860), each commit C replayed as its own PR (tree at C, base C^1) in a throwaway clone with the gate script replaced by an instrumented copy of the bb817c6c version (one added echo reporting per-plugin fork/head heading counts; bash -n clean):

commits selected 100
replayed 100
skipped 0
clean (exit 0) 100
fired (exit ≠ 0) 0
of which ABSORBED CHANGELOG HEADING 0
in-scope plugin-changelog comparisons 278
fork-point headings compared (sum) 10233

Caveat carried forward from the PR body: replaying C vs C^1 is not what CI saw at merge time, so fires from --check-bump's other checks would be artifacts — but for the absorbed-heading check C^1 is an ancestor of C, so fork point and parent coincide and these numbers are exact. And per Item 3: a zero here proves the check is quiet on this corpus, not correct on the format.


VERDICT: DEFECT FOUND#2341 fixes the two false positives it targeted (plus an unnamed third on trailing whitespace) and does not go soft, but by routing the check through changelog_versions it made inline-linked headings ## [<v>](<url>) - <date> invisible to preservation: absorbing one now passes where the pre-fix script failed it, and reformatting a plain heading into that form false-positives on a required gate — a form this same gate accepts for the bumped version.

Suggested remediation (not a revert — #2341 is a net improvement). The two obvious fixes are not equivalent, and the tension should be decided rather than papered over:

  • Relax changelog_versions' trailing anchor so a version is recognised when followed by (. This is the one I'd take: it keeps the single shared reader the file's own header insists on ("Shared by --check-order and --check so the two directions of the parity pair cannot read a changelog differently"), and it fixes all four symptoms at once — the FN, the FP, CHANGELOG AHEAD OF MANIFEST, and MISORDERED CHANGELOG. It does change behaviour for all three consumers, so it needs tests on each.
  • Give preservation its own extractor. Cheaper to reason about in isolation, but it re-creates the second reader fix(scripts): key the absorbed-heading check on the version, not the line (#2327) #2341 explicitly deleted and leaves --check / --check-order still blind. It also contradicts the design note this PR quoted as its own justification.

Either way the fix needs a test on the linked form in both directions, since the corpus cannot exercise it. Best folded into #2342's "preservation as its own diff mode" work rather than patching the anchor twice.

SCOPE — what I did NOT examine: the 90 lines added to scripts/check-changelog-parity.test.sh (I neither read the new cases nor ran the suite — my fixtures are independent of it); shellcheck / check-shell-portability.sh / the conflict-marker sweep; the CI workflow wiring and required-context names; --check and --check-order semantics beyond the linked-heading fixtures above and one corpus run each; #2342 item 3 (the fail-open 2>/dev/null fork-point read); duplicate-version fork headings (presence-keying means deleting one of two identical ## [1.0.0] sections would pass — --check-order already rejects duplicates, so I did not pursue it); PR review threads and CI history. Platform delta: everything above ran on Cygwin bash 5.3.9(1) with GNU grep/awk/comm; CI runs Linux with different builds, and declare -A reset semantics, mapfile, [[:space:]], and CRLF handling are all places that can diverge. The two Item 3 findings are regex-level and platform-independent; the cross-plugin non-finding is the one most worth re-confirming on the CI image.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant