Skip to content

Commit 376b3dc

Browse files
huangyiireneclaude
andauthored
feat(pm): report a closing keyword bound to a card the sentence says it is NOT closing (H21) (#10939)
H7's header states the rule generally — GitHub's parser matches the keyword plus the number and ignores the surrounding prose, negations included — but its predicate is bound to a `Part of #N` declaration and fires only when the same `#N` carries both. A body that declares `Part of` for nothing is silent by construction, however plainly it says the card stays open. That gap cost a card. PR #10241 carried no `Part of` anywhere, and a sentence in its `## Out of scope` section bound a past-tense closing keyword directly to card #10240; that card closed `completed` two seconds after the merge and read as finished until a human reopened it a day later. (This commit message states the specimen that way on purpose: a commit message is a closing-keyword surface too, and it is NOT markdown, so backticks would not have protected it. Quoting the sentence verbatim here would have closed the same card a second time — the first draft of this message did exactly that, caught by running this predicate over it.) H21 flags a closing keyword bound to a `#N` the body never declared itself part of, when the SENTENCE containing it carries a negation or filing marker. Disjoint from H7 by construction: a number already declared `Part of` is H7's row and is skipped here. Stage-1 corpus measurement, which the commissioning ruling made the precondition to any blocking posture — the 300 most recently merged PR bodies (2026-08-19T15:13:23Z..2026-08-21T19:11:30Z, 2,564,259 body chars): sentence window (shipped) 1 / 301 keyword matches — the specimen. 0 FP. paragraph window 1 / 301 whole body before match 14 / 301 — 13 false positives anywhere in body 301 / 301 — the naive shape The 13 whole-body false positives are one PR (#10714) legitimately closing fourteen cards, so the sentence bound is load-bearing rather than stylistic. The result is not clean for lack of opportunity: the same corpus carries 116 sentences in the deliberate-non-closure register ("## Out of scope", "filed, not repaired here", "#N is not addressed here") and only one author bound a closing keyword to the number. Second corpus, same answer: 1,418 squash commit messages on main (2026-08-11..21), 228 keyword matches, 0 flags. Report-only, and deliberately NOT wired into the blocking gate: scripts/check-partof-closing-keyword.mjs imports h7PartOfWithClosingKeyword and fails PRs on it, so H21 is a separate function that gate does not import — widening the class must not silently widen a check that fails builds. The scheduled patrol already calls this sweep, so no workflow edit is needed. Part of #10392 Co-authored-by: Claude <noreply@anthropic.com>
1 parent b47ba2c commit 376b3dc

1 file changed

Lines changed: 323 additions & 5 deletions

File tree

scripts/pm/check-half-states.mjs

Lines changed: 323 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,29 @@
355355
* readings rather than diagnosing one. Report-only like everything here:
356356
* the remedy is a dispatch or a withdrawn claim, never a label written
357357
* from this script.
358+
* H21 an OPEN PULL REQUEST whose body binds a closing keyword to a `#N` the
359+
* body never declared itself `Part of`, inside a SENTENCE that reads as
360+
* not closing it ("Filed, not fixed: #10240", "out of scope: closes
361+
* #N"). H7's own rationale covers this whole class while H7's predicate
362+
* covers one spelling of it — H7 is bound to a `Part of #N` declaration
363+
* and a body that declares `Part of` for nothing is silent by
364+
* construction, however plainly it says the card stays open. Measured
365+
* specimen (#10392): PR #10241 carried no `Part of` and the sentence
366+
* "Filed, not fixed: #10240"; #10240 closed `completed` two seconds
367+
* after that merge and read as finished until a human reopened it a day
368+
* later. ⛔ The trigger is the NEGATION WINDOW and never keyword
369+
* presence: 277 of the 300 most recently merged bodies carry a closing
370+
* keyword bound to a number (301 matches), so a presence rule would
371+
* report every correct PR in the corpus. The negation is the author's
372+
* own statement of intent contradicting the instruction beside it, which
373+
* is what makes accident separable from intent at all. The window is a
374+
* SENTENCE, measured: widening it to the whole body turns 0 false
375+
* positives into 13, all of them one legitimate fourteen-card close
376+
* (#10714). Disjoint from H7 by construction — a number already declared
377+
* `Part of` is H7's row and is skipped here. Report-only, and
378+
* deliberately not imported by the blocking gate that reuses H7's
379+
* predicate: widening the class must not silently widen a check that
380+
* fails builds.
358381
*
359382
* ## The close mechanism, measured (#8293)
360383
*
@@ -2919,6 +2942,190 @@ export function h20DispatchedNoBranchRef(issue, claim, refStates, nowMs = Date.n
29192942
);
29202943
}
29212944

2945+
// ---------------------------------------------------------------------------
2946+
// H21 — a closing keyword bound to `#N` inside a sentence that NEGATES it
2947+
// (#10392). H7's rationale, minus H7's `Part of` precondition.
2948+
//
2949+
// ## The gap, and the card it cost
2950+
//
2951+
// H7's header states the rule in fully general terms — GitHub's parser "matches
2952+
// the keyword plus the number and ignores the surrounding prose entirely,
2953+
// negations and modals included, so the sentence an author writes to PREVENT an
2954+
// auto-close is exactly what performs it on merge". The PREDICATE behind that
2955+
// sentence is narrower than the sentence: it is bound to a `Part of #N`
2956+
// declaration and fires only when the same `#N` carries both. A body that
2957+
// declares `Part of` for nothing is silent by construction, however plainly it
2958+
// says it is not closing the card.
2959+
//
2960+
// Measured specimen — PR #10241, merged 2026-08-20T15:10:06Z. Its body carries
2961+
// no `Part of` anywhere and this sentence under `## Out of scope`:
2962+
//
2963+
// Filed, not fixed: #10240 — the same leak through the **delete** verb.
2964+
//
2965+
// Issue #10240 closed `completed` at 15:10:08Z — two seconds later — with a
2966+
// closing-link summary naming #10241 and nothing else. #10240 is a genuine
2967+
// unfixed defect (attachment tombstoning no-ops on a predicate delete); it read
2968+
// as finished until a human reopened it a day later. The author wrote the
2969+
// sentence to record that the card was deliberately LEFT OPEN, and the sentence
2970+
// closed it. H7 was silent, exactly as designed.
2971+
//
2972+
// ## Why this is a window and not "flag every closing keyword"
2973+
//
2974+
// The naive widening is not available and the corpus says so quantitatively: a
2975+
// PR cannot declare which cards it intends to close except BY using the keyword,
2976+
// so a rule over keyword-presence alone cannot separate intent from accident.
2977+
// Over the 300 most recently merged PR bodies (below), 277 carry a closing
2978+
// keyword bound to a number and there are 301 such matches — flagging keyword
2979+
// presence would produce 301 findings, every one of them a correct PR.
2980+
//
2981+
// The negation is what makes the two separable, because it is the AUTHOR'S OWN
2982+
// statement of intent sitting in the same sentence as the instruction that
2983+
// contradicts it. That is a contradiction internal to one sentence, which is
2984+
// the same shape H7 already reports across `Part of` and a keyword.
2985+
//
2986+
// ## The window bound is load-bearing — measured, not assumed
2987+
//
2988+
// Corpus (2026-08-21, this change's own stage-1 measurement): the 300 most
2989+
// recently merged PRs into `main`, bodies as the API returns them,
2990+
// 2026-08-19T15:13:23Z … 2026-08-21T19:11:30Z, 2,564,259 body characters, no
2991+
// empty bodies. Read through `stripMarkdownCode`, exactly as H7 reads.
2992+
//
2993+
// window scope flagged / 301 keyword matches
2994+
// sentence (this rule) 1 — PR #10241, the specimen. 0 false positives.
2995+
// paragraph 1 — same single hit
2996+
// whole body before match 14 — 13 false positives
2997+
// anywhere in body 301 — the naive shape; useless
2998+
//
2999+
// All 13 whole-body false positives are ONE PR (#10714) which legitimately
3000+
// closes fourteen cards with a wall of `Fixes #N` lines and merely contains a
3001+
// negation word somewhere earlier in a long body. A body-scoped negation check
3002+
// would red-flag the most correct multi-close PR in the corpus thirteen times.
3003+
// That is why the window is a sentence and why it is stated in code rather than
3004+
// left to a reviewer's judgement.
3005+
//
3006+
// The result is not clean for lack of opportunity, which is the failure mode a
3007+
// 0-of-301 number invites: the same corpus carries 116 sentences in exactly the
3008+
// deliberate-non-closure register this rule reads — `## Out of scope`,
3009+
// "filed, not fixed here", "#N is not addressed here", "#N remains open". The
3010+
// register is everywhere; only one author put a closing keyword next to the
3011+
// number. Two near-misses are worth naming because they are the specimen's
3012+
// wording almost exactly and are correctly clean: #10876's "## Out of scope —
3013+
// filed, not repaired here" and #10851's "filed, not fixed here" — neither
3014+
// binds a keyword to a number.
3015+
//
3016+
// Second corpus, a different surface and the same answer: all 1,418 squash
3017+
// commit messages on `main` (2026-08-11 … 2026-08-21) — a surface GitHub's
3018+
// closing-keyword parser also reads — carry 228 keyword+`#N` matches across 197
3019+
// commits and produce 0 flags. That arm contributes no true positive either:
3020+
// commit messages here do not carry the `## Out of scope` register at all.
3021+
//
3022+
// ## Report-only, and deliberately NOT wired to the blocking gate
3023+
//
3024+
// `scripts/check-partof-closing-keyword.mjs` imports `h7PartOfWithClosingKeyword`
3025+
// and FAILS a PR on it. This predicate is deliberately a separate function that
3026+
// that gate does not import, so widening the class cannot silently widen a
3027+
// blocking check. Report-only first is the commissioned order (#10392 triage,
3028+
// 2026-08-21): measure, ship the row, and let a promotion to blocking be its own
3029+
// decision with these numbers in hand. Nothing about this file's report-only
3030+
// contract changes, and the existing patrol workflow already calls the sweep, so
3031+
// no workflow edit is part of this.
3032+
//
3033+
// ## Disjoint from H7 by construction
3034+
//
3035+
// A number already declared `Part of #N` is H7's row and is skipped here, so the
3036+
// two never double-report one number. H7 keeps that class whether or not the
3037+
// sentence is negated (its own self-test pins a negated `Part of` body as an H7
3038+
// finding); this rule takes the class H7 cannot see — a keyword bound to a
3039+
// number the body never declared itself part of.
3040+
// ---------------------------------------------------------------------------
3041+
3042+
/**
3043+
* The negation / filing markers, EXACTLY as measured above.
3044+
*
3045+
* The set is pinned to the measured one on purpose: a marker added later
3046+
* without re-running the corpus would inherit a "0 false positives" number it
3047+
* was never measured under. Each was also measured ALONE against both corpora
3048+
* and each is independently clean; `not` and `filed` are the two that fire on
3049+
* the specimen.
3050+
*
3051+
* The bare noun `file`/`files` is deliberately NOT a marker although it too
3052+
* measured clean. It carries no negation or filing sense — "this file fixes
3053+
* #123" is a normal, correct close — and it is one of the most common nouns in
3054+
* this repo's prose, so it is the marker most likely to turn into a false
3055+
* positive on a corpus this one did not sample.
3056+
*/
3057+
const NEGATED_CLOSE_MARKER_RE =
3058+
/\b(?:not|cannot|never|no longer|filed|filing|out of scope|rather than|instead of|without|remains open)\b|\bn't\b/i;
3059+
3060+
/**
3061+
* The start offset of the sentence containing `idx`.
3062+
*
3063+
* Boundaries are sentence-ending punctuation, a blank line (paragraph break),
3064+
* and a markdown structural line start (heading, list item, table row, block
3065+
* quote). A PLAIN single newline is deliberately NOT a boundary: PR bodies are
3066+
* soft-wrapped but commit messages in this repo are hard-wrapped at ~72
3067+
* columns, so prose sentences routinely span lines there, and treating every
3068+
* newline as a break would blind the window on exactly the second corpus.
3069+
*
3070+
* Exported for the self-test: the window is the whole design, so it is pinned
3071+
* directly rather than only through the predicate's verdict.
3072+
*/
3073+
export function sentenceStartOffset(text, idx) {
3074+
const head = String(text ?? '').slice(0, idx);
3075+
let best = 0;
3076+
for (const re of [
3077+
/[.!?][)\]"'`]*[ \t\n]/g,
3078+
/\n[ \t]*\n/g,
3079+
/\n[ \t]*(?:#{1,6}\s|[-*+]\s|\d+\.\s|\||>)/g,
3080+
]) {
3081+
let m;
3082+
while ((m = re.exec(head)) !== null) {
3083+
const end = m.index + m[0].length;
3084+
if (end > best) best = end;
3085+
}
3086+
}
3087+
return best;
3088+
}
3089+
3090+
/**
3091+
* H21 — null when clean, else the finding sentence.
3092+
*
3093+
* Reads the same code-stripped text H7 reads, so a keyword quoted in backticks
3094+
* or parked in a fence is not a finding here either — the measured reason is in
3095+
* `stripMarkdownCode`.
3096+
*/
3097+
export function h21NegatedClosingKeyword(pr) {
3098+
const body = pr?.body ?? '';
3099+
const text = stripMarkdownCode(body);
3100+
const declared = partOfTargets(body);
3101+
const hits = [];
3102+
for (const m of text.matchAll(closingKeywordRe())) {
3103+
const [full, keyword, number] = m;
3104+
if (declared.has(number)) continue; // H7 owns that number
3105+
const start = sentenceStartOffset(text, m.index);
3106+
if (!NEGATED_CLOSE_MARKER_RE.test(text.slice(start, m.index))) continue;
3107+
hits.push({
3108+
keyword,
3109+
number,
3110+
sentence: text.slice(start, m.index + full.length).replace(/\s+/g, ' ').trim(),
3111+
});
3112+
}
3113+
if (hits.length === 0) return null;
3114+
return hits
3115+
.map(
3116+
(h) =>
3117+
`body carries \`${h.keyword} #${h.number}\` in a sentence that reads as NOT closing it — ` +
3118+
`"${h.sentence}". GitHub's closing-keyword parser matches the keyword plus the number and ` +
3119+
`ignores the surrounding prose entirely, negations included, so merging this closes ` +
3120+
`#${h.number} and the sentence written to prevent that is what performs it. The measured ` +
3121+
`specimen (PR #10241, "Filed, not fixed: #10240") closed a genuine unfixed card two seconds ` +
3122+
`after merge, and a closed card reads as finished. Reword so no closing keyword sits next ` +
3123+
`to that number — "#${h.number} is not addressed here" / "out of scope: #${h.number}" / ` +
3124+
`"#${h.number} remains open" — or put the keyword in backticks.`,
3125+
)
3126+
.join('; ');
3127+
}
3128+
29223129
// ---------------------------------------------------------------------------
29233130
// Report rendering — pure over (findings, counts), so `--self-test` pins both
29243131
// media offline. The live sweep below picks a renderer and prints it; nothing
@@ -4054,15 +4261,19 @@ async function sweepInto(findings, seen, seenPrs, seenMerged, seenUnscoped, stat
40544261
throw lastHoldError;
40554262
}
40564263

4057-
// H7 + H12 — the PR side. Listed straight from `/pulls` rather than filtered
4058-
// out of the label pages above: PRs carry no `pm:*` label, so the issue sweep
4059-
// cannot see them (it discards them explicitly). Drafts are INCLUDED for H7 —
4060-
// a draft is exactly where that is still cheap to fix — and excluded by
4061-
// H12's own predicate (drafts are parked deliberately).
4264+
// H7 + H12 + H21 — the PR side. Listed straight from `/pulls` rather than
4265+
// filtered out of the label pages above: PRs carry no `pm:*` label, so the
4266+
// issue sweep cannot see them (it discards them explicitly). Drafts are
4267+
// INCLUDED for H7 and H21 — a draft is exactly where that is still cheap to
4268+
// fix, and both rows are only fixable while the PR is open, because the
4269+
// damage is done by the merge — and excluded by H12's own predicate (drafts
4270+
// are parked deliberately).
40624271
for (const pr of await listOpenPullRequests()) {
40634272
seenPrs.set(pr.number, pr);
40644273
const contradiction = h7PartOfWithClosingKeyword(pr);
40654274
if (contradiction) findings.push([pr, 'H7', contradiction]);
4275+
const negated = h21NegatedClosingKeyword(pr);
4276+
if (negated) findings.push([pr, 'H21', negated]);
40664277
const orphan = h12OrphanLanding(pr);
40674278
if (orphan) findings.push([pr, 'H12', orphan]);
40684279
}
@@ -4642,6 +4853,113 @@ function selfTest() {
46424853
);
46434854
t('H7: a fenced-only keyword is not a finding', h7PartOfWithClosingKeyword(pr('Part of #5\n\n```\nFixes #5\n```')), null);
46444855

4856+
// -- H21: a closing keyword inside a sentence that negates it (#10392) -----
4857+
// The positive fixture is the REAL specimen sentence, byte-for-byte from PR
4858+
// #10241's body, and the negative fixtures are real sentences from the same
4859+
// 300-body corpus the stage-1 measurement read — including the two that are
4860+
// the specimen's wording almost exactly and must stay clean.
4861+
4862+
// ★ Specimen — PR #10241 (merged 2026-08-20T15:10:06Z). No `Part of`
4863+
// anywhere in the body; #10240 closed `completed` two seconds later.
4864+
const pr10241 = pr(
4865+
'## Out of scope\n\n' +
4866+
'Filed, not fixed: #10240 — the same leak through the **delete** verb. ' +
4867+
'`beforeDelete`→`afterDelete` hands ids over on the context stash, which the ' +
4868+
'measurement above shows is lost on the predicate path.',
4869+
);
4870+
const fired21 = h21NegatedClosingKeyword(pr10241);
4871+
t('H21: the #10241 specimen FIRES', typeof fired21, 'string');
4872+
t('H21: …and names the card it will close', fired21.includes('`fixed #10240`'), true);
4873+
t('H21: …and quotes the offending sentence back', fired21.includes('Filed, not fixed: #10240'), true);
4874+
t('H21: …and says the parser ignores the negation', fired21.includes('negations included'), true);
4875+
t('H21: …and offers the safe rewordings', fired21.includes('#10240 is not addressed here'), true);
4876+
// H7 is silent on this body — the gap that made the row necessary. If this
4877+
// ever inverts, H21 is redundant rather than merely quiet.
4878+
t('H21: …and H7 is silent on it (the gap this row exists for)', h7PartOfWithClosingKeyword(pr10241), null);
4879+
4880+
// The window. `not` and `filed` both fire on the specimen; each marker was
4881+
// measured alone against both corpora.
4882+
t('H21: a bare negated close fires', typeof h21NegatedClosingKeyword(pr('This does not fix #77.')), 'string');
4883+
t('H21: "out of scope" fires', typeof h21NegatedClosingKeyword(pr('Out of scope: closes #77.')), 'string');
4884+
t('H21: "no longer" fires', typeof h21NegatedClosingKeyword(pr('#77 is no longer in scope, so this closes #77 only on paper.')), 'string');
4885+
4886+
// ⛔ The rule is the negation window, never keyword presence. 277 of 300
4887+
// measured bodies carry a plain closing keyword and every one is correct.
4888+
t('H21: a plain `Fixes #N` is clean', h21NegatedClosingKeyword(pr('Fixes #10171')), null);
4889+
t(
4890+
'H21: a fourteen-card close list is clean (the #10714 shape)',
4891+
h21NegatedClosingKeyword(pr('Fixes #10581\nFixes #10582\nFixes #10583')),
4892+
null,
4893+
);
4894+
// …and it stays clean even when the body says "not" somewhere ELSE. This is
4895+
// the measured 13-false-positive case a body-scoped window produces.
4896+
t(
4897+
'H21: a negation elsewhere in the body does not reach the keyword',
4898+
h21NegatedClosingKeyword(pr('This does not touch the loader.\n\n### Closing lines\n\nFixes #10581')),
4899+
null,
4900+
);
4901+
4902+
// Real corpus near-misses — the specimen's register, no keyword bound to a
4903+
// number. These are the 116-sentence population the row must not report.
4904+
t(
4905+
'H21: "#N is not addressed here" is clean (the advised spelling)',
4906+
h21NegatedClosingKeyword(pr('#10526 is not addressed here — spec-side, another lane.')),
4907+
null,
4908+
);
4909+
t(
4910+
'H21: "#N remains open" is clean',
4911+
h21NegatedClosingKeyword(pr('out of scope for it — #10368 remains open and untouched.')),
4912+
null,
4913+
);
4914+
t(
4915+
'H21: the #10876 near-miss ("filed, not repaired here") is clean',
4916+
h21NegatedClosingKeyword(pr('## Out of scope — filed, not repaired here\n\nThe loader half stays open.')),
4917+
null,
4918+
);
4919+
t(
4920+
'H21: the #10851 near-miss ("filed, not fixed here") is clean',
4921+
h21NegatedClosingKeyword(pr('## The divergence the suite found — filed, not fixed here')),
4922+
null,
4923+
);
4924+
4925+
// Disjoint from H7: a number already declared `Part of` is H7's row.
4926+
t(
4927+
'H21: a `Part of #N` body is H7\'s row, not this one',
4928+
h21NegatedClosingKeyword(pr('Part of #77\n\nThis does not fix #77.')),
4929+
null,
4930+
);
4931+
t(
4932+
'H21: …while a DIFFERENT number in the same body is still this row\'s',
4933+
typeof h21NegatedClosingKeyword(pr('Part of #77\n\nFiled, not fixed: #88.')),
4934+
'string',
4935+
);
4936+
4937+
// Code stripping — inherited from H7, so a quoted keyword is not a finding.
4938+
t('H21: a keyword in backticks is clean', h21NegatedClosingKeyword(pr('Filed, not `fixed #10240`.')), null);
4939+
t('H21: a fenced keyword is clean', h21NegatedClosingKeyword(pr('not fixed:\n\n```\nFixes #10240\n```')), null);
4940+
t('H21: gerunds are not closing keywords', h21NegatedClosingKeyword(pr('This is not fixing #77.')), null);
4941+
t('H21: empty / missing body', h21NegatedClosingKeyword(pr(undefined)), null);
4942+
4943+
// The window itself, pinned directly — it is the whole design. Asserted as
4944+
// the TEXT the marker scan actually sees, not as an offset: the offset is an
4945+
// implementation detail, while "the previous sentence is not in the window"
4946+
// is the property the 13-false-positive measurement turns on.
4947+
const win = (text, idx) => text.slice(sentenceStartOffset(text, idx), idx);
4948+
t('H21 window: a sentence break bounds it', win('No. Fixes #1', 4), '');
4949+
t('H21 window: a blank line bounds it', win('not here\n\nFixes #1', 10), '');
4950+
// The structural boundary lands after the markdown marker, so the window is
4951+
// the heading's / item's own text — and the sentence BEFORE it is excluded.
4952+
t('H21 window: a heading line bounds it', win('not here\n## H\nFixes #1', 14), 'H\n');
4953+
t('H21 window: a list item bounds it', win('not here\n- item\nFixes #1', 16), 'item\n');
4954+
// ⛔ A plain newline is NOT a boundary: commit messages here are hard-wrapped
4955+
// at ~72 columns, so a sentence routinely spans lines on that corpus.
4956+
t('H21 window: a soft-wrapped newline does NOT bound it', sentenceStartOffset('not\nhere', 8), 0);
4957+
t(
4958+
'H21: …so a hard-wrapped negated close still fires',
4959+
typeof h21NegatedClosingKeyword(pr('Filed, not\nfixed: #10240')),
4960+
'string',
4961+
);
4962+
46454963
// -- H8: delivering PR merged, card still `pm:dispatched` (#8683) ----------
46464964
// Fixtures reuse H7's extractor pins, so the stripping and per-number-
46474965
// binding measurements carry over rather than being re-proved.

0 commit comments

Comments
 (0)