1313 * card-relation trailer at all: no closing keyword, no Part-of and no Refs
1414 * bound to any card number. The body is the only carrier of the relation.
1515 *
16+ * RULE 3 — THE BODY, NEGATED. A pull request may not bind a closing keyword
17+ * to a `#N` inside a sentence that reads as NOT closing it. This is the
18+ * half-state sweep's H21, called. It reads the same surface as RULE 1 and is
19+ * disjoint from it by construction.
20+ *
1621 * node scripts/check-partof-closing-keyword.mjs # judge this PR (CI)
1722 * node scripts/check-partof-closing-keyword.mjs --self-test # verify it offline
1823 *
134139 * block, which is the same argument the body's `env:` spelling rests on, applied
135140 * to a payload too big and too multi-line to be an environment value.
136141 *
142+ * ## RULE 3 — the same surface as RULE 1, and the half of the class it misses
143+ *
144+ * RULE 1's own rationale is stated in fully general terms: GitHub's parser
145+ * matches the keyword plus the number and ignores the surrounding prose
146+ * entirely, negations and modals included, so the sentence an author writes to
147+ * PREVENT an auto-close is exactly what performs it. Its PREDICATE is narrower
148+ * than that sentence — it is bound to a Part-of declaration and fires only when
149+ * the same number carries both. A body that declares Part of for nothing is
150+ * silent under RULE 1 however plainly it says the card stays open.
151+ *
152+ * RULE 3 is that missing half, and it is the sweep's H21 called rather than a
153+ * second rule invented here — the same no-fork posture the import note below
154+ * takes for RULE 1. It fires when a closing keyword is bound to a number the
155+ * body never declared itself part of, inside a SENTENCE carrying a negation or
156+ * filing marker. The trigger is the negation WINDOW and never keyword presence:
157+ * 277 of the 300 most recently merged bodies in the measurement behind H21
158+ * carry a closing keyword bound to a number, so a presence rule would red every
159+ * correct pull request in the corpus.
160+ *
161+ * Numbered 3 although it reads RULE 1's surface: renumbering the commit rule
162+ * would rewrite output text that this file's own self-test pins by name, for no
163+ * gain. Read the numbers as the order the rules were learned, not as a grouping
164+ * by surface.
165+ *
166+ * ## Why RULE 3 is here NOW, and not when H21 was written
167+ *
168+ * H21 shipped deliberately report-only, as a patrol row this gate did not
169+ * import, so that widening the class could not widen a check that fails builds
170+ * before the class had a baseline. The promotion rode a measurement rather than
171+ * a moment, and the measurement is what a reader of this section should know:
172+ *
173+ * - Over every pull request that was open at any point between H21 landing
174+ * and 2026-09-07 — 2,285 of them, every body read through the shipped
175+ * predicate — H21 flags three. None is a correct close wrongly refused.
176+ * - One of the three is a second specimen of the original incident, and it is
177+ * the argument for a blocking gate rather than a patrol row. A body said a
178+ * finding was filed rather than repaired, bound a closing keyword to that
179+ * card in the same sentence, and the card closed two seconds after the
180+ * merge — still carrying its bug and queue labels, i.e. a genuine unfixed
181+ * defect reading as finished.
182+ * - The patrol could not have caught it. That pull request was open for 41
183+ * minutes, entirely between two six-hourly sweeps. A report-only row is
184+ * structurally blind to a short-lived pull request; a PR-time gate is not.
185+ * That gap is not a discipline problem and no schedule fixes it.
186+ *
137187 * ## Exit codes — and why an empty body is a VERDICT, not a skip
138188 *
139189 * 0 judged, clean — BOTH rules, over inputs that were really read.
140- * 1 judged, finding. The PR is red until the body is reworded (RULE 1) or
141- * the relation is moved out of the commits and into the body (RULE 2).
190+ * 1 judged, finding. The PR is red until the body is reworded (RULE 1 and
191+ * RULE 3) or the relation is moved out of the commits and into the body
192+ * (RULE 2). All three findings share this exit: a body that negates the
193+ * relation it also states is the same contradiction class RULE 1 refuses,
194+ * so it is a new finding KIND and not a new exit code.
142195 * 2 NOT WIRED — an input this gate judges is missing, so a rule verified
143196 * nothing. A usage/wiring failure, never a statement about any PR.
144197 *
@@ -221,6 +274,7 @@ import process from 'node:process';
221274import {
222275 closingKeywordTargets ,
223276 h7PartOfWithClosingKeyword ,
277+ h21NegatedClosingKeyword ,
224278 partOfTargets ,
225279 refsTargets ,
226280} from './pm/check-half-states.mjs' ;
@@ -266,11 +320,12 @@ const SELF_TEST_BATTERIES = Object.freeze({
266320 'The commit list input. An absent, broken or empty list can never read' : 8 ,
267321 'The verdict layer over two rules: precedence, and the unread half is' : 5 ,
268322 'The wiring gathers the commit list and hands it over as a file path.' : 5 ,
323+ 'RULE 3 — a closing keyword bound to a card the sentence says it is NOT' : 17 ,
269324} ) ;
270325
271326// DELETING an entry silences that battery's floor exactly as effectively as
272327// zeroing it, so the roster's own size is pinned too.
273- const SELF_TEST_BATTERY_FLOOR = 15 ;
328+ const SELF_TEST_BATTERY_FLOOR = 16 ;
274329
275330// The key an assertion is filed under when no battery is open. It is not a
276331// declared battery, so it reds by the same set difference rather than silently
@@ -542,13 +597,17 @@ export function judge(ctx) {
542597
543598 const where = ctx . number ? `PR #${ ctx . number } ` : 'this PR' ;
544599 const contradiction = h7PartOfWithClosingKeyword ( { body : ctx . body } ) ;
600+ // RULE 3, the sweep's H21 called on the same body. Disjoint from RULE 1 by
601+ // construction — a number already declared Part of is RULE 1's, and H21 skips
602+ // it — so one number can never be reported twice in one verdict.
603+ const negated = h21NegatedClosingKeyword ( { body : ctx . body } ) ;
545604 const commitFindings = ctx . commits ? commitTrailerFindings ( ctx . commits ) : [ ] ;
546605
547606 // The unread half is named wherever it exists, on EVERY exit path — a run
548607 // that judged one surface must never present itself as one that judged both.
549608 const unread = ctx . commitsProblem ? [ ` ⚠️ RULE 2 judged nothing: ${ ctx . commitsProblem } ` ] : [ ] ;
550609
551- if ( contradiction || commitFindings . length ) {
610+ if ( contradiction || negated || commitFindings . length ) {
552611 const lines = [ ] ;
553612 if ( contradiction ) {
554613 lines . push (
@@ -563,8 +622,33 @@ export function judge(ctx) {
563622 ' inventory re-pull. Editing the body re-runs this check; no push and no re-run are needed.' ,
564623 ) ;
565624 }
566- if ( commitFindings . length ) {
625+ if ( negated ) {
567626 if ( contradiction ) lines . push ( '' ) ;
627+ lines . push (
628+ `::error::${ where } tells GitHub to close a card its own sentence says it is not closing: ${ negated } ` ,
629+ '' ,
630+ `✗ check:partof-closing-keyword: ${ where } tells GitHub to close a card its own sentence says it` ,
631+ ' is NOT closing.' ,
632+ '' ,
633+ ` ${ negated } ` ,
634+ '' ,
635+ ' Remedy, in order of preference: reword so that no closing keyword sits beside that number —' ,
636+ ' the sentence above names the three approved rewordings, and any of them says the same thing' ,
637+ ' to a reader while saying nothing to the parser. If the keyword must stay in the prose, put' ,
638+ ' it in BACKTICKS: a pull request body is markdown, the parser does not fire inside a code' ,
639+ ' span, and that was measured live rather than assumed. ⛔ Backticks are NOT the escape in a' ,
640+ ' commit message — nothing renders one, so they are ordinary characters there and RULE 2' ,
641+ ' above is the rule that surface answers to.' ,
642+ '' ,
643+ ' Why this is blocking rather than advisory: this body declares no `Part of`, so RULE 1 is' ,
644+ ' silent on it by construction, however plainly the sentence says the card stays open. The' ,
645+ ' close then lands SILENTLY on merge, on a card nobody is watching, and a closed card reads as' ,
646+ ' finished — the measured specimen closed a genuine unfixed defect two seconds after its merge.' ,
647+ ' Editing the body re-runs this check; no push and no re-run are needed.' ,
648+ ) ;
649+ }
650+ if ( commitFindings . length ) {
651+ if ( contradiction || negated ) lines . push ( '' ) ;
568652 for ( const finding of commitFindings ) lines . push ( `::error::${ finding } ` ) ;
569653 lines . push (
570654 '' ,
@@ -602,7 +686,8 @@ export function judge(ctx) {
602686 return {
603687 exit : EXIT_CLEAN ,
604688 lines : [
605- `✓ check:partof-closing-keyword: ${ where } ${ what } Part-of/closing-keyword contradiction, and its` ,
689+ `✓ check:partof-closing-keyword: ${ where } ${ what } Part-of/closing-keyword contradiction and no` ,
690+ ' closing keyword bound to a card its own sentence says it is not closing, and its' ,
606691 ` ${ ctx . commits . length } commit message(s) carry no card-relation trailer.` ,
607692 ] ,
608693 } ;
@@ -946,6 +1031,86 @@ function selfTest() {
9461031 true ,
9471032 ) ;
9481033
1034+ // --- RULE 3 — a closing keyword bound to a card the sentence says it is NOT
1035+ // closing. The sweep's H21, called. Every fixture below is a real sentence:
1036+ // the two positives are measured specimens quoted byte-for-byte, and the
1037+ // greens are the register the specimens live in, which the corpus behind H21
1038+ // found 116 times without one of them binding a keyword to a number. That
1039+ // ratio is the whole reason this rule reads a negation WINDOW and not keyword
1040+ // presence, and these cases are what stops a later edit from widening it.
1041+ battery ( 'RULE 3 — a closing keyword bound to a card the sentence says it is NOT' ) ;
1042+ const negatedOut = verdict ( '## Out of scope\n\nFiled, not fixed: #10240 — the same leak through the delete verb.' ) ;
1043+ const negatedText = negatedOut . lines . join ( '\n' ) ;
1044+ t ( 'the incident specimen is refused, and under the existing finding exit' , negatedOut . exit , EXIT_CONTRADICTION ) ;
1045+ t (
1046+ 'the second specimen is refused too (measured 2026-08-24, a card closed 2s after its merge)' ,
1047+ verdict ( 'One adjacent finding filed rather than fixed: #11745' ) . exit ,
1048+ EXIT_CONTRADICTION ,
1049+ ) ;
1050+ t ( 'the control passes: an ordinary closing body with no negation is clean' , verdict ( 'Fixes #10171' ) . exit , EXIT_CLEAN ) ;
1051+ t (
1052+ 'the specimen REGISTER stays clean when it binds no keyword to a number (the near miss)' ,
1053+ verdict ( '## Out of scope — filed, not repaired here\n\nThe delete verb keeps its own card.' ) . exit ,
1054+ EXIT_CLEAN ,
1055+ ) ;
1056+ t (
1057+ '…and its other measured spelling stays clean for the same reason' ,
1058+ verdict ( 'The adjacent leak is filed, not fixed here.' ) . exit ,
1059+ EXIT_CLEAN ,
1060+ ) ;
1061+ t (
1062+ 'a multi-card close list under an earlier negation is clean (the sentence bound, measured)' ,
1063+ verdict ( 'This does not change the loader.\n\nFixes #10581\nFixes #10582\nFixes #10583' ) . exit ,
1064+ EXIT_CLEAN ,
1065+ ) ;
1066+ t (
1067+ 'a markdown structural line start bounds the window, so the negation does not reach the keyword' ,
1068+ verdict ( 'This does not touch the loader.\n\n### Closing lines\n\nFixes #10581' ) . exit ,
1069+ EXIT_CLEAN ,
1070+ ) ;
1071+ t (
1072+ 'backticks ARE the escape on this surface, so an author explaining the keyword is not taxed' ,
1073+ verdict ( 'Out of scope — this body deliberately does not say `Fixes #10240` in prose.' ) . exit ,
1074+ EXIT_CLEAN ,
1075+ ) ;
1076+ t (
1077+ "a number already declared Part of is RULE 1's row: RULE 3 does not double-report it" ,
1078+ h21NegatedClosingKeyword ( { body : 'Part of #8131 — this does not close #8131 yet.' } ) ,
1079+ null ,
1080+ ) ;
1081+ t (
1082+ '…and that body is still refused, by RULE 1, so the disjointness costs no coverage' ,
1083+ verdict ( 'Part of #8131 — this does not close #8131 yet.' ) . exit ,
1084+ EXIT_CONTRADICTION ,
1085+ ) ;
1086+ const negatedBodies = [
1087+ 'Filed, not fixed: #10240 — the same leak through the delete verb.' ,
1088+ 'Fixes #10171' ,
1089+ 'Part of #8131 — this does not close #8131 yet.' ,
1090+ 'This does not change the loader.\n\nFixes #10581' ,
1091+ '' ,
1092+ 'Out of scope: closes #77.' ,
1093+ ] ;
1094+ t (
1095+ 'the verdict is exactly the two shipped predicates over every fixture (no forked rule)' ,
1096+ negatedBodies . every (
1097+ ( body ) =>
1098+ ( judge ( { number : '1' , body, commits : CLEAN_COMMITS , commitsProblem : null } ) . exit === EXIT_CONTRADICTION ) ===
1099+ ( h7PartOfWithClosingKeyword ( { body } ) !== null || h21NegatedClosingKeyword ( { body } ) !== null ) ,
1100+ ) ,
1101+ true ,
1102+ ) ;
1103+ t ( 'the failure names the "not addressed here" rewording' , negatedText . includes ( '#10240 is not addressed here' ) , true ) ;
1104+ t ( 'the failure names the "out of scope" rewording' , negatedText . includes ( 'out of scope: #10240' ) , true ) ;
1105+ t ( 'the failure names the "remains open" rewording' , negatedText . includes ( '#10240 remains open' ) , true ) ;
1106+ t ( 'the failure names the backtick escape, which is valid on THIS surface' , negatedText . includes ( 'BACKTICKS' ) , true ) ;
1107+ t (
1108+ '…and says in the same breath that backticks are NOT the escape in a commit message' ,
1109+ negatedText . includes ( 'Backticks are NOT the escape in a' ) ,
1110+ true ,
1111+ ) ;
1112+ t ( 'the RULE 3 failure is annotated for the GitHub UI' , negatedText . includes ( '::error::' ) , true ) ;
1113+
9491114 // The floor runs BEFORE the verdict below, so a success line can only be
9501115 // printed by a run in which every declared battery registered its cases.
9511116 for ( const message of batteryFloorFailures ( ) ) cases . push ( [ message , false , true ] ) ;
0 commit comments