diff --git a/.changeset/9906-lint-coverage-banner-cross-file-claim.md b/.changeset/9906-lint-coverage-banner-cross-file-claim.md new file mode 100644 index 0000000000..a210babd0e --- /dev/null +++ b/.changeset/9906-lint-coverage-banner-cross-file-claim.md @@ -0,0 +1,17 @@ +--- +--- + +`scripts/check-lint-coverage.mjs`'s rule-2 ratchet banner no longer asserts, in +the present tense, which other gates carry the closing-keyword shape +(objectui#9906). It named two sibling gates and said the shape "still stands" in +both; both halves were true when the sentence was written and both had gone +false before anyone read it again, each falsified by a different pull request +and neither by the one that wrote the sentence. The trade-off the banner exists to explain — +spell the anchor `objectui#` rather than a bare `#`, so a quoted message cannot +match GitHub's closing grammar — is unchanged, and the scope record of the +repair that wrote it survives as history rather than as a reading of the tree. + +`scripts/__tests__/check-lint-coverage-closing-keyword.test.ts` now also fails +if the banner names any other gate at all, with a control that restores the +removed sentence and proves the scan fires on it. Scripts and tests only; no +package is released by this change. diff --git a/scripts/__tests__/check-lint-coverage-closing-keyword.test.ts b/scripts/__tests__/check-lint-coverage-closing-keyword.test.ts index 81291e32ad..20fa5a435e 100644 --- a/scripts/__tests__/check-lint-coverage-closing-keyword.test.ts +++ b/scripts/__tests__/check-lint-coverage-closing-keyword.test.ts @@ -68,9 +68,14 @@ const FIXTURE_ANCHOR = 424242; const CLOSING_TRIGGER = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+(?:[A-Za-z0-9-]+\/[A-Za-z0-9-]+|objectui|objectstack)?#(?:\$\{|\d)/i; -/** The rule-2 ratchet, from its banner comment to the report banner after it. */ -function staleEntryRatchet(): string { - const text = fs.readFileSync(gateSource, 'utf8'); +/** + * The rule-2 ratchet, from its banner comment to the report banner after it. + * + * `rewrite` lets a control leg put a PRE-repair wording back, so a scan over + * this region is proven able to go red on the same slice that reports green. + */ +function staleEntryRatchet(rewrite: (source: string) => string = (source) => source): string { + const text = rewrite(fs.readFileSync(gateSource, 'utf8')); const start = text.indexOf('// 2. Ratchet — a declared gap that has been closed'); expect(start, "rule 2's ratchet banner is gone").toBeGreaterThan(-1); const end = text.indexOf('// ── Report', start); @@ -116,6 +121,79 @@ function renderStaleEntry(rewrite: (source: string) => string = (source) => sour } } +/** + * The `scripts/` files this banner is allowed to name: the gate the banner is + * written in, and the pin that reads it. Both travel WITH the sentence — a + * reader of either is looking at the other, and whoever moves one is the person + * the citation is in front of. + * + * Every other `scripts/` path is a different gate's source, and a sentence here + * about a different gate's source is a reading of a population that moves + * without this file and re-derives never. objectui#9906 is that shape landed: + * the banner said the closing-keyword spelling "still stands" in two named + * sibling gates. Measured with the `CLOSING_TRIGGER` below, over each named + * file as of each merge commit rather than inferred from pull-request titles: + * both halves were TRUE the day the sentence was written, and it went stale in + * two steps — the first about 35 hours later, the second the same day after + * that — each step a different pull request and ⛔ neither of them the one that + * wrote the sentence. Nothing could have noticed, because nothing read it. + * + * ⚠️ That last reading is itself dated, and it lives HERE and on the card + * rather than in the banner, on purpose. This file is what a person opens when + * they are deciding how to word that banner, and the card carries the commits + * the reading was taken at. A first draft of the repair put a version of it in + * the banner instead — and got it backwards, claiming a half was already false + * on the day of writing. ⭐ A dated reading asserted where nothing re-reads it + * was wrong within one review round, which is the argument for this whole rule + * arriving by being committed rather than by being made. + * + * ⛔ This is NOT a check that any such claim is TRUE — no tree is scanned here + * and none may be (the 2026-09-18 ruling closed the door on a new gate for + * cross-file claims). It is the weaker, cheaper, unrottable rule: the banner + * does not make the claim at all. If a future reader needs to know which gates + * carry the shape today, the answer is those gates and their own pins, not a + * sentence in this one. + */ +const CITABLE_IN_THE_BANNER = new Set([ + 'check-lint-coverage.mjs', + 'check-lint-coverage-closing-keyword.test.ts', + 'lint:coverage', +]); + +/** + * The three spellings a banner reaches for when it names a gate: the gate's + * source file, a pin's file, and the `package.json` script that runs the gate. + * Directory prefixes are optional on purpose — dropping `scripts/` is the + * cheapest way to reword around a scan that demands it, and a bare + * `check-type-check-coverage.mjs` cites exactly as hard. + * + * ⚠️ What this deliberately does NOT reach: a gate named in prose with no + * filename and no script key ("the type-check coverage gate"). Closing that + * would mean a list of every gate's English name — a second copy of the gate + * roster, maintained by hand, which is the defect this pin exists to stop + * wearing a pin's clothes. The guarantee here is bounded and stated rather + * than assumed: the banner carries no CITATION of another gate. + * + * ⚠️ Nor does it reach outside the rule-2 region — the same boundary the + * `CLOSING_TRIGGER` source leg below already has. The gate's leading docblock + * names its sibling in the opening sentence, and that citation is a standing + * architectural relation rather than a reading of the sibling's contents, so it + * is left where it is; a rotting claim written THERE would be out of this + * scan's reach and is not covered by anything. + */ +const GATE_CITATION = + /(?:\bscripts\/(?:__tests__\/)?)?(?:[A-Za-z0-9._-]+\.test\.tsx?|check-[A-Za-z0-9._-]+\.mjs)\b|\b(?:check|lint):[a-z][a-z0-9-]*\b/g; + +/** The same citation with any directory prefix dropped, so the two spellings compare equal. */ +const basename = (citation: string) => citation.slice(citation.lastIndexOf('/') + 1); + +/** The gates the ratchet region names that are neither this gate nor its pin. */ +function foreignGateCitations(region: string): string[] { + return [...new Set(region.match(GATE_CITATION) ?? [])].filter( + (citation) => !CITABLE_IN_THE_BANNER.has(basename(citation)), + ); +} + describe('the stale-entry message may not carry a card-closing keyword', () => { it('the harness and the regex can both fire — the control, on the wording this card replaced', () => { // ⛔ Without this leg every assertion below is unfalsifiable: a substitution @@ -159,6 +237,63 @@ describe('the stale-entry message may not carry a card-closing keyword', () => { expect(rendered).toContain('can be ended once that work is done'); }); + it('the foreign-citation scan fires on the sentence this card removed — the control', () => { + // ⛔ Without this leg the assertion below is unfalsifiable: a regex that + // matches nothing and a banner that names nothing render identically. + // + // The pre-repair sentence is restored byte for byte, as objectui#9906 + // measured it — including its two present-tense halves, which were false + // on the tree the day this control was written. Restoring it HERE plants no + // claim: it lives inside a string this file feeds to its own scan and never + // reaches the gate on disk. + const region = staleEntryRatchet((source) => { + const restored = source.replace( + "// alone; the siblings the same repair would reach were reported rather than\n" + + "// widened into (objectui#9595). The precedent this wording came from is\n" + + "// recorded in the pin named below, beside the code that reads it.", + "// The landed precedent is the rule-2\n" + + "// stale-entry message in scripts/check-spec-symbol-derivation.mjs; the same\n" + + "// shape still stands in scripts/check-type-check-coverage.mjs and\n" + + "// scripts/check-action-forward-parity.mjs, which this change deliberately does\n" + + "// not reach.", + ); + expect(restored, 'the live wording this control rewrites is gone').not.toBe(source); + return restored; + }); + + expect(foreignGateCitations(region)).toEqual([ + 'scripts/check-spec-symbol-derivation.mjs', + 'scripts/check-type-check-coverage.mjs', + 'scripts/check-action-forward-parity.mjs', + ]); + // …and the pin that reads this banner is still not counted as foreign, so + // the scan below cannot be satisfied by the banner simply losing its pin. + expect(region).toContain('scripts/__tests__/check-lint-coverage-closing-keyword.test.ts'); + expect(foreignGateCitations(staleEntryRatchet())).not.toContain( + 'scripts/__tests__/check-lint-coverage-closing-keyword.test.ts', + ); + }); + + it('…and the banner names no OTHER gate, so it cannot recite a tree that moves', () => { + const region = staleEntryRatchet(); + // ⛔ The slice first: `staleEntryRatchet` only proves its two markers exist + // in order, so a region collapsed to a few bytes would satisfy the scan + // below by containing nothing at all. The banner's own last sentence is + // inside it, or this leg is not reading the paragraph it claims to read. + expect(region, 'the ratchet slice no longer reaches the banner it is scanning').toContain( + 'scripts/__tests__/check-lint-coverage-closing-keyword.test.ts', + ); + expect( + foreignGateCitations(region), + "the rule-2 banner names another gate's source file again. Whatever it says about that " + + 'file is a reading taken once, by a person who is not looking at it, and re-derived ' + + 'never — the sentence that stood there went stale without anyone touching this ' + + 'file, which is objectui#9906. Say it at that gate, or in this pin, where the words ' + + 'sit beside the thing they describe. ⛔ Not by adding a tree scan here: the ruling ' + + 'of 2026-09-18 closed that door, and this assertion is deliberately the weaker one.', + ).toEqual([]); + }); + it('the ratchet SOURCE carries no trigger either, comment included', () => { // The cheap leg, and not a duplicate of the rendered one: it also covers the // prose beside the emitter, which reaches a reader who is deciding how to diff --git a/scripts/check-lint-coverage.mjs b/scripts/check-lint-coverage.mjs index 146c9d0fe1..93e8f88830 100644 --- a/scripts/check-lint-coverage.mjs +++ b/scripts/check-lint-coverage.mjs @@ -113,13 +113,18 @@ for (const pkg of packages) { // was being careful. GitHub's parser does no sentence parsing, so hedging the // sentence around it buys nothing. Keep the instruction, lose the keyword, and // spell the anchor `objectui#` rather than a bare `#` so the reference cannot -// match the closing grammar at all. The landed precedent is the rule-2 -// stale-entry message in scripts/check-spec-symbol-derivation.mjs; the same -// shape still stands in scripts/check-type-check-coverage.mjs and -// scripts/check-action-forward-parity.mjs, which this change deliberately does -// not reach. Pinned by +// match the closing grammar at all. This gate was reworded onto that shape +// alone; the siblings the same repair would reach were reported rather than +// widened into (objectui#9595). The precedent this wording came from is +// recorded in the pin named below, beside the code that reads it. ⛔ Do not +// re-answer that here, and ⛔ do not name which other gates carry the shape +// today: a sentence in this banner about another gate's source is a reading +// taken once, by someone who is not looking at that file, and re-derived +// never. Such a sentence does not stay true; it only waits. The one that stood +// here, and how it went stale, are on objectui#9906. Pinned by // scripts/__tests__/check-lint-coverage-closing-keyword.test.ts, which reads -// this module's text rather than trusting this comment. +// this module's text rather than trusting this comment — this paragraph +// included, for the naming of any other gate. for (const name of Object.keys(DEBT)) { const pkg = byName.get(name); if (!pkg) {