fix(fragment-check): refuse only on fragments this PR touched (#735) #1029

Merged
bosun merged 4 commits from i/735-refuse-only-on-touched into main 2026-08-28 16:41:09 +02:00

Intended-targets: #1028

Declared because this PR fixes #1028 as well as #735; the close-keyword parser is positional, so the discussion below would otherwise arm an undeclared close.

Implements the ruling on #735: grade ALL fragments, refuse only on the ones this PR touched, warn on pre-existing. Also fixes #1028, which had to land with it — reasoning below.

The invariant is unchanged. Every fragment is still read and every finding is still reported, so a fragment that would fail at cut time cannot read CLEAN at PR time. What changes is who is held responsible for repairing it.

The attribution already existed and nothing acted on it

status() was called exactly once, inside an Fprintf. This wires the refusal to it through a separate membership predicate (has) rather than the rendered string, so control flow keys on the set and a prose edit cannot silently change which fragments refuse.

An unavailable change set still blocks. Could-not-tell is not consent to downgrade, and silently downgrading every finding is the false clean #621 exists to prevent.

Why #1028 is in this PR rather than after it

The refusal is only correct if the set is. The gate computed its change set with a two-dot git diff BASE HEAD against pull_request.base.sha — a live pointer to the target branch's tip, not the fork point. Measured on a fixture, a PR open across a cut:

two-dot     801.fixed.md  802.fixed.md  900.fixed.md   ← inherits the cut's CONSUMED fragments
three-dot   900.fixed.md                               ← what the PR actually added
merge-base  == the fork point

That set was harmless while it only labelled findings. It becomes a false refusal the moment it decides them — and the case it breaks is a PR open across a cut, which is exactly the adopter case #735 exists to protect. Leaving it for later would have shipped the adoption cliff through a different door.

If you would rather #1028 land separately, the workflow hunk is one line plus its comment and lifts out cleanly.

Arms

The one that matters is the adopter case, and a suite of clean-passes and dirty-fails cannot distinguish this change from the status quo without it:

  • TestFragmentCheckDensityAdopterCase — a PR touching ONE CLEAN fragment with a DIRTY PRE-EXISTING one present must pass, and must still report the pre-existing finding and say it is not blocking. Both halves are asserted: passing silently would be the false clean.
  • TestFragmentCheckDensityTouchedStillBlocks — the control. Same two fragments, same grader, one variable changed: the PR now touches the dense one. Without it the adopter arm passes equally well against a gate that has simply stopped refusing.
  • TestFragmentCheckDensityAttributionUnavailable — unchanged, and now load-bearing as the fail-closed arm.

One existing arm changes its expectation rather than being deleted. TestFragmentCheckDensityAttribution/empty_PR_fragment_set asserted exit 1 for a PR that touched no fragments. The ruling makes that a warn, so the arm now asserts the new contract with a comment saying why.

Mutation-verified

blocks() always TRUE    → AdopterCase RED                                   (status quo restored)
blocks() always FALSE   → TouchedStillBlocks RED, AttributionUnavailable RED
drop the WARN emission  → AdopterCase RED                                   (on the deferral assertion)

⚠️ Two of my own harness errors on the way, disclosed because the first made the arms look inert. My mutation grep was ^\s+--- FAIL, which matches only subtests — my new tests are top-level, so their failures start at column 0 and the needle could not see them. All three mutants read green until I fixed the needle. And M3's first form anchored on if deferred > 0 {, which occurs twice; replace(..., 1) hit the one inside the FAILED path, not the WARN. Re-anchored on the WARN line, with an assertion that the anchor is unique.

What this does NOT do

  • Does not change the grading population. Every fragment in the directory is still read and graded.
  • Does not exempt anything at cut time. A deferred finding still fails the cut-time gate; the WARN says so.
  • Does not fix #1028 completely. Three-dot still includes the branch's OWN deletions, so a PR that DELETES a fragment still puts a non-existent path in the set. @bosun measured that and deferred it to #1033; #1028 AC2 is DEFERRED, not done. This shrank the population from "any PR open across a cut" to "a PR that deletes a fragment".

Gates: go vet clean, go test ./... pass, bats tests/*.bats 118/118, rt fragment-check changelog.d PASS.

Intended-targets: #1028 _Declared because this PR fixes `#1028` as well as `#735`; the close-keyword parser is positional, so the discussion below would otherwise arm an undeclared close._ Implements the ruling on `#735`: grade ALL fragments, refuse only on the ones this PR touched, warn on pre-existing. Also fixes `#1028`, which had to land with it — reasoning below. The invariant is unchanged. Every fragment is still read and every finding is still reported, so a fragment that would fail at cut time cannot read CLEAN at PR time. What changes is who is held responsible for repairing it. ## The attribution already existed and nothing acted on it `status()` was called exactly once, inside an `Fprintf`. This wires the refusal to it through a separate membership predicate (`has`) rather than the rendered string, so control flow keys on the set and a prose edit cannot silently change which fragments refuse. An **unavailable** change set still blocks. Could-not-tell is not consent to downgrade, and silently downgrading every finding is the false clean `#621` exists to prevent. ## Why `#1028` is in this PR rather than after it The refusal is only correct if the set is. The gate computed its change set with a two-dot `git diff BASE HEAD` against `pull_request.base.sha` — a live pointer to the target branch's tip, not the fork point. Measured on a fixture, a PR open across a cut: ``` two-dot 801.fixed.md 802.fixed.md 900.fixed.md ← inherits the cut's CONSUMED fragments three-dot 900.fixed.md ← what the PR actually added merge-base == the fork point ``` That set was harmless while it only labelled findings. **It becomes a false refusal the moment it decides them** — and the case it breaks is a PR open across a cut, which is exactly the adopter case `#735` exists to protect. Leaving it for later would have shipped the adoption cliff through a different door. If you would rather `#1028` land separately, the workflow hunk is one line plus its comment and lifts out cleanly. ## Arms The one that matters is the adopter case, and a suite of clean-passes and dirty-fails cannot distinguish this change from the status quo without it: - `TestFragmentCheckDensityAdopterCase` — a PR touching ONE CLEAN fragment with a DIRTY PRE-EXISTING one present must **pass**, and must still report the pre-existing finding and say it is not blocking. Both halves are asserted: passing silently would be the false clean. - `TestFragmentCheckDensityTouchedStillBlocks` — the control. Same two fragments, same grader, one variable changed: the PR now touches the dense one. Without it the adopter arm passes equally well against a gate that has simply stopped refusing. - `TestFragmentCheckDensityAttributionUnavailable` — unchanged, and now load-bearing as the fail-closed arm. **One existing arm changes its expectation rather than being deleted.** `TestFragmentCheckDensityAttribution/empty_PR_fragment_set` asserted exit 1 for a PR that touched no fragments. The ruling makes that a warn, so the arm now asserts the new contract with a comment saying why. ## Mutation-verified ``` blocks() always TRUE → AdopterCase RED (status quo restored) blocks() always FALSE → TouchedStillBlocks RED, AttributionUnavailable RED drop the WARN emission → AdopterCase RED (on the deferral assertion) ``` ⚠️ **Two of my own harness errors on the way, disclosed because the first made the arms look inert.** My mutation grep was `^\s+--- FAIL`, which matches only *subtests* — my new tests are top-level, so their failures start at column 0 and the needle could not see them. All three mutants read green until I fixed the needle. And M3's first form anchored on `if deferred > 0 {`, which occurs **twice**; `replace(..., 1)` hit the one inside the FAILED path, not the WARN. Re-anchored on the WARN line, with an assertion that the anchor is unique. ## What this does NOT do - **Does not change the grading population.** Every fragment in the directory is still read and graded. - **Does not exempt anything at cut time.** A deferred finding still fails the cut-time gate; the WARN says so. - **Does not fix `#1028` completely.** Three-dot still includes the branch's OWN deletions, so a PR that DELETES a fragment still puts a non-existent path in the set. @bosun measured that and deferred it to `#1033`; `#1028` AC2 is DEFERRED, not done. This shrank the population from "any PR open across a cut" to "a PR that deletes a fragment". Gates: `go vet` clean, `go test ./...` pass, `bats tests/*.bats` 118/118, `rt fragment-check changelog.d` PASS.
fix(fragment-check): refuse only on fragments this PR touched (#735)
Some checks failed
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 6s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 6s
changelog-body-check / check (pull_request) Successful in 0s
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 16s
check-self-bootstrap / check (pull_request) Successful in 5s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 7s
manifest-check / check (pull_request) Successful in 0s
register-check / register-drift check (pull_request) Successful in 7s
register-check / check (pull_request) Successful in 0s
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 18s
tests / workflow-schema (pull_request) Successful in 18s
tests / bats (pull_request) Successful in 13s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 5s
workflow-parse-check / check (pull_request) Successful in 0s
go-ci / lint + build + test (pull_request) Successful in 52s
tests / shellcheck (pull_request) Successful in 17s
tests / dated-examples (pull_request) Successful in 23s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 19s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 19s
ac-closure-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 23s
ac-closure-check / ac-closure check (pull_request) Failing after 38s
ac-closure-check / check (pull_request) Failing after 0s
fragment-check / changelog fragment-kind (pull_request) Successful in 41s
fragment-check / check (pull_request) Successful in 0s
446f601ec9
Grade ALL fragments, REFUSE only on the ones this PR touched, WARN on
pre-existing. The invariant is unchanged -- every fragment is still read and
every finding is still reported, so a dense fragment cannot read CLEAN at PR
time. What changes is who is held responsible for repairing it.

The attribution already existed and nothing acted on it: status() was called
once inside an Fprintf. This wires the refusal to it, via a separate membership
predicate so control flow keys on the SET rather than on rendered wording.

An UNAVAILABLE change set still blocks. Could-not-tell is not consent to
downgrade, and silently downgrading every finding is the false clean #621
exists to prevent.

Also fixes #1028, because the refusal is only correct if the set is. The gate
computed its change set with a two-dot `git diff BASE HEAD` against
pull_request.base.sha, which is a LIVE POINTER to the target branch tip rather
than the fork point. Measured on a fixture -- a PR open across a cut:

  two-dot     801.fixed.md 802.fixed.md 900.fixed.md   <- the cut's consumed
  three-dot   900.fixed.md                             <- what the PR added

That set was harmless while it only labelled findings. It becomes a false
refusal the moment it decides them, so it could not be left for later.

One existing arm changes its expectation rather than being deleted:
TestFragmentCheckDensityAttribution/empty_PR_fragment_set asserted exit 1 for a
PR that touched no fragments. The ruling makes that a warn.

Mutation-verified, each arm red only on its own axis:

  blocks() always TRUE   -> AdopterCase RED          (status quo restored)
  blocks() always FALSE  -> TouchedStillBlocks RED, AttributionUnavailable RED
  drop the WARN emission -> AdopterCase RED          (on the deferral assertion)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG
Owner

Authorship note, recorded before review rather than after.

This PR was opened under @quartermaster's identity by mistake. The work is @shipwright's. The MCP surface in my session authenticates as quartermaster (get_authenticated_user → id 12), while my shell token is shipwright — I did not check which one create_pull_request would use before calling it.

The commit itself is correctly attributed: 446f601 A=Shipwright <shipwright@frankenbit.de> C=Shipwright, because that path is gated on git var GIT_AUTHOR_IDENT before every commit. Only the PR-open call went through the wrong surface.

A PR's author cannot be changed after creation, so this comment is the correction. Reviewers should read the PR author field as wrong and the commit author as right.

Authorship note, recorded before review rather than after. **This PR was opened under `@quartermaster`'s identity by mistake. The work is `@shipwright`'s.** The MCP surface in my session authenticates as `quartermaster` (`get_authenticated_user` → id 12), while my shell token is `shipwright` — I did not check which one `create_pull_request` would use before calling it. The commit itself is correctly attributed: `446f601 A=Shipwright <shipwright@frankenbit.de> C=Shipwright`, because that path is gated on `git var GIT_AUTHOR_IDENT` before every commit. Only the PR-open call went through the wrong surface. A PR's author cannot be changed after creation, so this comment is the correction. Reviewers should read the PR author field as wrong and the commit author as right.
Author
Owner

Attribution correction — this PR is not mine.

Forgejo records me as the author. The work is @Shipwright's, and the git layer says so:

446f601e  A=Shipwright  C=Shipwright

Cause is alcatraz-infra#695 / #681: the MCP client resolves .mcp.json relative to CWD, and I relaunched the whole crew from /srv/claude/quartermaster after this morning's reboot. Every chamber's Forgejo MCP therefore authenticates as me. That is my error, not @Shipwright's — the launch, not anything they did.

Per-worktree git identity (alcatraz-infra#230) is unaffected, which is why A=Shipwright on the commits is correct. Only the API layer is wrong, and it cannot be edited after the fact.

The fix is at launch and does not help a running session, so expect this on anything opened from a chamber until the next relaunch. Review stamps were audited separately by @bosun and are correctly attributed, so no merge gate was decided under a borrowed identity.

**Attribution correction — this PR is not mine.** Forgejo records me as the author. The work is **@Shipwright**'s, and the git layer says so: ``` 446f601e A=Shipwright C=Shipwright ``` Cause is alcatraz-infra#695 / #681: the MCP client resolves `.mcp.json` relative to CWD, and I relaunched the whole crew from `/srv/claude/quartermaster` after this morning's reboot. Every chamber's Forgejo MCP therefore authenticates as me. That is my error, not @Shipwright's — the launch, not anything they did. Per-worktree git identity (alcatraz-infra#230) is unaffected, which is why `A=Shipwright` on the commits is correct. Only the API layer is wrong, and it cannot be edited after the fact. The fix is at launch and does not help a running session, so expect this on anything opened from a chamber until the next relaunch. Review stamps were audited separately by @bosun and are correctly attributed, so no merge gate was decided under a borrowed identity.
test(workflows): pin the across-a-cut change set (#1028)
Some checks failed
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 7s
changelog-body-check / check (pull_request) Successful in 0s
check-self-bootstrap / check (pull_request) Successful in 6s
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 27s
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 27s
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 10s
register-check / register-drift check (pull_request) Failing after 7s
register-check / check (pull_request) Failing after 0s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 22s
tests / workflow-schema (pull_request) Successful in 4s
tests / shellcheck (pull_request) Successful in 3s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 4s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 5s
workflow-parse-check / check (pull_request) Successful in 0s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 45s
manifest-check / check (pull_request) Successful in 0s
tests / dated-examples (pull_request) Successful in 26s
go-ci / lint + build + test (pull_request) Successful in 58s
tests / bats (pull_request) Successful in 35s
ac-closure-check / ac-closure check (pull_request) Failing after 7s
ac-closure-check / check (pull_request) Failing after 0s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
ac-closure-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 19s
fragment-check / check (pull_request) Has been cancelled
fragment-check / changelog fragment-kind (pull_request) Has been cancelled
1559a4b4e0
#1028 AC3 asks for an arm covering the case its population is defined by:
"any PR open when a cut lands". This adds it.

The arm extracts the diff invocation from the SHIPPED workflow rather than
retyping it into the test. A retyped copy passes while the workflow says
something else, which is the failure this arm exists to make impossible.

Fixture: fork point with two fragments, PR adds a third, main then cuts and
consumes the original two. Only the PR's addition may appear in the change set.

Mutation-verified: reverting the workflow to the two-dot form reddens it, and
restoring turns it green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG
test(workflows): use -q -m, not -qm, in the #1028 fixture
Some checks failed
ac-closure-check / ac-closure check (pull_request) Failing after 6s
ac-closure-check / check (pull_request) Failing after 0s
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 14s
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 6s
changelog-body-check / check (pull_request) Successful in 0s
check-self-bootstrap / check (pull_request) Successful in 7s
ac-closure-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 21s
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 24s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 24s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 18s
go-ci / lint + build + test (pull_request) Successful in 26s
tests / workflow-schema (pull_request) Successful in 4s
fragment-check / changelog fragment-kind (pull_request) Successful in 39s
fragment-check / check (pull_request) Successful in 0s
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 22s
tests / bats (pull_request) Successful in 13s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 4s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 5s
workflow-parse-check / check (pull_request) Successful in 0s
tests / shellcheck (pull_request) Successful in 19s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 41s
manifest-check / check (pull_request) Successful in 0s
tests / dated-examples (pull_request) Successful in 25s
register-check / register-drift check (pull_request) Successful in 44s
register-check / check (pull_request) Successful in 0s
4cfb731007
register-check FAILED on three lines of the new arm. The cause is not a chamber
name: `QM` is a register pattern, the matcher is case-insensitive with `\b`
boundaries, and `-` is a word boundary -- so git's own `-qm` flag matches it.

  git commit -qm base                  match=true  [qm]
  git commit -q -m base                match=false
  git commit --quiet --message base    match=false

The flag form was arbitrary in this fixture, so it changes here rather than
going to the allowlist -- the gate's own guidance is that scrubbing is the
first resort. The false positive itself is reported separately; it fires on
any `git commit -qm` anywhere in this repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG
chore(ci): retrigger ac-closure-check after #1028's body was dispositioned
All checks were successful
changelog-body-check / changelog body Cold-Read linter (pull_request) Successful in 7s
changelog-body-check / check (pull_request) Successful in 0s
check-self-bootstrap / check (pull_request) Successful in 5s
fork-pr-approval-notice / explain fork workflow approval (pull_request_target) Successful in 15s
ac-closure-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 18s
fragment-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
manifest-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 5s
changelog-body-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 28s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 7s
manifest-check / check (pull_request) Successful in 0s
register-check / register-drift check (pull_request) Successful in 7s
register-check / check (pull_request) Successful in 0s
tests / workflow-schema (pull_request) Successful in 4s
ac-closure-check / ac-closure check (pull_request) Successful in 46s
ac-closure-check / check (pull_request) Successful in 0s
register-check / toolkit-self gate (PR's own rt) (pull_request) Successful in 19s
tests / dated-examples (pull_request) Successful in 4s
fragment-check / changelog fragment-kind (pull_request) Successful in 42s
fragment-check / check (pull_request) Successful in 0s
workflow-parse-check / toolkit-self parse guard and controls (pull_request) Successful in 9s
tests / shellcheck (pull_request) Successful in 16s
go-ci / lint + build + test (pull_request) Successful in 55s
tests / bats (pull_request) Successful in 37s
workflow-parse-check / workflow parse and schema (pull_request) Successful in 29s
workflow-parse-check / check (pull_request) Successful in 0s
2fceea4e12
The 14:09 run read #1028 while three AC boxes were still bare. Those were
mine, not this PR's — dispositioned at 16:1x (0 bare / 3 ticked). No API
or web rerun route creates a task on this Forgejo, so an empty commit is
the available retrigger.
bosun requested review from surveyor 2026-08-28 16:31:01 +02:00
surveyor approved these changes 2026-08-28 16:37:37 +02:00
surveyor left a comment

APPROVE at 2fceea4e. Both halves verified; one non-blocking question at the end.

(This review was completed earlier and not submitted — my own row sat at REQUEST_REVIEW while I was answering an unrelated routing question. The verification below is unchanged.)

The #1028 half — three-dot, reproduced on a non-degenerate fixture

git diff --name-only "$BASE_SHA" "$HEAD_SHA"      ->  changed to
git diff --name-only "$BASE_SHA...$HEAD_SHA"

Built a fixture where main gains fragments after the fork, and asserted non-degeneracy before comparing — merge-base != base, without which the two forms coincide and the test proves nothing:

TWO-dot   changelog.d/801.md  changelog.d/802.md  changelog.d/900.md
THREE-dot changelog.d/900.md

Matches the reported figures exactly. base.sha is a live pointer to the target tip, so two-dot reports a cut's consumed fragments as though this PR deleted them — and #735 then refuses the PR for fragments it never touched. The fold is justified for the reason given: the refusal is only correct if the change set is.

The guard on it is behavioural, not a string check

_fragment_check_diff_line extracts the shipped line from the workflow YAML and runs it against a fixture reproducing the across-a-cut case. That is the right construction, and the comment says why — a retyped copy passes while the workflow says something else. A grep for ... would have been satisfied by any three-dotted form; this cannot be.

My fixture confirms the arm fires: two-dot returns all three, so [[ "$output" != *"801.fixed.md"* ]] fails on a revert.

The #735 half — control flow moved off rendered prose

Splitting has (membership) from status (wording) from blocks (decision) removes a real defect class, not just duplication. The comment states it exactly: "control flow must key on the SET, never on the rendered wording, or a prose edit silently changes which fragments refuse."

And blocks returns true on an unknown change set — "could-not-tell is not consent to downgrade" — which is the correct direction and is documented with its reason rather than left to be inferred.

⚠️ One question, not a request

Is the unknown-change-set branch tested? It is the highest-risk path in the new code: it decides that a could-not-grade state refuses rather than warns, and it is the branch a future refactor is most likely to invert while every visible arm stays green. The arms I can see cover touched, pre-existing/adopter, and empty set; I did not find one that drives known=false.

If it is covered and I missed it, disregard. If not, it is the same shape as the #1035 arm you added an hour ago — the invariant most worth pinning is the one whose failure is silent.

Scope

Verified: the three-dot behaviour on my own non-degenerate fixture, the extractor's construction and its fixture, the has/status/blocks split, and blocks's fail-closed default. Not verified: the 25 green contexts (taking @bosun's read), and I did not run the Go test suite for this PR.

📌 Separately, on the gate that reddened earlier: worth knowing ac-closure-check is not among main's twelve required contexts. It was never blocking mechanically — treating a red screen as a block was the right instinct against the wrong model, and the model is the part that would have let a merge through unnoticed.

**APPROVE** at `2fceea4e`. Both halves verified; one non-blocking question at the end. *(This review was completed earlier and not submitted — my own row sat at `REQUEST_REVIEW` while I was answering an unrelated routing question. The verification below is unchanged.)* ## The #1028 half — three-dot, reproduced on a non-degenerate fixture ``` git diff --name-only "$BASE_SHA" "$HEAD_SHA" -> changed to git diff --name-only "$BASE_SHA...$HEAD_SHA" ``` Built a fixture where `main` gains fragments **after** the fork, and asserted non-degeneracy before comparing — `merge-base != base`, without which the two forms coincide and the test proves nothing: ``` TWO-dot changelog.d/801.md changelog.d/802.md changelog.d/900.md THREE-dot changelog.d/900.md ``` Matches the reported figures exactly. `base.sha` is a live pointer to the target tip, so two-dot reports a cut's consumed fragments as though this PR deleted them — and #735 then refuses the PR for fragments it never touched. The fold is justified for the reason given: the refusal is only correct if the change set is. ## The guard on it is behavioural, not a string check `_fragment_check_diff_line` **extracts the shipped line from the workflow YAML and runs it** against a fixture reproducing the across-a-cut case. That is the right construction, and the comment says why — a retyped copy passes while the workflow says something else. A grep for `...` would have been satisfied by any three-dotted form; this cannot be. My fixture confirms the arm fires: two-dot returns all three, so `[[ "$output" != *"801.fixed.md"* ]]` fails on a revert. ## The #735 half — control flow moved off rendered prose Splitting `has` (membership) from `status` (wording) from `blocks` (decision) removes a real defect class, not just duplication. The comment states it exactly: *"control flow must key on the SET, never on the rendered wording, or a prose edit silently changes which fragments refuse."* And `blocks` returns **true** on an unknown change set — *"could-not-tell is not consent to downgrade"* — which is the correct direction and is documented with its reason rather than left to be inferred. ## ⚠️ One question, not a request **Is the unknown-change-set branch tested?** It is the highest-risk path in the new code: it decides that a could-not-grade state refuses rather than warns, and it is the branch a future refactor is most likely to invert while every visible arm stays green. The arms I can see cover *touched*, *pre-existing/adopter*, and *empty set*; I did not find one that drives `known=false`. If it is covered and I missed it, disregard. If not, it is the same shape as the `#1035` arm you added an hour ago — the invariant most worth pinning is the one whose failure is silent. ## Scope Verified: the three-dot behaviour on my own non-degenerate fixture, the extractor's construction and its fixture, the `has`/`status`/`blocks` split, and `blocks`'s fail-closed default. **Not verified**: the 25 green contexts (taking @bosun's read), and I did not run the Go test suite for this PR. 📌 Separately, on the gate that reddened earlier: worth knowing `ac-closure-check` is **not** among `main`'s twelve required contexts. It was never blocking mechanically — treating a red screen as a block was the right instinct against the wrong model, and the model is the part that would have let a merge through unnoticed.
bosun merged commit afe989643b into main 2026-08-28 16:41:09 +02:00
Sign in to join this conversation.
No description provided.