Skip to content

The two issue-reference gates disagree: inline-code stripping deletes a disposition before the Python gate sees it #17640

Open

Description

@mrveiss

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

  1. Keyword+issue pairs stop counting as examples wherever they appear. Fixes this; re-opens the
    six-extra-issues defect.
  2. 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.
  3. 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 _FENCE is 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-73 rationale 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:

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

  1. changed the title [-]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[/+] on Sep 27, 2026
  2. mrveiss commented on Sep 27, 2026

    @mrveiss
    OwnerAuthor

    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:

    • If the motivating PR's inline hits were all genuine examples, option 2 is a clean narrowing and the original rationale simply over-reached in its wording.
    • If any of them was a claim rather than an example, then the original decision was already conflating the two, and narrowing the strip inherits that conflation instead of resolving it. In that case none of the three options is sufficient as written and the "is this an example" question needs an answer that does not rely on markup at all.

    Recording the provenance of the recommendation honestly: option 2 was proposed by a session that had :71-73 in 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.

  3. added this to the v0.10.0 milestone on Sep 27, 2026
  4. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    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 at origin/main 7adaca8. 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:

    'Closes #15178, #15173'    -> ['15173', '15178']
    'refs #1, #2, and MVA-7'   -> ['1', '2', 'MVA-7']
    'Closes #1,#2,#3'          -> ['1', '2', '3']
    'this relates to #15178'   -> []
    

    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 returns True with a warning annotation. So the harm the rationale names, sample text satisfying the rule, can only come through closing_issues, which counts resolves, closes and fixes only.

    2. Can both cases be satisfied? Yes.

    • `Refs #N` never enters closing_issues: refs is 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 only referenced_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_note and _scope. Neither changes the verdict.
    • So stripping inline code from referenced_issues protects 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-139 applies _FENCE.sub for both patterns. That coupling is the whole defect.

    Minimal fix, one function: keep the full _FENCE (fenced blocks plus inline code) for closing_issues, which leaves the #15493 protection exactly where its rationale applies. For referenced_issues, strip fenced blocks only, which keeps the documented incident excluded from messages too. Add a contrast test in scripts/check_pr_issue_batching_test.py:

    scripts/validate_pr_body.py imports check() (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:

    Check What it strips before matching Keywords
    (a) any linkage, :88 nothing: raw $BODY resolves, closes, fixes, refs, references, part of
    (b) branch issue present as a token, :105 nothing none; any standalone #N
    fork path, closing keyword only, :124-137 fenced blocks only, via a line-based ``` toggle in awk; not inline code, not ~~~ resolves, closes, fixes

    So the disagreement is real:

    • `Refs #N` in backticks: passes (a) and (b) in the workflow gate, and is invisible to check_pr_issue_batching.py.
    • ~~~ fences: the workflow's fork path does not strip them; the batching gate does.
    • Examples: check (a) strips nothing, so a fenced Refs #1 example 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

    • Not executed. I did not run the existing test suite against a patched copy; this is a reading of origin/main.
    • The audit: I did not see the tool that produced tonight's "no closing keyword" finding. Strictly, Refs is "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.
    • Routing note from the executing session: by the coordinator's own standing rule this is local-session work (small, bounded, no sweep). It was done here only because the route is one-way and declining would have left it undone.

    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions