From 08a11b5102b25b22e76596f81d38aea16ca90150 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 17:43:34 +0000 Subject: [PATCH] fix(ci): make the cross-repo closer accept `Fixes:` like the duplicate-fix guard The repo parses GitHub's closing-keyword grammar in three places and two of them disagreed about the optional colon. `duplicate-fix-guard.yml` accepted it, `cross-repo-issue-closer.yml` did not, so a merged PR written `Fixes: objectstack-ai/objectui#456` took the closer's exit path 1 -- the same quiet green a body with no cross-repo reference at all produces -- and the foreign issue was lost with no notice on the PR and no annotation anywhere. The guard's comment claimed the colon is GitHub syntax; that claim decided the direction of the fix and was verified rather than inherited. It is true, and the reference is in this repository: PR #10241 merged 2026-08-20T15:10:06Z carrying a `not fixed:` sentence bound to issue #10240, and that issue closed as `completed` at 15:10:08Z with its own closing-link summary naming #10241 alone. So the closer was under-matching and is widened; the guard was already right. Also adds the durable half. A shared module is unavailable -- neither workflow checks the repo out -- so `scripts/check-closing-keyword-parity.mjs` extracts all three parsers from the shipped bytes and asserts they agree behaviourally on the keyword set and the separator, while still refusing what each is scoped to refuse, and sweeps every tracked file for an unregistered fourth. The existing closer pin was blind to this: adding `:?` moved real behaviour and all 105 assertions stayed green, because no scenario had ever put a colon in front of a qualified reference. P1 now carries the spelling and M15 restores the defect. Fixes #9755 --- .github/workflows/cross-repo-issue-closer.yml | 28 +- .github/workflows/lint.yml | 28 + scripts/check-closing-keyword-parity.mjs | 477 ++++++++++++++++++ scripts/check-cross-repo-closer-outcome.mjs | 41 +- 4 files changed, 571 insertions(+), 3 deletions(-) create mode 100644 scripts/check-closing-keyword-parity.mjs diff --git a/.github/workflows/cross-repo-issue-closer.yml b/.github/workflows/cross-repo-issue-closer.yml index fd162a10a5..b82103bcb0 100644 --- a/.github/workflows/cross-repo-issue-closer.yml +++ b/.github/workflows/cross-repo-issue-closer.yml @@ -190,9 +190,35 @@ jobs: // GitHub's own keyword set, restricted to the qualified // `owner/repo#N` form — the bare `#N` form already works natively // and must not be touched here. + // + // The `:?` is load-bearing, and MEASURED rather than inherited from + // the sibling file's comment (#9755). GitHub's own parser accepts + // the colon, so `Fixes: ` is a real closing declaration and not + // a typo: PR #10241 merged 2026-08-20T15:10:06Z carrying the + // sentence `Filed, not fixed: #10240`, and GitHub closed #10240 as + // `completed` at 15:10:08Z — two seconds later, with that issue's + // own `closed_by_pull_requests` naming #10241 and nothing else. The + // negation in the prose changed nothing, which is the same property + // partof-closing-keyword-guard.yml is built on. + // + // Without the `:?` this job read `Fixes: objectstack-ai/objectui#456` + // as `No cross-repository closing keywords in this PR body.` and + // took exit path 1 — the quiet green that is indistinguishable from + // the ~2300 runs with genuinely nothing to do. A foreign issue is + // then lost with no notice on the PR and no annotation anywhere, + // which is the exact silent-path shape this file's card family + // (#9373 · #9424 · #9575 · #9595 · #9643 · #9711) has been closing + // one exit at a time. + // + // duplicate-fix-guard.yml spells this same keyword-and-separator + // prefix, and `scripts/check-closing-keyword-parity.mjs` goes red if + // the two ever stop agreeing. NEITHER workflow checks this repo out + // — this one deliberately (`pull_request_target`), the guard because + // it has no checkout step at all — so they cannot import a shared + // module. That gate is what stands in for the import. const KEYWORDS = 'close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved'; const pattern = new RegExp( - `\\b(?:${KEYWORDS})\\s+([\\w.-]+)\\/([\\w.-]+)#(\\d+)\\b`, + `\\b(?:${KEYWORDS}):?\\s+([\\w.-]+)\\/([\\w.-]+)#(\\d+)\\b`, 'gi', ); diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index ffd456af09..bb0c37ed2f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1110,6 +1110,34 @@ jobs: node scripts/check-cross-repo-closer-outcome.mjs --self-test node scripts/check-cross-repo-closer-outcome.mjs + # Closing-keyword parser parity (#9755). THREE files parse GitHub's + # closing-keyword grammar out of user-authored markdown — the cross-repo + # closer, the duplicate-fix guard, and H7's `closingKeywordRe()` behind + # partof-closing-keyword-guard.yml — and they had drifted: the guard + # accepted `Fixes: ` and the closer silently did not, so a merged PR + # written with the colon took the closer's quiet green exit and lost its + # foreign issue. + # + # A shared module is the right shape and is unavailable: neither workflow + # checks the repo out (the closer deliberately, on `pull_request_target`; + # the guard because its whole job is one github-script step), so + # `require()` resolves to nothing in either. This gate is what stands in + # for the import — it extracts all three parsers from the shipped bytes + # and asserts they agree BEHAVIOURALLY on the keyword set and the + # separator, while still refusing what each is scoped to refuse. It also + # sweeps all tracked files for a FOURTH parser, because "fix these two" + # is exactly the change that would miss one. + # + # Invoked as `node` rather than a `pnpm check:*` alias for the same reason + # as the step above: that alias belongs in root package.json, declared + # territory of the @changesets/cli v3 migration lane (#9465) while it + # runs. dispatch-gates.mjs derives gate families from either spelling. + # No network, no build; ~1 s, most of it the tracked-file sweep. + - name: Closing-keyword parser parity + run: | + node scripts/check-closing-keyword-parity.mjs --self-test + node scripts/check-closing-keyword-parity.mjs + # Merge-queue triage outcome contract (#10128, over the #4859 bot). # merge-queue-triage.yml carries ~450 lines of inline github-script that # only ever runs on a RED merge_group build — a condition nobody can diff --git a/scripts/check-closing-keyword-parity.mjs b/scripts/check-closing-keyword-parity.mjs new file mode 100644 index 0000000000..0e0b26ce08 --- /dev/null +++ b/scripts/check-closing-keyword-parity.mjs @@ -0,0 +1,477 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Closing-keyword parser parity guard (#9755). + * + * node scripts/check-closing-keyword-parity.mjs # judge the shipped parsers + * node scripts/check-closing-keyword-parity.mjs --self-test # prove the battery can go red + * node scripts/check-closing-keyword-parity.mjs --list # the registry + * + * ## The seam + * + * THREE places in this repository parse GitHub's closing-keyword grammar out of + * user-authored markdown, and each one decides something a human would rather + * not have decided wrongly: + * + * - `.github/workflows/cross-repo-issue-closer.yml` decides whether an issue + * in ANOTHER repository gets closed; + * - `.github/workflows/duplicate-fix-guard.yml` decides whether a PR goes RED + * for claiming an issue another open PR already claimed; + * - `closingKeywordRe()` in `scripts/pm/check-half-states.mjs` (H7, the + * blocking gate behind `.github/workflows/partof-closing-keyword-guard.yml`) + * decides whether a `Part of #N` PR is about to close the card it is only + * part of. + * + * They are three spellings of one judgement, and they HAVE drifted. Measured on + * 2026-08-20, `Fixes: objectstack-ai/objectui#456` matched the duplicate-fix + * guard and did NOT match the cross-repo closer, so a merged PR written with the + * colon took the closer's exit path 1 -- `No cross-repository closing keywords + * in this PR body.` -- the same quiet green a body with no cross-repo reference + * at all produces. The foreign issue stays open and nothing distinguishes the + * run from the ~2300 that genuinely had nothing to do. + * + * ## Why a gate and not a shared module + * + * A shared module is the right shape and is NOT AVAILABLE here. Both parsers + * that diverged live inside `actions/github-script` blocks, and neither workflow + * checks this repository out: the closer deliberately never does (it runs on + * `pull_request_target`, and its header makes the no-checkout posture an + * invariant rather than an accident), and the duplicate-fix guard has no + * checkout step at all -- its whole job is one github-script step. `require()` + * of a repo file therefore resolves to nothing in either. Giving both a checkout + * to share ten characters of regex would buy the import by spending the closer's + * stated security posture, which is the worse trade. + * + * So the text stays spelled three times and this gate makes the JUDGEMENT one: + * it extracts all three parsers from the shipped bytes and asserts they agree + * BEHAVIOURALLY -- same keyword set, same separator -- rather than asserting the + * three regexes are the same string, which they legitimately are not. The three + * differ on purpose in REFERENCE SCOPE (qualified-only / same-repo / bare-only), + * and those differences are asserted too, so that a widening of the separator + * cannot quietly widen the scope with it. + * + * ## What is asserted + * + * 1. EXTRACTION. Every registered parser is found in the shipped bytes. A + * parser that cannot be extracted is a FAILURE, never a skip (#4690): a + * harness that could not find its subject has verified nothing. + * 2. KEYWORD PARITY. All nine of GitHub's keywords -- close/closes/closed, + * fix/fixes/fixed, resolve/resolves/resolved -- qualify in every parser. + * 3. SEPARATOR PARITY. Every parser accepts BOTH measured spellings of the + * separator: `KEYWORD ` and `KEYWORD: `. + * 4. SCOPE INVARIANTS. Each parser still refuses what it is supposed to refuse + * -- including with the colon present, so widening the separator cannot + * widen the scope as a side effect. + * 5. THE SWEEP. No FOURTH parser of this grammar exists unregistered. This + * card came out of a sweep for other consumers; a later one is exactly what + * a "fix these two" change would miss, so the sweep is mechanical now. + * + * ## The colon is measured, not inherited + * + * The duplicate-fix guard's comment asserted that the optional colon is part of + * GitHub's accepted syntax. A comment is not evidence, and the whole direction + * of the #9755 fix rested on it -- if GitHub did NOT accept the colon then the + * GUARD was over-matching and the edit pointed the other way. It does accept it, + * and the reference is in this repository: + * + * PR #10241 merged 2026-08-20T15:10:06Z with the sentence + * `Filed, not fixed: #10240` in its body. Issue #10240 closed as `completed` + * at 15:10:08Z -- two seconds later -- and its `closed_by_pull_requests` + * names #10241 and nothing else. + * + * That specimen settles two things at once: the colon binds (GitHub parsed + * `fixed: #10240` as a closing declaration), and the surrounding prose does not + * (the sentence said the issue was NOT fixed and it was closed anyway, which is + * the property partof-closing-keyword-guard.yml is built on). + * + * ## Deliberately NOT asserted + * + * The zero-space spelling `Fixes:#123`, on which the three parsers still + * disagree (H7 accepts it; the two workflows require whitespace after the + * colon), and the newline separator, which the workflows' `\s+` crosses and + * H7's `[ \t]` deliberately does not. Neither is MEASURED against GitHub's real + * parser, and this gate pins only what has been. Filed rather than guessed -- + * pinning an unmeasured spelling here would launder a guess into a contract. + */ + +import { execFileSync } from 'node:child_process'; +import { readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { parseDocument } from 'yaml'; +import { isEntrypoint } from './invoked-as.mjs'; + +const SELF = 'scripts/check-closing-keyword-parity.mjs'; + +/** GitHub's closing keywords, all nine. */ +const KEYWORDS = ['close', 'closes', 'closed', 'fix', 'fixes', 'fixed', 'resolve', 'resolves', 'resolved']; + +/** + * A reference of each kind, and which parsers are supposed to bind to it. + * + * `same-repo` names the guard's regex scope, not its final verdict: the regex + * binds any `owner/repo#N` and the guard drops foreign owners in JS afterwards. + * Scope here is a property of the REGEX, which is what can drift. + */ +const REFS = { + bare: '#22', + qualified: 'my-org/some.repo#22', +}; + +const SCOPES = { + // The closer acts only on the qualified form; the bare form is GitHub's own + // job and closing it here would comment on every merge. + qualified: { binds: ['qualified'], refuses: ['bare'] }, + // The guard's regex takes both; ownership is filtered after the match. + 'same-repo': { binds: ['bare', 'qualified'], refuses: [] }, + // H7 asks only "is this card being closed", which is always same-repo bare. + bare: { binds: ['bare'], refuses: ['qualified'] }, +}; + +// ── The registry ───────────────────────────────────────────────────────────── + +const PARSERS = [ + { + id: 'cross-repo-issue-closer', + file: '.github/workflows/cross-repo-issue-closer.yml', + job: 'close-foreign-issues', + kind: 'workflow', + scope: 'qualified', + }, + { + id: 'duplicate-fix-guard', + file: '.github/workflows/duplicate-fix-guard.yml', + job: 'duplicate-fix-guard', + kind: 'workflow', + scope: 'same-repo', + }, + { + id: 'h7-partof-closing-keyword', + file: 'scripts/pm/check-half-states.mjs', + kind: 'module', + scope: 'bare', + }, +]; + +/** + * Files that carry the grammar's signature but are NOT parsers, with the reason. + * + * Kept as a named list rather than a pattern so that adding one is a decision + * somebody wrote down. The sweep fails on anything here that no longer matches, + * for the same reason a mutation with a dead anchor fails: an exemption for a + * file that stopped matching is an exemption nobody is checking. + */ +const NON_PARSERS = [ + { + file: 'scripts/check-cross-repo-closer-outcome.mjs', + why: 'the closer\'s outcome harness -- it carries the keyword set as MUTATION ANCHORS (M5, M15), not as a parser of its own', + }, + { + file: SELF, + why: 'this gate -- it carries the keyword set as its own MUTATION ANCHORS (X4) and as the specimen the sweep self-test plants', + }, +]; + +// ── Extraction ─────────────────────────────────────────────────────────────── + +export function repoRoot() { + return execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim(); +} + +/** The inline github-script body of a named job, or a problem string. */ +function workflowScript(text, jobId, file) { + const doc = parseDocument(text); + const steps = doc.getIn(['jobs', jobId, 'steps'], true); + if (!steps || typeof steps.items === 'undefined') { + return { problem: `${file}: no job \`${jobId}\` with steps -- renamed? The parser cannot be located.` }; + } + for (const step of steps.items) { + const uses = String(step.getIn?.(['uses']) ?? ''); + if (!uses.startsWith('actions/github-script')) continue; + const script = step.getIn?.(['with', 'script']); + if (typeof script === 'string' && script.trim() !== '') return { source: script }; + } + return { problem: `${file}: job \`${jobId}\` has no github-script step with a non-empty \`script:\`.` }; +} + +/** + * Build the real RegExp a parser ships, from the shipped text. + * + * The workflow patterns are TEMPLATE LITERALS interpolating `KEYWORDS`, so the + * literal is evaluated the way the script evaluates it rather than string- + * matched: a `\\b` in the file is a `\b` in the pattern, and only evaluation + * gets that right. + */ +function parserFrom(entry, text) { + if (entry.kind === 'workflow') { + const got = workflowScript(text, entry.job, entry.file); + if (got.problem) return { problem: got.problem }; + const src = got.source; + const kw = /const KEYWORDS = '([^']+)';/.exec(src); + if (!kw) return { problem: `${entry.file}: no \`const KEYWORDS = '...';\` in the inline script.` }; + const tpl = /const pattern = new RegExp\(\s*(`(?:[^`\\]|\\.)*`)\s*,\s*'gi'\s*,?\s*\)/.exec(src); + if (!tpl) return { problem: `${entry.file}: no \`const pattern = new RegExp(\`...\`, 'gi')\` in the inline script.` }; + let source; + try { + source = new Function('KEYWORDS', `return ${tpl[1]};`)(kw[1]); + } catch (err) { + return { problem: `${entry.file}: the pattern template did not evaluate -- ${err.message}` }; + } + return { keywords: kw[1].split('|'), source, make: () => new RegExp(source, 'gi') }; + } + + const lit = /function closingKeywordRe\(\)\s*\{[\s\S]*?return\s+(\/(?:[^/\\\n]|\\.)*\/[gimsuy]*)\s*;/.exec(text); + if (!lit) return { problem: `${entry.file}: no \`function closingKeywordRe()\` returning a regex literal.` }; + let re; + try { + re = new Function(`return ${lit[1]};`)(); + } catch (err) { + return { problem: `${entry.file}: the regex literal did not evaluate -- ${err.message}` }; + } + return { keywords: null, source: re.source, make: () => new RegExp(re.source, re.flags.includes('g') ? re.flags : `${re.flags}g`) }; +} + +export function loadParsers(root, overrides = {}) { + return PARSERS.map((entry) => { + const text = overrides[entry.file] ?? readFileSync(join(root, entry.file), 'utf8'); + return { ...entry, ...parserFrom(entry, text) }; + }); +} + +// ── The sweep ──────────────────────────────────────────────────────────────── + +/** The two shapes this grammar is spelled in across the repo. */ +const SIGNATURES = [ + /close\|closes\|closed\|fix\|fixes\|fixed\|resolve\|resolves\|resolved/, + /clos\(\?:e\|es\|ed\)|resolv\(\?:e\|es\|ed\)|fix\(\?:es\|ed\)\?/, +]; + +export function sweep(root, extraFiles = {}) { + const tracked = execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }) + .split('\0') + .filter(Boolean) + .filter((f) => !/(^|\/)(node_modules|dist)\//.test(f)); + + const hits = []; + for (const f of tracked) { + let text = extraFiles[f]; + if (text === undefined) { + try { + if (statSync(join(root, f)).size > 2 * 1024 * 1024) continue; + text = readFileSync(join(root, f), 'utf8'); + } catch { + continue; + } + } + if (SIGNATURES.some((re) => re.test(text))) hits.push(f); + } + for (const [f, text] of Object.entries(extraFiles)) { + if (!hits.includes(f) && SIGNATURES.some((re) => re.test(text))) hits.push(f); + } + return { scanned: tracked.length, hits }; +} + +// ── Judgement ──────────────────────────────────────────────────────────────── + +const matches = (parser, body) => [...body.matchAll(parser.make())].length > 0; + +export function judge(parsers, swept) { + const failures = []; + const fail = (id, message) => failures.push({ id, message }); + + for (const p of parsers) { + // 1. Extraction. + if (p.problem) { + fail(p.id, p.problem); + continue; + } + + const scope = SCOPES[p.scope]; + const binds = scope.binds.map((k) => REFS[k]); + const refuses = scope.refuses.map((k) => REFS[k]); + + for (const kw of KEYWORDS) { + for (const ref of binds) { + // 2 + 3. Every keyword, both measured separators. + if (!matches(p, `${kw} ${ref}`)) fail(p.id, `does not accept the plain spelling \`${kw} ${ref}\``); + if (!matches(p, `${kw}: ${ref}`)) { + fail(p.id, `does not accept the OPTIONAL-COLON spelling \`${kw}: ${ref}\` -- GitHub does (PR #10241 closed #10240 through it), so the parsers must agree on it`); + } + } + // 4. Scope invariants, asserted with the colon present as well as without, + // so a widened separator cannot widen the reference scope with it. + for (const ref of refuses) { + if (matches(p, `${kw} ${ref}`)) fail(p.id, `binds \`${kw} ${ref}\`, which is outside its declared \`${p.scope}\` scope`); + if (matches(p, `${kw}: ${ref}`)) fail(p.id, `binds \`${kw}: ${ref}\` -- the colon widened its \`${p.scope}\` reference scope, not just the separator`); + } + } + + // 4b. Shared refusals. `closing`/`fixing` are NOT closing keywords and both + // occur constantly in exactly the prose these parsers read; `Part of` is + // a reference and not a close. + for (const ref of binds) { + for (const near of ['closing', 'fixing', 'Part of']) { + if (matches(p, `${near} ${ref}`)) fail(p.id, `treats \`${near} ${ref}\` as a closing declaration`); + } + } + } + + // 5. The sweep. + const registered = new Set([...PARSERS.map((p) => p.file), ...NON_PARSERS.map((n) => n.file)]); + for (const f of swept.hits) { + if (!registered.has(f)) { + fail('sweep', `${f} parses the closing-keyword grammar and is not in ${SELF}'s registry. A fourth parser is the finding this gate exists to surface: register it (and give it a scope), or explain it in NON_PARSERS.`); + } + } + for (const f of registered) { + if (!swept.hits.includes(f)) { + fail('sweep', `${f} is registered as carrying the closing-keyword grammar but no longer matches the sweep. Stale registry entries verify nothing -- remove it, or fix the signature.`); + } + } + + return failures; +} + +// ── CLI ────────────────────────────────────────────────────────────────────── + +function list() { + for (const p of PARSERS) console.log(`${p.id.padEnd(28)} ${p.scope.padEnd(11)} ${p.file}`); + for (const n of NON_PARSERS) console.log(`${'(not a parser)'.padEnd(28)} ${''.padEnd(11)} ${n.file}`); + console.log(`\n${PARSERS.length} parsers + ${NON_PARSERS.length} known non-parser(s)`); +} + +function run() { + const root = repoRoot(); + const parsers = loadParsers(root); + const swept = sweep(root); + const failures = judge(parsers, swept); + + if (failures.length === 0) { + const assertions = parsers.filter((p) => !p.problem).length; + console.log( + `check-closing-keyword-parity: OK (${assertions} parsers agree on all ${KEYWORDS.length} keywords and both measured separators; ` + + `sweep found ${swept.hits.length} file(s) carrying the grammar across ${swept.scanned} tracked file(s), all registered).`, + ); + return 0; + } + + console.error(`check-closing-keyword-parity: ${failures.length} failure(s)\n`); + for (const f of failures) console.error(` • [${f.id}] ${f.message}`); + console.error(`\nRegistry: node ${SELF} --list`); + return 1; +} + +// ── Self-test ──────────────────────────────────────────────────────────────── +// +// Each mutation states the anchor it needs. An anchor that is absent is a +// FAILURE, not a skip: the substitution would be a no-op and the battery would +// report a detector it never exercised. + +const MUTATIONS = [ + { + id: 'X1', + what: 'the cross-repo closer drops the optional colon again (the #9755 defect, restored)', + file: '.github/workflows/cross-repo-issue-closer.yml', + from: '}):?\\\\s+([\\\\w.-]+)', + to: '})\\\\s+([\\\\w.-]+)', + expect: 'cross-repo-issue-closer', + }, + { + id: 'X2', + what: 'the duplicate-fix guard drops the optional colon', + file: '.github/workflows/duplicate-fix-guard.yml', + from: '}):?\\\\s+(?:', + to: '})\\\\s+(?:', + expect: 'duplicate-fix-guard', + }, + { + id: 'X3', + what: 'H7 drops the optional colon', + file: 'scripts/pm/check-half-states.mjs', + from: '\\b[ \\t]*:?[ \\t]*#(\\d+)\\b', + to: '\\b[ \\t]*#(\\d+)\\b', + expect: 'h7-partof-closing-keyword', + }, + { + id: 'X4', + what: 'the closer narrows its keyword set, so `fixes` and `resolved` stop qualifying', + file: '.github/workflows/cross-repo-issue-closer.yml', + from: "const KEYWORDS = 'close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved';", + to: "const KEYWORDS = 'close';", + expect: 'cross-repo-issue-closer', + }, + { + id: 'X5', + what: 'the closer widens to the bare form, so it would close what GitHub already closes', + file: '.github/workflows/cross-repo-issue-closer.yml', + from: ':?\\\\s+([\\\\w.-]+)\\\\/([\\\\w.-]+)#', + to: ':?\\\\s+(?:([\\\\w.-]+)\\\\/([\\\\w.-]+))?#', + expect: 'cross-repo-issue-closer', + }, +]; + +function selfTest() { + const root = repoRoot(); + const failures = []; + let checked = 0; + const assert = (cond, msg) => { + checked++; + if (!cond) failures.push(msg); + }; + + // 1. Clean must be green, or every red below proves nothing. + const cleanSweep = sweep(root); + assert( + judge(loadParsers(root), cleanSweep).length === 0, + `the shipped parsers are green before any mutation, got: ${judge(loadParsers(root), cleanSweep).map((f) => `[${f.id}] ${f.message}`).join(' | ')}`, + ); + + // 2. Every mutation must be REACHED and must turn the battery red, on the + // parser it names. + for (const m of MUTATIONS) { + const text = readFileSync(join(root, m.file), 'utf8'); + assert(text.includes(m.from), `${m.id}: its anchor is present in ${m.file} (a no-op mutation proves nothing)`); + if (!text.includes(m.from)) continue; + const mutated = text.replace(m.from, m.to); + assert(mutated !== text, `${m.id}: the substitution changed ${m.file}`); + const reds = judge(loadParsers(root, { [m.file]: mutated }), cleanSweep); + assert(reds.length > 0, `${m.id}: turns the battery RED (${m.what})`); + assert( + reds.some((f) => f.id === m.expect), + `${m.id}: the red names \`${m.expect}\`, got: ${reds.map((f) => f.id).join(', ') || 'nothing'}`, + ); + } + + // 3. The sweep must catch a FOURTH parser that nobody registered. + const planted = { 'packages/somewhere/src/invented-parser.ts': "const KEYWORDS = 'close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved';" }; + const plantedSweep = sweep(root, planted); + const plantedReds = judge(loadParsers(root), plantedSweep); + assert( + plantedReds.some((f) => f.id === 'sweep' && f.message.includes('invented-parser')), + 'X6: an unregistered fourth parser is caught by the sweep', + ); + checked++; + + // 4. A registry entry that stops matching must fail too -- a stale exemption + // verifies nothing. + const stale = judge(loadParsers(root), { scanned: plantedSweep.scanned, hits: plantedSweep.hits.filter((f) => f !== NON_PARSERS[0].file) }); + assert( + stale.some((f) => f.id === 'sweep' && f.message.includes('no longer matches the sweep')), + 'X7: a registered file that stopped carrying the grammar is caught', + ); + + if (failures.length === 0) { + console.log(`✓ check-closing-keyword-parity --self-test: ${checked} assertions, ${MUTATIONS.length} mutations of the shipped parsers each driven to red.`); + return 0; + } + console.error(`✗ check-closing-keyword-parity --self-test -- ${failures.length} failure(s)\n`); + for (const f of failures) console.error(` • ${f}`); + return 1; +} + +if (isEntrypoint(import.meta.url)) { + const arg = process.argv[2]; + if (arg === '--list') list(); + else if (arg === '--self-test') process.exit(selfTest()); + else process.exit(run()); +} diff --git a/scripts/check-cross-repo-closer-outcome.mjs b/scripts/check-cross-repo-closer-outcome.mjs index 637349cdf4..2429d31740 100644 --- a/scripts/check-cross-repo-closer-outcome.mjs +++ b/scripts/check-cross-repo-closer-outcome.mjs @@ -388,14 +388,32 @@ const FIXED = httpError(503, 'No server is currently available to service your r const DENIED = httpError(404, 'Not Found'); const UNAUTHORIZED = httpError(401, 'Bad credentials'); -/** A body carrying the three keyword shapes plus three that must NOT qualify. */ +/** + * A body carrying the three keyword shapes plus four that must NOT qualify. + * + * `CLOSES:` carries the OPTIONAL COLON deliberately, and it is one of the three + * targets rather than a fourth (#9755): the colon is therefore load-bearing for + * this body's target ARITY, so dropping `:?` from the shipped regex takes P1 + * from three targets to two instead of leaving the count intact. M15 is that + * mutation. Before #9755 this fixture used only the space-separated spelling, + * and the consequence was measured rather than assumed -- adding `:?` to the + * shipped script moved real behaviour and all 105 assertions stayed green, + * because no scenario had ever put a colon in front of a qualified reference. + * + * That the colon is GitHub's syntax and not a typo is measured too, and in this + * repository: PR #10241 merged 2026-08-20T15:10:06Z carrying `Filed, not fixed: + * #10240`, and #10240 closed `completed` at 15:10:08Z with its own + * `closed_by_pull_requests` naming #10241. So a body spelled this way is a real + * closing declaration, and a cross-repo target inside one is a target. + */ const MIXED_BODY = [ 'Fixes objectstack-ai/objectui#456', - 'CLOSES my-org/some.repo#22', + 'CLOSES: my-org/some.repo#22', 'resolved third/party#7', '', 'Part of objectstack-ai/objectui#999 -- a reference, not a close.', 'Fixes #321 -- bare form, GitHub already handles it.', + 'Closes: #4500 -- bare form WITH the colon; still GitHub\'s own job, not this one.', 'Fixes objectstack-ai/objectstack#5 -- same repo, GitHub already closed it.', ].join('\n'); @@ -464,6 +482,13 @@ export const SCENARIOS = [ return [ t(listed.length === 3, `P1 finds exactly the three qualified foreign targets, got ${listed.length}: ${line}`), ...FOREIGN.map((k) => t(listed.includes(k), `P1 recognises ${k}`)), + // The colon half, named separately so a red says WHICH spelling was + // lost rather than only that the count moved (#9755). The target above + // is reached through `CLOSES:`; this asserts the separator, and the two + // negatives below assert the colon widens the SEPARATOR only and not + // the reference scope. + t(listed.includes(PR_TARGET), `P1 recognises the optional-colon spelling (\`CLOSES: ${PR_TARGET}\`)`), + t(!line.includes('#4500'), 'P1 does not act on the bare `#N` form when it carries the colon either'), t(!line.includes('#999'), 'P1 does not treat `Part of` as a closing keyword'), t(!line.includes('#321'), 'P1 does not act on the bare `#N` form GitHub already handles'), t(!line.includes('objectstack-ai/objectstack#5'), 'P1 skips the qualified SAME-repo reference'), @@ -1002,6 +1027,18 @@ const MUTATIONS = [ to: "const KEYWORDS = 'close';", expect: ['P1'], }, + { + id: 'M15', + what: 'the optional colon is dropped, so `Fixes: owner/repo#N` stops being a target (the #9755 defect, restored)', + // The anchor is the SEPARATOR alone, not the whole pattern: the reference + // half of this regex is the closer's own scope decision (qualified only) + // and is pinned by the negatives in P1, while this half is the one that has + // to keep agreeing with duplicate-fix-guard.yml. Restoring it to `\\s+` is + // exactly the text that shipped before #9755. + from: ':?\\\\s+([\\\\w.-]+)', + to: '\\\\s+([\\\\w.-]+)', + expect: ['P1'], + }, { id: 'M6', what: 'the already-closed branch is removed, so a closed issue is commented on AND re-closed',