Skip to content

Commit a9d25d8

Browse files
claude[bot]claude
andauthored
feat(lint): refuse a body that binds a closing keyword to a card its own sentence says it is not closing (H21 promotion) (#16444)
* feat(lint): refuse a body that binds a closing keyword to a card its own sentence says it is not closing Promotes the half-state sweep's negation-window predicate from a report-only patrol row to this blocking PR gate, as a new finding kind under the existing contradiction exit. The predicate is imported, not re-spelled: the gate already delegates its Part-of rule to the same module, and a second copy of the grammar is the thing the parity guard exists to prevent. The gate side is the whole change. The predicate, its corpus and its markers are untouched, and the wiring workflow needs no new input -- it already hands over the body this rule reads. Seventeen self-test cases land with it, in their own registered battery: two measured specimens quoted byte-for-byte, the same-register bodies that must stay green, the sentence-window bounds, the backtick escape, and the disjointness that keeps one card number from being reported by two rules at once. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox * test(lint): give the new battery's annotation case a name of its own Two batteries carried the same case name, so a red on either printed a line that did not say which rule stopped working -- the exact ambiguity the battery roster exists to remove. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019RfFHiRCSs3JXLK4cwcfox --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 32e57e5 commit a9d25d8

1 file changed

Lines changed: 171 additions & 6 deletions

File tree

scripts/check-partof-closing-keyword.mjs

Lines changed: 171 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@
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
*
@@ -134,11 +139,59 @@
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';
221274
import {
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

Comments
 (0)