Skip to content

Commit 66cbfa6

Browse files
os-litantclaude
andauthored
feat(gates): give the closing-keyword parity gate a --body pre-flight mode (#17552)
* feat(gates): give the closing-keyword parity gate a --body mode There was no runnable instrument a PR author could point at a body to prove its closing keywords before the PR exists. The one grammar this repo grades for agreement is spelled three times and every spelling is private to its own file, so the only enforcement that reads a body is the blocking gate that fires after the body is written. This gate already builds the three shipped parsers as real RegExps from the shipped bytes. `--body <FILE|->` runs exactly those over one body and prints every hit with the parser id and its reference scope. No fourth spelling is introduced -- a transcribed grammar is the defect the sweep exists to catch, and it would go red on one. Exit register, documented in the header: 0 when no registered parser binds anything, 2 when at least one binds (the report, not a failure), 1 when the instrument did not run -- no path, an unreadable input, or a parser that could not be extracted. The case it exists for is a sentence written to prevent a close that arms one, because no parser reads negation. Six new self-test cases cover it: the negation body, a `Part of` body, the colon spelling on a qualified reference, and two end-to-end drives of the mode through a real process (a file and stdin) that grade the exit code. Claude-Session: https://claude.ai/code/session_01YKEjmbYNvYWJvWGSWx26zK Co-authored-by: Claude <noreply@anthropic.com> * fix(gates): drive the body-mode self-test against THIS file, not the cwd's copy The two end-to-end cases spawned `join(root, SELF)`, and `root` comes from `repoRoot()` — the cwd. Launched from a sibling checkout the battery therefore spawned THAT tree's copy of the script and graded a file nobody had edited: measured, it reported `got 3` (the dependency-prerequisite exit of an uninstalled sibling) instead of the exit register under test. A harness that verified the wrong subject has verified nothing. The child is now `fileURLToPath(import.meta.url)`, so the process under test is the process that is running. Claude-Session: https://claude.ai/code/session_01YKEjmbYNvYWJvWGSWx26zK Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 245f360 commit 66cbfa6

1 file changed

Lines changed: 159 additions & 1 deletion

File tree

‎scripts/check-closing-keyword-parity.mjs‎

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
* node scripts/check-closing-keyword-parity.mjs # judge the shipped parsers
88
* node scripts/check-closing-keyword-parity.mjs --self-test # prove the battery can go red
99
* node scripts/check-closing-keyword-parity.mjs --list # the registry
10+
* node scripts/check-closing-keyword-parity.mjs --body FILE # what binds in ONE body ("-" reads stdin)
1011
*
1112
* ## The seam
1213
*
@@ -51,6 +52,40 @@
5152
* and those differences are asserted too, so that a widening of the separator
5253
* cannot quietly widen the scope with it.
5354
*
55+
* ## The body mode -- the same three parsers, one body
56+
*
57+
* `--body FILE` (or `-` for stdin) asks a different question of the SAME
58+
* parsers: not "do the three agree" but "which of them binds what in THIS
59+
* text". It is a pre-flight instrument for a PR body, runnable BEFORE the PR
60+
* exists -- otherwise the only enforcement that reads a body is the blocking
61+
* gate behind partof-closing-keyword-guard.yml, which fires after the body is
62+
* already written.
63+
*
64+
* It adds NO grammar. It calls `loadParsers()` and runs whatever that returns,
65+
* because a fourth spelling transcribed into a body checker is precisely the
66+
* defect the sweep below exists to catch. Nor does it collapse the answer: the
67+
* three differ on purpose in REFERENCE SCOPE, so a bare reference binding in
68+
* two of them and not the third IS the answer, per parser, not noise.
69+
*
70+
* The case it exists for is prose written to PREVENT a close that arms one.
71+
* A sentence of the shape `Merging this must not <verb> #N` binds in every
72+
* parser scoped to the bare form, because none of them reads negation -- and
73+
* neither does GitHub's own parser (the #10241 specimen below was closed out of
74+
* a sentence saying it was not). The safe spelling is zero verbs beside the
75+
* number. This mode makes that visible; it decides nothing and blocks nothing.
76+
*
77+
* Exit register -- three answers, deliberately distinct:
78+
*
79+
* 0 No registered parser binds anything in the body.
80+
* 2 At least one parser binds. NOT a failure: it is the report, and the
81+
* caller decides. It is 2 and not 1 because 1 already means this gate
82+
* could not do its job, and "your body declares a close" must never read
83+
* as "the instrument broke".
84+
* 1 The instrument did not run: no path given, an unreadable file, or a
85+
* parser that could not be extracted -- #4690 again, a harness that could
86+
* not find its subject has verified nothing. Never a quiet zero-hit
87+
* answer.
88+
*
5489
* ## What is asserted
5590
*
5691
* 1. EXTRACTION. Every registered parser is found in the shipped bytes. A
@@ -98,8 +133,10 @@
98133
// dispatch-gates: whole-tree-population -- the sweep reads every tracked file (node_modules and dist aside) hunting the closing-keyword grammar, so a card adding prose or a workflow anywhere implicates it; the literals below are the parsers it grades.
99134

