feat(ac-closure-check): scan commit messages, refuse an undeclared target #977
No reviewers
Labels
No labels
bump
major
bump
minor
bump
patch
kind/bug
kind/chore
kind/docs
kind/feature
priority/critical
priority/high
priority/low
priority/medium
size/L
size/M
size/S
size/XL
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
frankenbit/release-toolkit!977
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "i/965-commit-message-close-keywords"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Refs
frankenbit/release-toolkit#965. All six ACs.Intended-targets: none — this PR declares no closes.
Two surfaces, and which one lands depends on the merge style
default_merge_stylehere is rebase, so the gate read only the surface the default style does not use.🔑 AC6 is the one the other five miss
They all key on the target having an unfinished AC. The silent close happens precisely when it does not:
Targets are now diffed against an explicit declaration, and an undeclared target is refused whatever its AC state.
Intent cannot be read from prose. The parser is positional, so a negated keyword followed by a bare number is a target — prose can neither declare nor disclaim, in either direction. That is why the declaration has to be a separate machine-readable form.
🔴 The first draft of that form was itself a close keyword
the trailer name
Intended-closes:followed by a bare number matchescloseKeywordRE—\bmatches after the hyphen. In Forgejo that line would have fired the very close it claimed to declare.It also masked a mutation: removing the commit-surface scan stayed green, because the trailer supplied the target on its own. Renamed to
Intended-targets:, which contains no keyword stem.Caught by the mutation, not by reading it. I wrote a declaration mechanism that did the thing it was declaring — inside the tracker about exactly that.
Verification
Occurrence-asserted and refusing unless exactly one match.
⚠️ An earlier attempt at the first mutation was INERT — it left
addAllunused, so the package failed to compile and reported no failing arm. A build failure and a passing suite are not the same thing, and a grep for--- FAILcannot tell them apart. Re-run in a compilable form, which is when the trailer defect surfaced.⚠️ And
-run 'ACClosure_965'matched nothing —Checksits between those tokens — so a filtered run reported success having executed no arm. Re-run with-run '965'; six subtests confirmed by name.Migration cost, measured before choosing the design
Zero open PRs are affected. The control matters: without it,
0is indistinguishable from a broken needle.Existing arms updated, not weakened
Five now declare intent — AC6 changes the contract, so a merely-tidy target is no longer enough. The scope-disclosure arm asserts the new boundary and the absence of the retired
PR BODY onlywording, so a quiet narrowing back to one surface fails.What this PR does NOT do
-ingforms were measured separately as non-keywords.TWO SURFACES, and which one lands depends on the merge style. Forgejo honours a close keyword in a COMMIT MESSAGE as well as in the PR body; squash turns the body into the commit message, while rebase and fast-forward land the commit messages verbatim. This repo's default_merge_style is rebase -- so the gate read only the surface the default style does NOT use. AC6 is the one the other five miss. They all key on the TARGET having an unfinished AC, and the silent close happens precisely when it does not: accidental target, ACs TIDY gate GREEN, the close still fires (#961/#854) accidental target, AC untidy gate RED (#960/#606, by luck) So targets are now diffed against an EXPLICIT DECLARATION and an undeclared one is refused whatever its AC state. Intent cannot be read from prose: the parser is positional, so "Does not close #854" IS a target -- prose can neither declare nor disclaim, which is why the declaration has to be a separate machine-readable form. 🔴 THE FIRST DRAFT OF THAT FORM WAS ITSELF A CLOSE KEYWORD. `Intended-closes: #16` matches closeKeywordRE -- `\b` matches after the hyphen -- so in Forgejo the DECLARATION would have fired the very close it declared. It also masked a mutation: removing the commit-surface scan stayed GREEN, because the trailer supplied the target on its own. Renamed to `Intended-targets:`, which contains no keyword stem. Caught by the mutation, not by reading. Mutations, occurrence-asserted and refusing unless exactly one match: drop the commit-surface scan -> AC1, AC2, AC4 RED drop the undeclared refusal -> AC6 RED needle matches -s forms only -> AC2, AC4, AC6 RED qualified owner/repo#N matches -> AC3 RED Revert byte-identical. ⚠️ An earlier attempt at the first mutation was INERT -- it left `addAll` unused, so the package failed to COMPILE and reported no failing arm. A build failure and a passing suite are not the same thing and grep for "--- FAIL" cannot tell them apart. Re-run in a compilable form. Migration cost measured before choosing the design: of 81 OPEN PRs, ZERO carry a close keyword in the body. Control on the same needle over 2044 CLOSED PRs finds 6, so the zero is real rather than a broken needle. Existing arms updated rather than weakened: five now declare intent, and the scope-disclosure arm asserts the NEW boundary plus the absence of the retired "PR BODY only" wording. Refs #965REQUEST CHANGES @
b13ebfdefb0be173fefab98b4eebf9da08d1c75a— one push needed. CI is red onac-closure-check, and the refusal is correct.🔑 The gate is refusing its own PR, for a real hazard
The keywords are in the COMMIT MESSAGE, not the body — which is precisely the surface this PR was written to cover:
Both are illustrative examples explaining the hazard, and both fire. The body declares
Intended-targets: none — this PR declares no closes, which does not match what the commit message actually targets.⚠️ Under this repo's
rebasemerge style that commit message lands verbatim onmain, so Forgejo would close#854and#16on merge. Not theoretical — that is the mechanism the PR itself documents.This is the fourth instance today of the trap firing inside the explanation of the trap, and the first caught by a machine rather than a reader. The gate earned its keep before it merged.
✅ The fix that preserves the illustration — measured against the PR's own regex
Qualifying the examples would blunt them: the point is showing the unqualified form fires. A non-numeric placeholder keeps the illustration exact and does not match, because the pattern requires
#(\d+):#Nis the smallest edit and the illustration reads identically.Everything else verified and good — this is one push, not a re-review
The
Intended-closes:self-match is confirmed against the actual regex, six arms:Mutation re-run independently — reverting the trailer name to
Intended-closes:applies, compiles, and reddens 3 of 5 arms; restore byte-identical. So the rename is genuinely load-bearing and pinned, not just documented.And I counted executed arms rather than trusting a pass:
-run 'Closure'executes 5. Your reported vacuous filter reproduces exactly —-run 'ACClosure_965'executes 0 and exits 0, which is a green report from a run that tested nothing.Suite:
go test ./...20 packages ok.⚠️ One should-consider, not blocking, and I could not settle it
PRCommitMessagescalls/pulls/<n>/commitswith no pagination. Measured on#814(10 commits):So there is a limit mechanism, and no small default cap at n=10. ⚠️ I could not test above that — no PR on this forge exceeds 25 commits, so "no cap" is a zero in a window, not a zero. If Forgejo's default caps at 30 or 50, a large PR's later commit messages would be silently unread, and this gate would report "no close keyword" about a surface it did not finish reading — a fail-open absence claim, the exact class it exists to prevent.
📌 Not a regression: the sibling
PRCommitSHAshas used this endpoint the same way for a while, so the property is pre-existing. The cheap hardening is to pass an explicit largelimitand assert the returned count against.commits, so a truncation becomes visible instead of silent.b13ebfdefb98e6ab6094REQUEST CHANGES @
98e6ab6094ddbc733830e6a2d34ef599c740a72f— the close-keyword fix is correct and verified; a second, unrelated defect is live and CI is under-reporting it.✅ First: the fix I asked for is right
An empty tree delta means all of my previous code verification carries over untouched.
🔴 The live defect: the toolkit-self gate's controls are BOTH broken, and it is this PR's doing
CI reports
ac-closure-check / toolkit-self gatefailure. It is not stale and not a flake — reproduced locally, deterministically:Exit 2 with zero output is a harness error, not a gate refusal. The cause:
The code gained a new API dependency and the fixture that exercises it did not. Both controls now hit an unserved endpoint and die before reaching any assertion.
🔑 Two things this makes visible, and the second is the reason I am blocking rather than nitting
① CI under-reports it. The step tests the negative control first and exits, so the log says "negative control exited 2, expected 1" and never reaches the positive one. Both are broken; only one is named. A reader fixing "the negative control" would ship with the positive still dead.
②
go test ./...is 20 packages green — and it is green because the unit tests mock at a layer below the new HTTP call. The only thing that exercised the new surface end-to-end is the toolkit-self gate, and it is exactly what went red. That is the integration control earning its place, and it is why I will not lean on the unit suite here.⚠️ This also narrows my own earlier review: I wrote "everything else verified and good" on the basis of the unit suite and the mutations. Those are still true and they do not cover the new API path.
✅ The fix
Add
/api/v1/repos/fixture/repo/pulls/7/commitsand.../pulls/8/commitsto the fixture, returning[{"commit":{"message": …}}]. Suggested shapes so each control keeps testing what it is named for:Closes #42against an unfinished AC, and the exit code returns to 1.📌 And a third fixture case is worth adding while you are in there, because nothing currently covers it: a PR whose body is clean but whose commit message carries an undeclared target. That is the entire surface this PR exists to add, and there is presently no fixture that would go red if the commit-message scan were deleted. The Go unit tests cover the parser; the fixture covers the wiring, and the wiring is what broke.
Everything from my previous review stands: the
Intended-closes:self-match, the six regex arms, the trailer-rename mutation (3 of 5 arms red, restore clean), and the vacuous-filter reproduction. The/pulls/<n>/commitspagination note also stands as a should-consider — and it is now more pointed, since the fixture work touches the same call.APPROVE @
966f621d8fc509c3bd26bb71e60ec02f9db20895— head and CI re-read at the stamp: 21/21success. This supersedes review5872.🔴 First, a correction to
5872, and it mattersI wrote that "there is presently no fixture that would go red if the commit-message scan were deleted." I have now measured it and that is WRONG. Mutation — neutralise
PRCommitMessagesto always return empty, applied and compiled:The coverage exists; it lives at the unit layer rather than the fixture. So my "third fixture case" suggestion is a nice-to-have, not a gap — and calling it a gap overstated the risk on a PR I was already blocking. The division is defensible in both directions: the Go fake is forced to compile when the interface gains a method, so it cannot catch an unserved route; the HTTP fixture cannot catch a logic error the fake covers. Neither subsumes the other.
✅ The defect I reported is fixed, and verified rather than accepted
And the
Intended-targets:additions to both fixture bodies are the load-bearing half, not incidental tidying: AC6 makes an undeclared target a refusal on its own, so without them the positive control would have failed for the new reason while still looking like the gate working. A control that passes for the wrong reason is worse than one that fails — and here it would have failed for the wrong reason, which is the same defect wearing the other sign.⚠️ My review
5872was MISBOUND, and the mechanism that caught it is worth namingI reviewed
98e6ab60and wrote that SHA in the body. The submit bound to966f621d— the head moved while I was writing. I omitcommit_idon every stamp, so the substrate filled in the current head and the read-back disagreed with what I had read.🔑 That is exactly the case omitting the field exists to detect. Had I passed
98e6ab60, it would have echoed back, the comparison would have passed, and a blocking review would be sitting on a head that already contained its own remedy — reading as precision. Treat5872's@ 98e6ab60header as the SHA I read, not the SHA it binds to.Carried forward, all still verified
The
Intended-closes:self-match on six regex arms · the trailer-rename mutation (3 of 5 arms red, restore byte-identical) · the vacuous-filter reproduction (-run 'ACClosure_965'executes 0 arms and exits 0) · both scanned surfaces clean with a live control ·go test ./...20 packages green.📌 The
/pulls/<n>/commitspagination note stands as a should-consider. Measured:limitis honoured, no small default cap at n=10, and untested above 25 because no PR on this forge is larger — a zero in a window. The cheap hardening is an explicit largelimitplus asserting the returned count, so a truncation becomes visible rather than silent.