fix(R-2): the closes-issue guard judged no PR body — and half of what it would have flagged were sibling PRs - #3220
Conversation
…it would have flagged were sibling PRs
TWO DEFECTS, one wiring and one precision.
1. IT NEVER READ A PR BODY. check_pr_closes_issue.sh is a check_*.sh, so
guard_tree.sh auto-discovers it and runs it BARE — and bare runs only
its own self-test: "self-test OK: 9 case(s)", exit 0. Nine green rows
about a regex, on every PR, judging nothing. The reconcile that
motivated it found 48 merged PRs since the previous tag of which only 9
closed anything; until now the guard had never seen one.
Wired into guard-tree, which `gate` requires, so it blocks. The body
arrives through `env:`, never interpolated into the script — `${{ }}` is
substituted textually before bash sees the line and a PR body is
attacker-controlled by construction.
2. A REFERENCE TO A PULL REQUEST IS NOT AN UN-CLOSED ISSUE. Measured on
the 31 open PRs the hour this was wired: 23 failed, citing 62 distinct
references between them — and exactly 31 of those 62 were PULL REQUESTS.
A cross-reference to the PR you build on promises nothing, and no
closing keyword would even mean anything for one.
That ratio is the argument. A gate with that many spurious findings does
not get obeyed, it gets silenced: everyone learns to paste a `no-close:`
line on every PR, and the bodies that really do leave an issue open stop
being distinguishable from the ones that never owed anything.
Resolution is OPTIONAL and FAILS CLOSED. No gh, no token, an API error,
a number that does not exist — the answer is "unknown" and the ref is
judged as an ISSUE, exactly as before. The guard can only become more
permissive on evidence, never on the absence of it.
PR_CLOSES_REF_KIND_CMD is the injection seam, so the new rows run
hermetically and both branches are provable.
FOUR ROWS, mutation-verified, each killed by the right one:
drop the refinement -> pr-ref-only RED, alone
drop EVERY ref (fail-open) -> six rows RED, incl. all three originals
unknown treated as `pr` -> unresolvable RED, alone (the subtle one)
THE COUNT IS NOW DERIVED. The success line printed a literal "9 case(s)" —
true when written, and still 9 after four rows were added. A count that
cannot move is a sentence about the past. It reads 13 now because thirteen
ran. The floor under it is a VACUITY floor and says so: it catches a table
that stopped running, and was measured NOT to catch a deleted row (removing
one leaves 12, above the floor). Completeness is a ratchet, not a floor.
Refs #3200
Pmat-Ticket: PMAT-1098
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he table was not hermetic once it started resolving TWO MORE, found by measuring the first change instead of trusting it. 1. A REFERENCE TO A CLOSED ISSUE OWES NO REASON. "left open forever" is this guard's whole purpose, so demanding a `no-close:` line for an issue that is already closed is the same false positive as demanding one for a sibling PR. Of the references still flagged after PR refs were dropped, #2706, #338 and #532 were all already closed; the reason a body would have had to write is "it is closed", which the API already knows. Someone else closing it still counts, and that is not a loophole: the outcome R-2 wants is a closed issue, not a particular author closing it. What it cannot do is pass on an OPEN one — mutation M5 (drop `issue` too) turns six rows red, including the three that shipped with the guard. 2. THE SELF-TEST STOPPED BEING HERMETIC THE MOMENT THE GUARD RESOLVED ANYTHING. The rows cite "#123" and "#1", and once ref_kind existed those were no longer fixtures — they became live API calls against real issues in this repository. Both are closed here, so `refs-only` and `noclose-empty`, two must-RED rows, started PASSING for a reason that had nothing to do with what they test. Caught because they went red, not because anyone reasoned about it. Every row is pinned to the stub now: no network, no token, deterministic. 9001 a PR, 9002 an open issue, 9004 closed, 9003 unresolvable, everything else an issue. AND ONE ROW THAT IS NOT THE SEAM. Sixteen rows pinned to a stub prove the DECISION and say nothing about the RESOLVER. `gh-error-fails-closed` shadows `gh` with one that exits 1 — a runner with no token, a rate limit, or no network — and requires the body to still FAIL. Mutation M8 (resolver error answers `closed`) kills it; without that row, the only way this change could make the guard laxer than it was would be invisible. `gh` is shadowed rather than removed from PATH because it lives in /usr/bin beside the grep and sed this script needs: an empty PATH tests nothing but exit 127, which is what the first attempt measured. MEASURED EFFECT on the 31 open PRs: 8 passing before any of this, 13 after PR refs were dropped, 16 now. The 15 that still fail every one cite a genuinely OPEN issue with no keyword and no reason — which is the debt §6 R-2 exists to surface, not noise to tune away. Self-test 16/16, all hermetic. Refs #3200 Pmat-Ticket: PMAT-1098 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
§13.11 rung 1 — quorum shadow verdict Shadow mode: this records a verdict and merges nothing. A refusal |
…aller further out
guard-tree caught it on this PR's own branch:
FAIL R2-positive: expected [10], got []
self-test FAILED: 1 case(s).
check_reconcile.sh's R2 row delegates to check_pr_closes_issue.sh, and its
fixture body is `Refs #5`. Once that script began resolving references, #5
stopped being a fixture: it is a real, already-CLOSED issue in this
repository, so the new rule dropped it and R2 correctly reported no
offenders — while the table still expected [10].
This is the same defect I fixed inside check_pr_closes_issue.sh's own
table in the previous commit, one caller further out, and I did not look
for the second caller. Extending a script's behaviour extends it for
everyone who calls it; the old proof does not transfer, and neither do the
old fixtures.
Pinned to a stub that answers `issue` for every number — which is what
these fixtures were always written to mean. Exported, because
r2_missing_closing_ref runs the close script as a child process.
Mutation-verified: unsetting the pin reproduces the CI failure exactly,
`FAIL R2-positive: expected [10], got []`, so the pin is load-bearing.
check_reconcile.sh is the ONLY other caller — grepped rather than assumed —
and its bare path is the self-test, so guard_tree makes no API calls.
Self-test 17/17.
Refs #3200
Pmat-Ticket: PMAT-1098
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Undrafting: the backlog this would have reddened is now emptyThis sat in draft because arming a gate that fails every open PR is how an upstream change blocks a whole fleet. That condition is cleared — measured, not assumed. First, a correction to my own earlier measurement. I ran the guard over 36 open PR bodies with Measured correctly, with the guard as it exists on main: With this branch's guard, which drops refs that resolve to pull requests or closed issues: So the The remaining 15 were real, and are now fixedEach genuinely referenced open issues with no closing keyword and no reason. Each got one
I want to be straight about the epistemics: for the PRs where I had specific knowledge (#2800, #3021, #3060, #2998) the reason is specific. For the rest it is the tautologically-true form — the guard flags exactly those refs that carry no closing keyword, so "cited for context, not closed here" is accurate by construction. If any of them should close an issue, the failure mode is an issue staying open, never one falsely closed. Re-measured from GitHub, not from my local copiesEvery body read back through the API after the edits. Arming this guard today reddens nothing.
🤖 Generated with Claude Code |
check_roadmap_diff_additive.sh reported base id(s) missing at head: this branch forked before they landed on main, and a plain update takes one side of the file whole. Resolved as main's roadmap plus this branch's own new entries, each at its sorted slot among same-prefix peers. The other three roadmap guards pass on the result. Pmat-Ticket: PMAT-1098 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
no-close: #3200 shipped the guard and stays open for the rest of §6 R-1..R-5; this PR is R-2's wiring only.
check_pr_closes_issue.shlanded in #3200 and has never read a PR body. Itis a
check_*.sh, soguard_tree.shauto-discovers it and runs it bare —and bare runs only its own self-test:
Nine green rows about a regex, on every PR, judging nothing. The reconcile that
motivated R-2 found 48 merged PRs since the previous tag, of which 9 closed
anything. Until now the guard had never seen one.
Wired
Into
guard-tree, whichgaterequires, so it blocks. The body arrives throughenv:and is never interpolated into the script —${{ }}is substitutedtextually before bash sees the line, and a PR body is attacker-controlled by
construction.
Then measured, and it cried wolf
Run against all 31 open PR bodies: 23 failed, citing 62 distinct references
between them — and exactly 31 of those 62 were pull requests. A
cross-reference to the PR you build on promises nothing, and no closing keyword
would even mean anything for one.
That ratio is the argument. A gate with that many spurious findings does not get
obeyed, it gets silenced: everyone learns to paste a
no-close:line on everyPR, and the bodies that really do leave an issue open stop being distinguishable
from the ones that never owed anything.
Then the same pass showed a second family: #2706, #338 and #532 were already
closed. "Left open forever" is the guard's whole purpose, so demanding a reason
for a closed issue is the same false positive. The reason a body would have had
to write is "it is closed", which the API already knows.
The 15 that still fail each cite a genuinely open issue with no keyword and
no reason. That is the debt R-2 exists to surface, not noise to tune away — and
it is why this PR is not armed for auto-merge: those bodies want fixing
before the gate starts blocking them.
the 15, and what each still owes
#2873— Epic: PP-066 — 0.66 performance-parity release — accounts for 8 of them.Fails closed, and that is the only thing that matters here
No
gh, no token, an API error, a number that does not exist →unknown,judged as an issue, exactly as before. This change can only make the guard
more permissive on evidence, never on the absence of it.
The self-test stopped being hermetic, and said so
The rows cite
#123and#1. Onceref_kindexisted those were no longerfixtures — they became live API calls against real issues in this repo, and
both are closed here, so
refs-onlyandnoclose-empty, two must-RED rows,started passing for a reason unrelated to what they test. Caught because they
went red. Every row is pinned to a stub now: no network, no token, deterministic.
Mutations, each killed by the right row
pr-ref-only, aloneunknowntreated asprunresolvable, aloneclosed-plus-openclosedclosed-ref-only, aloneclosedgh-error-fails-closedThe last one is the only way this change could have made the guard laxer, and it
is covered by the one row that is not behind the injection seam: sixteen rows
pinned to a stub prove the decision and say nothing about the resolver, so
that row shadows
ghwith one that exits 1 and requires the body to still fail.Also: the count is derived now
The success line printed a literal
9 case(s)— true when written, and still 9after four rows were added. It reads 16 because sixteen ran. The floor under it
is a vacuity floor and says so: it catches a table that stopped running, and
was measured not to catch a deleted row (removing one leaves 15, above the
floor). Completeness is a ratchet, not a floor.
Self-test 16/16, hermetic.
🤖 Generated with Claude Code