100135
import { execFileSync } from 'node:child_process';
101-
import { readFileSync, statSync } from 'node:fs';
136+
import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
137+
import { tmpdir } from 'node:os';
102138
import { join } from 'node:path';
139+
import { fileURLToPath } from 'node:url';
103140
import { requireDependency } from './import-prerequisite.mjs';
104141
const { parseDocument } = await requireDependency('yaml', () => import('yaml'), import.meta.url);
105142
import { isEntrypoint } from './invoked-as.mjs';
@@ -335,6 +372,26 @@ export function judge(parsers, swept) {
335372
return failures;
336373
}
337374

375+
/**
376+
* Every hit the registered parsers bind in ONE body, in registry order.
377+
*
378+
* The answer stays per parser and is never collapsed: the three differ on
379+
* purpose in REFERENCE SCOPE, so WHICH one binds is the whole point -- a bare
380+
* reference is invisible to the cross-repo closer and decisive to H7, and an
381+
* author needs to know which of those two facts they are looking at.
382+
*
383+
* Parsers carrying a `problem` are skipped here and reported by the caller: an
384+
* extraction failure is exit 1, never a zero-hit answer (#4690).
385+
*/
386+
export function judgeBody(parsers, body) {
387+
const hits = [];
388+
for (const p of parsers) {
389+
if (p.problem) continue;
390+
for (const m of body.matchAll(p.make())) hits.push({ id: p.id, scope: p.scope, text: m[0] });
391+
}
392+
return hits;
393+
}
394+
338395
// ── CLI ──────────────────────────────────────────────────────────────────────
339396

