Repository navigation
The two issue-reference gates disagree: inline-code stripping deletes a disposition before the Python gate sees it #17640
Description
The two issue-reference gates disagree about the same body
Corrected 2026-09-27: the original filing named the wrong mechanism. It said a disposition in
prose is invisible to a trailer-position reader. Position is irrelevant. The real cause is
inline-code stripping, and it is deliberate — which makes this a decision to be made, not a bug
to be fixed.
Measured
On the real body text of a merged PR:
body: `Refs #15500` rather than `Closes` — AC5 and AC6 name modes this does not deliver.
shell gate (pr-issue-validation.yml, matches the raw body) -> 1 match
python gate (scripts/check_pr_issue_batching.py) -> no match at all
The reason, from the same run:
after _FENCE.sub(" ", body): " rather than — AC5 and AC6 name modes ..."
the identical sentence, unbackticked -> the reference IS found
_FENCE (scripts/check_pr_issue_batching.py:74) is
…| ~~~…~~~ | `…` and is applied at :134 before any matching. A disposition written
in single backticks is deleted before the detector runs. Unbacktick the same sentence, same
position, and it is found.
The invariant that is broken
#17580's own comment states it: the two gates cannot disagree about what a reference is. They do,
on two merged bodies. The shell gate saw a reference; the Python gate saw nothing. A pre-push
reporting "satisfies both gates" is therefore satisfiable by one gate agreeing and the other having
nothing to object to.
Why this is a decision, not an oversight
The stripping is documented at :71-73 and the comment names inline code explicitly:
A reference inside a fenced block or inline code is an EXAMPLE, not a link. Found on this
gate's own PR, whose worked examples scored as six extra issues: left in, a PR could satisfy the
rule with sample text and never link anything.
That reasoning is sound and the defect it prevents is real. An example and a claim are
indistinguishable by markup — `Refs #N` is both a quoted token and a statement of intent.
So narrowing the strip reverses a recorded ruling and re-opens the defect it was added for.
The options, none of them free
- Keyword+issue pairs stop counting as examples wherever they appear. Fixes this; re-opens the
six-extra-issues defect. - Only fenced blocks are examples; single-backtick inline code is not. Narrower, and the
motivating defect was worked examples in fenced blocks — but it contradicts the comment's
explicit wording, so it needs checking against the PR that motivated_FENCE, not assumed. - Neither gate strips, and "is this an example" is decided another way — an explicit marker, or
position after all.
Recommendation on record: option 2, with the caveat that it is unverified against the motivating
PR and contradicts the written rationale. That verification is a precondition, not a follow-up.
Acceptance criteria
- The motivating PR for
_FENCEis read, and it is stated whether its six false references were
in fenced blocks, inline code, or both. This decides between options 1 and 2 and must happen
before any code changes. - Whichever option is taken, the
:71-73rationale is updated to match, not left contradicting
the behaviour. A comment that describes the old rule is worse than none. - The two gates are shown to agree on the same body — evidence is both gates run over the two
merged bodies below and over the motivating PR's body, with matching verdicts. - The six-extra-issues defect is shown still prevented, by the motivating PR's body scoring
correctly after the change. - Any audit built on the Python checker's view states that it used it, so a past clean result is
not mistaken for a verified one.
Instances
Both merged, both correctly written, both reported as having no disposition:
- fix(llm): provider preference order is configuration, not statement order (#15500) #17518 body line 74 —
`Refs #15500`, because two criteria name a mode-selection surface
this PR does not deliver. That surface's issue is still open. - fix(hooks): judge a push by its refspec's destination, not by words in the command (#14144) #17521 body line 77 —
`Refs #14144`, because one criterion stays open until further
guards are gated.
Correction to the record
An earlier audit reported these two as declaring no closing keyword. That report was not a
trailer-position error — it was almost certainly reading the Python checker's view, which genuinely
contains nothing. It was a correct report of a gate that cannot see the body, which is a different
and more dangerous thing than a sloppy scan.
Population is a lower bound: two is what one audit surfaced. How many other merged bodies state a
disposition in backticks is unmeasured.
Related: #17580 (closing-keyword-mid-sentence, the same file and the source of the invariant),
#17601 (same detector-breadth family).
Activity
[-]A disposition written in prose is invisible to a trailer-position reader, producing wrong negatives[/-][+]The two issue-reference gates disagree: inline-code stripping deletes a disposition before the Python gate sees it[/+]mrveiss commentedon Sep 27, 2026
Refinement to the first acceptance criterion, from the session that found the mechanism.
The verification must state not only whether the six false references were fenced, inline, or both, but also whether any of them was a disposition rather than an example.
That second question is what decides whether option 2 is clean:
Recording the provenance of the recommendation honestly: option 2 was proposed by a session that had
:71-73in its working context at the time, including the words "or inline code". Reading the text and not registering what it said is the same failure this issue documents, one level up. That is a reason to treat the recommendation as a hypothesis needing verification, not as the likely answer.mrveiss commentedon Sep 28, 2026
Analysis: inline-code stripping in the batching gate vs
`Refs #N`Short answer: this is not a trade. The case the stripping protects and the
`Refs #N`case live in two different sets inside the gate, so both can be satisfied. The fix is small and keeps the written decision intact. Everything below is read atorigin/main7adaca8. No change was pushed.1. What the stripping protects against
The rationale at
scripts/check_pr_issue_batching.py:71-73: "A reference inside a fenced block or inline code is an EXAMPLE, not a link. Found on this gate's own PR, whose worked examples scored as six extra issues: left in, a PR could satisfy the rule with sample text and never link anything."The originating PR is #15493, the gate's introduction for #15492 and #15488. Its body has a fenced block of worked examples:
That block yields exactly six identifiers: 15178, 15173, 1, 2, MVA-7, 3. So the incident that motivated the stripping was a fenced block. The same body also has an inline example (
`Closes #15178, #15173` matched only one issue), which would add nothing new, since both numbers are already among the six. The inline half of the regex is therefore a generalisation, not something the incident required. It is still a reasonable generalisation: inline examples with real issue numbers do occur, as #15493 itself shows.What "satisfy the rule" means in the code: the only verdict the gate computes is
len(closing_issues(body)) >= 2→ "Batched" (check(),:333-334). Since #17128 every other path returnsTruewith a warning annotation. So the harm the rationale names, sample text satisfying the rule, can only come throughclosing_issues, which countsresolves,closesandfixesonly.2. Can both cases be satisfied? Yes.
`Refs #N`never entersclosing_issues:refsis a mention word (_MENTION_WORDS,:66), and the fix(ci): the same-scope batching gate counts Refs, so any umbrella reference exempts a single-issue PR #16795 split exists precisely so that mentions do not count toward batching.`Refs #N`enters onlyreferenced_issues(:142-144).check()uses that set for two things: (a) the "No issue reference found; pr-issue-validation owns that check" path (:336-339), and (b) message text such as_mention_noteand_scope. Neither changes the verdict.referenced_issuesprotects nothing the rationale names. A mention can never satisfy the batching rule, whether or not it is an example. What it does cause is a wrong message: "no issue reference found" for a body that plainly has one. That is the output tonight's audit trusted.The two uses currently share one helper:
_issues_under(body, pattern)at:131-139applies_FENCE.subfor both patterns. That coupling is the whole defect.Minimal fix, one function: keep the full
_FENCE(fenced blocks plus inline code) forclosing_issues, which leaves the #15493 protection exactly where its rationale applies. Forreferenced_issues, strip fenced blocks only, which keeps the documented incident excluded from messages too. Add a contrast test inscripts/check_pr_issue_batching_test.py:`Refs #17640`in prose →referenced_issues== {17640},closing_issues== ∅;`Closes #1`inline in a single-issue body → still not counted as batched;scripts/validate_pr_body.pyimportscheck()(docstring at:309-314), so the local pre-create check changes with it. The signature is unchanged, so that caller is safe.3. Trade or fix?
A fix. The written decision survives: its reason, sample text satisfying the rule, is enforced exactly where the rule is decided. Narrowing the stripping only for the mention set does not reverse it; it corrects a helper that applied the decision to a set where it has no effect beyond producing false "not linked" messages.
The residual cost is small. A body whose only reference is an inline example will now be reported as "referenced" instead of "no issue reference found". The sibling gate (below) already accepts that body as linked, so this gate would merely stop contradicting it.
4. The other gate, verified rather than assumed
.github/workflows/pr-issue-validation.yml::88$BODYresolves, closes, fixes, refs, references, part of:105#N:124-137```toggle in awk; not inline code, not~~~resolves, closes, fixesSo the disagreement is real:
`Refs #N`in backticks: passes (a) and (b) in the workflow gate, and is invisible tocheck_pr_issue_batching.py.~~~fences: the workflow's fork path does not strip them; the batching gate does.Refs #1example satisfies "has linkage". That is a pre-existing weakness in (a), the mirror image of the ci(guard): enforce same-scope batching and documented blanket skips (#15492, #15488) #15493 problem, and not in scope here. It is worth its own issue if anyone cares; I have not filed one.After the fix above, the two gates agree on the question they share ("is anything linked?") for inline code. They still differ on fenced blocks, where the workflow is looser. Aligning that means changing check (a), which is a separate decision.
Limits
origin/main.Refsis "no closing keyword", so if the audit's words were literal, its finding was correct and the error was in reading it as "not linked". Worth checking which it was before attributing the audit error to the regex alone.Generated by Claude Code
fix(ci): both PR-body gates recognise all nine GitHub closing keyword…