340397
function list() {
@@ -343,6 +400,47 @@ function list() {
343400
console.log(`\n${PARSERS.length} parsers + ${NON_PARSERS.length} known non-parser(s)`);
344401
}
345402

403+
/** `--body <FILE|->`: run the loaded parsers over one body. See the header. */
404+
function bodyMode(pathOrDash) {
405+
if (!pathOrDash) {
406+
console.error(`usage: node ${SELF} --body <FILE> ("-" reads the body from stdin)`);
407+
console.error('No body was read, so nothing was verified.');
408+
return 1;
409+
}
410+
const where = pathOrDash === '-' ? 'stdin' : pathOrDash;
411+
let body;
412+
try {
413+
body = readFileSync(pathOrDash === '-' ? 0 : pathOrDash, 'utf8');
414+
} catch (err) {
415+
console.error(`check-closing-keyword-parity --body: cannot read ${where} -- ${err.message}`);
416+
return 1;
417+
}
418+
419+
const parsers = loadParsers(repoRoot());
420+
const hits = judgeBody(parsers, body);
421+
for (const h of hits) console.log(`${h.id.padEnd(28)} ${`(${h.scope})`.padEnd(13)} ${h.text}`);
422+
423+
const broken = parsers.filter((p) => p.problem);
424+
if (broken.length > 0) {
425+
console.error(`\ncheck-closing-keyword-parity --body: ${broken.length} of ${parsers.length} parser(s) could not be extracted, so ${where} was NOT fully read:\n`);
426+
for (const p of broken) console.error(` • [${p.id}] ${p.problem}`);
427+
console.error(`\nRegistry: node ${SELF} --list`);
428+
return 1;
429+
}
430+
431+
if (hits.length === 0) {
432+
console.log(`check-closing-keyword-parity --body: OK (no registered parser binds a closing declaration in ${where}; ${parsers.length} parsers consulted).`);
433+
return 0;
434+
}
435+
const ids = new Set(hits.map((h) => h.id));
436+
console.log(
437+
`\ncheck-closing-keyword-parity --body: ${hits.length} closing declaration(s) in ${where}, `
438+
+ `bound by ${ids.size} of ${parsers.length} registered parser(s). Merging a PR with this body acts on them; `
439+
+ 'the safe spelling for a reference you do NOT want acted on is zero verbs beside the number.',
440+
);
441+
return 2;
442+
}
443+
346444
function run() {
347445
const root = repoRoot();
348446
const parsers = loadParsers(root);
@@ -472,6 +570,65 @@ function selfTest() {
472570
'X7: a registered file that stopped carrying the grammar is caught',
473571
);
474572

573+
// 5. The BODY MODE, on the fixtures sections 2-4 already grade. The
574+
// references come from `REFS` and the refusals from `SCOPES`, so a case
575+
// here cannot drift away from what this file asserts about the same three
576+
// parsers -- and each expectation is the parser SET, because the per-parser
577+
// scope split is the answer the mode exists to show.
578+
const loaded = loadParsers(root);
579+
const bound = (text) => [...new Set(judgeBody(loaded, text).map((h) => h.id))].sort().join(',');
580+
581+
// The negation trap, the case the mode exists for: prose written to PREVENT
582+
// a close still binds, in every parser scoped to the bare form.
583+
const negation = `Merging this must not close ${REFS.bare}`;
584+
assert(
585+
bound(negation) === 'duplicate-fix-guard,h7-partof-closing-keyword',
586+
`X8: the negation body binds in both bare-scoped parsers and not the qualified one, got: ${bound(negation) || 'nothing'}`,
587+
);
588+
// `Part of` is a reference, not a close -- 4b above asserts the same thing
589+
// about the same string, from the parser side.
590+
assert(bound(`Part of ${REFS.bare}`) === '', `X9: a \`Part of\` body binds nothing, got: ${bound(`Part of ${REFS.bare}`)}`);
591+
// The colon spelling, on the qualified reference: the two parsers that take
592+
// a qualified ref bind it, H7 does not.
593+
const colon = `${KEYWORDS[4]}: ${REFS.qualified}`;
594+
assert(
595+
bound(colon) === 'cross-repo-issue-closer,duplicate-fix-guard',
596+
`X10: the colon spelling on a qualified ref binds in the two qualified-capable parsers, got: ${bound(colon) || 'nothing'}`,
597+
);
598+
599+
// 6. The mode END TO END -- argv switch, input read, exit register. The
600+
// assertions above drive `judgeBody` directly and would stay green if
601+
// `bodyMode` handed it the wrong text (an empty string, say), so this leg
602+
// runs a real process over a real input and grades the exit code. Both
603+
// input spellings and both non-error exits are covered.
604+
//
605+
// The child is THIS file (`import.meta.url`), never `join(root, SELF)`:
606+
// `root` comes from the cwd, so a self-test launched from a sibling
607+
// checkout would spawn THAT tree's copy and grade a script nobody edited
608+
// -- the #4690 shape again, a harness that verified the wrong subject.
609+
const dir = mkdtempSync(join(tmpdir(), 'closing-keyword-body-'));
610+
try {
611+
const selfPath = fileURLToPath(import.meta.url);
612+
const drive = (args, input) => {
613+
try {
614+
return { status: 0, out: execFileSync(process.execPath, [selfPath, ...args], { cwd: root, encoding: 'utf8', input: input ?? '', stdio: ['pipe', 'pipe', 'pipe'] }) };
615+
} catch (err) {
616+
return { status: err.status, out: `${err.stdout ?? ''}${err.stderr ?? ''}` };
617+
}
618+
};
619+
620+
const file = join(dir, 'negation.md');
621+
writeFileSync(file, negation);
622+
const hit = drive(['--body', file]);
623+
assert(hit.status === 2, `X11: \`--body FILE\` exits 2 on the negation body, got ${hit.status}`);
624+
assert(hit.out.includes('h7-partof-closing-keyword'), `X11: the exit-2 report names the parser that binds, got: ${hit.out.trim() || 'nothing'}`);
625+
626+
const clean = drive(['--body', '-'], `Part of ${REFS.bare}`);
627+
assert(clean.status === 0, `X12: \`--body -\` exits 0 on a body that declares no close, got ${clean.status}`);
628+
} finally {
629+
rmSync(dir, { recursive: true, force: true });
630+
}
631+
475632
if (failures.length === 0) {
476633
console.log(`✓ check-closing-keyword-parity --self-test: ${checked} assertions, ${MUTATIONS.length} mutations of the shipped parsers each driven to red.`);
477634
selfTestReachedVerdict = true;
@@ -486,6 +643,7 @@ function selfTest() {
486643
if (isEntrypoint(import.meta.url)) {
487644
const arg = process.argv[2];
488645
if (arg === '--list') list();
646+
else if (arg === '--body') process.exit(bodyMode(process.argv[3]));
489647
else if (arg === '--self-test') {
490648
const code = selfTest();
491649
if (!selfTestReachedVerdict) {

0 commit comments

Comments
 (0)