|
48 | 48 | // Growing the ledger is possible (add the file and its count) but it is a |
49 | 49 | // visible line in this repo's diff and needs the same justification any DEBT |
50 | 50 | // entry needs — the idiom of `scripts/check-type-check-coverage.mjs`, applied |
51 | | -// per file rather than per package. |
| 51 | +// per file rather than per package. That authority rule is stated to the AUTHOR |
| 52 | +// too, not only here: the unledgered-file message marks the ledger-expanding |
| 53 | +// path `⛔ MAINTAINER-ONLY` per the #8435 convention, and the self-test holds |
| 54 | +// the marker in place. See the convention block below `LEDGER_COMMENT`. |
52 | 55 | // |
53 | 56 | // Usage (`--package` is repo-relative and required for everything but |
54 | 57 | // `--self-test`, which judges the ledger semantics alone): |
@@ -111,6 +114,61 @@ const LEDGER_COMMENT = |
111 | 114 | 'its entry is deleted, and a file NOT listed here may have no errors at all. Regenerate with: ' + |
112 | 115 | UPDATE_COMMAND; |
113 | 116 |
|
| 117 | +// ── The ratchet-remedy authority convention (#8435) ───────────────────────── |
| 118 | +// |
| 119 | +// The unledgered-file verdict's second remedy is "add the file to the ledger", |
| 120 | +// which EXPANDS a shrink-only ratchet. The rule saying that is a maintainer's |
| 121 | +// call was written only in this file's prose — where a maintainer reading the |
| 122 | +// script sees it and the author who trips the gate never does — while the |
| 123 | +// message itself offered the path as the plain second of two things to do. |
| 124 | +// The convention landed for check-engine-double-contract.mjs and |
| 125 | +// check-type-check-coverage.mjs; the twin blocks there are the reference. |
| 126 | +// |
| 127 | +// The words in the marked message are lifted from this file's own two |
| 128 | +// statements of the rule — `LEDGER_COMMENT`'s "EXACT ratchet" and the GREW |
| 129 | +// verdict's "the ledger only ratchets down" — rather than invented: one rule |
| 130 | +// stated twice in two voices is two rules by the next reading. |
| 131 | +// |
| 132 | +// Deliberately NOT extended to this file's other two ledger-naming verdicts. |
| 133 | +// SHRANK tells the author to re-record a number that already fell, and |
| 134 | +// GRADUATED tells them to DELETE an entry; both are the ratchet TIGHTENING and |
| 135 | +// squarely the author's job. Note GRADUATED names the ledger file too, so the |
| 136 | +// detector below is keyed on the EXPANDING phrasing ("add the file to …") and |
| 137 | +// not on the ledger's name — a detector that caught GRADUATED would stamp |
| 138 | +// maintainer-only onto the improvement path and teach the opposite of the rule. |
| 139 | +// |
| 140 | +// ⛔ This STRENGTHENS ratchet governance and weakens nothing. No verdict moves, |
| 141 | +// no ledger entry is added, and the problems `evaluate()` reports are the same |
| 142 | +// set on the same inputs — only the diagnostic text of one of them changes. |
| 143 | + |
| 144 | +/** Kept identical to the other gates' token so the convention is greppable. */ |
| 145 | +const RATCHET_AUTHORITY_MARKER = '⛔ MAINTAINER-ONLY'; |
| 146 | + |
| 147 | +/** |
| 148 | + * How this gate OFFERS the privileged path, as a detector rather than a string |
| 149 | + * compare, so the self-test can prove it still reaches its subject: a reworded |
| 150 | + * offer that stopped matching would make the convention check pass vacuously on |
| 151 | + * every message. |
| 152 | + * |
| 153 | + * The optional `(?:\S*\/)?` accepts the ledger named by PATH as well as by bare |
| 154 | + * filename — a gap spelled to exclude the dot would silently stop matching the |
| 155 | + * moment the message qualified the name with a directory, and the self-test |
| 156 | + * carries a path-spelled control precisely because that failure is invisible. |
| 157 | + */ |
| 158 | +const RATCHET_EXPANSION_OFFER = new RegExp( |
| 159 | + `add the file to\\s+(?:\\S*\\/)?${LEDGER_NAME.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, |
| 160 | +); |
| 161 | + |
| 162 | +/** |
| 163 | + * The convention: a message that hands the author the ledger-expanding path |
| 164 | + * must say in the same breath that the path is not theirs. A message offering no |
| 165 | + * such path is unaffected — this is an authority label, not a vocabulary ban. |
| 166 | + */ |
| 167 | +export function ratchetRemedyCarriesAuthority(message: string): boolean { |
| 168 | + if (!RATCHET_EXPANSION_OFFER.test(message)) return true; |
| 169 | + return message.includes(RATCHET_AUTHORITY_MARKER); |
| 170 | +} |
| 171 | + |
114 | 172 | type Ledger = { _comment: string; entries: Record<string, number> }; |
115 | 173 |
|
116 | 174 | /** `path(line,col): error TSxxxx: …` — continuation lines of a multi-line message never match. */ |
@@ -144,7 +202,11 @@ export function evaluate(actual: Map<string, number>, ledger: Record<string, num |
144 | 202 | problems.push( |
145 | 203 | `${file}: ${count} type error(s) in a file the ledger does not cover. Fix them — this file is ` + |
146 | 204 | `inside the checked zone, which is the point of ${PROJECT}. (A deleted \`@ts-expect-error\` ` + |
147 | | - `shows up exactly here, as TS2578/TS2694.) Only with a reason: add the file to ${LEDGER_NAME}.`, |
| 205 | + `shows up exactly here, as TS2578/TS2694.) That is the fix, and the only one of the two you ` + |
| 206 | + `can take on your own. ${RATCHET_AUTHORITY_MARKER}, NOT a co-equal option: add the file to ` + |
| 207 | + `${LEDGER_NAME}. This is an EXACT ratchet and the ledger only ratchets down, so a new entry ` + |
| 208 | + `EXPANDS it — that needs a maintainer to agree the debt is legitimate first (${ISSUE}). Do ` + |
| 209 | + `not take this path to get CI green.`, |
148 | 210 | ); |
149 | 211 | continue; |
150 | 212 | } |
@@ -301,12 +363,140 @@ function selfTest(): void { |
301 | 363 | failures.push(`parseDiagnostics mis-read a multi-line transcript: ${JSON.stringify([...parsed])}`); |
302 | 364 | } |
303 | 365 |
|
| 366 | + // ── The ratchet-remedy authority convention (#8435) ──────────────────────── |
| 367 | + // |
| 368 | + // Asserted against the text `evaluate()` REALLY emits rather than a copy of |
| 369 | + // it: a hand-copied fixture would prove the convention about a string no |
| 370 | + // author ever reads. The assertions are deliberately non-overlapping, so each |
| 371 | + // way this can rot is caught by exactly one NAMED failure: |
| 372 | + // |
| 373 | + // (1) the detector still reaches its subject — the only one that fails if |
| 374 | + // the offer is reworded out from under RATCHET_EXPANSION_OFFER, which |
| 375 | + // would make (3) pass vacuously forever after; |
| 376 | + // (2) the real emitted verdict carries the marker — the only one that fails |
| 377 | + // if the label is dropped from the unledgered-file message; |
| 378 | + // (3) an offer WITHOUT the marker is REJECTED — the only one that fails if |
| 379 | + // the predicate stops discriminating (e.g. is reduced to `return true`); |
| 380 | + // (4) an offer naming the ledger by PATH is still matched — the only one |
| 381 | + // that fails if the detector's gap is narrowed to exclude directories; |
| 382 | + // (5)/(6)/(7) the detector does NOT reach the SHRANK, GRADUATED and GREW |
| 383 | + // verdicts. Those are the ratchet TIGHTENING, squarely the author's |
| 384 | + // job. (6) is the one that earns its keep: GRADUATED names the ledger |
| 385 | + // FILE while telling the author to delete an entry, so a detector keyed |
| 386 | + // on the name rather than on the act would over-reach onto it and stamp |
| 387 | + // maintainer-only on the improvement path. |
| 388 | + // |
| 389 | + // (3) is what makes (2) worth having: without it, a predicate that approved |
| 390 | + // everything would keep this block green while the convention is gone. |
| 391 | + const expect = (label: string, cond: boolean): void => { |
| 392 | + if (!cond) failures.push(label); |
| 393 | + }; |
| 394 | + |
| 395 | + const unledgered = evaluate(new Map([['pin.test.ts', 1]]), {})[0] ?? ''; |
| 396 | + expect( |
| 397 | + '#8435 — the ratchet-offer DETECTOR still matches the unledgered-file verdict (else every ' |
| 398 | + + 'assertion below it passes vacuously)', |
| 399 | + RATCHET_EXPANSION_OFFER.test(unledgered), |
| 400 | + ); |
| 401 | + expect( |
| 402 | + `#8435 — the unledgered-file verdict marks the ledger path ${RATCHET_AUTHORITY_MARKER} (the ledger ` |
| 403 | + + 'is an EXACT, shrink-only ratchet, so ADDING a file to it is a maintainer action — the author ' |
| 404 | + + "must be told that where they read it, not only in this file's prose)", |
| 405 | + ratchetRemedyCarriesAuthority(unledgered), |
| 406 | + ); |
| 407 | + |
| 408 | + { |
| 409 | + // SYNTHETIC — and specifically the pre-#8538 wording — rather than the real |
| 410 | + // verdict with the marker stripped out: derived, it would also fire on a |
| 411 | + // rewording, giving two named failures for one rot with the second |
| 412 | + // misdescribing the cause. if/else, not two flat asserts, so exactly one of |
| 413 | + // the two below can fire. |
| 414 | + const unmarkedOffer = `pin.test.ts: 1 type error(s) … Only with a reason: add the file to ${LEDGER_NAME}.`; |
| 415 | + if (!RATCHET_EXPANSION_OFFER.test(unmarkedOffer)) { |
| 416 | + expect( |
| 417 | + '#8435 — the synthetic unmarked-offer fixture is no longer recognised as an offer, so it ' |
| 418 | + + 'cannot test discrimination at all. Re-spell it to match RATCHET_EXPANSION_OFFER', |
| 419 | + false, |
| 420 | + ); |
| 421 | + } else { |
| 422 | + expect( |
| 423 | + '#8435 — ratchetRemedyCarriesAuthority() REJECTS an offer carrying no marker (proves the ' |
| 424 | + + 'predicate discriminates rather than approving everything)', |
| 425 | + !ratchetRemedyCarriesAuthority(unmarkedOffer), |
| 426 | + ); |
| 427 | + } |
| 428 | + } |
| 429 | + |
| 430 | + // The hand-classified control for the detector's GAP. A sibling gate's |
| 431 | + // first-cut regex spelled its gap to exclude the dot, so every offer naming |
| 432 | + // its registry BY PATH silently stopped matching — a vacuous pass that no |
| 433 | + // green run can reveal, because the fail path is where these strings live. |
| 434 | + const pathSpelledOffer = `pin.test.ts: 1 type error(s) … add the file to packages/spec/${LEDGER_NAME}.`; |
| 435 | + expect( |
| 436 | + '#8435 — the detector still matches an offer that names the ledger by PATH rather than by bare ' |
| 437 | + + 'filename (a gap narrowed to exclude directories would make the convention unenforceable the ' |
| 438 | + + 'moment the message qualified the name)', |
| 439 | + RATCHET_EXPANSION_OFFER.test(pathSpelledOffer), |
| 440 | + ); |
| 441 | + |
| 442 | + const shrank = evaluate(new Map([['a.test.ts', 2]]), { 'a.test.ts': 3 })[0] ?? ''; |
| 443 | + if (!shrank.includes('SHRANK')) { |
| 444 | + expect( |
| 445 | + '#8435 — the ratchet-DOWN control is no longer the SHRANK verdict, so it cannot prove the ' |
| 446 | + + 'detector leaves the improvement path alone. Re-point it at the re-record message', |
| 447 | + false, |
| 448 | + ); |
| 449 | + } else { |
| 450 | + expect( |
| 451 | + '#8435 — the detector does NOT reach the SHRANK verdict (re-recording a number that already fell ' |
| 452 | + + "is the ratchet tightening and squarely the author's job; a maintainer-only marker there " |
| 453 | + + 'would teach the opposite of the rule)', |
| 454 | + !RATCHET_EXPANSION_OFFER.test(shrank) && ratchetRemedyCarriesAuthority(shrank), |
| 455 | + ); |
| 456 | + } |
| 457 | + |
| 458 | + const graduated = evaluate(new Map<string, number>(), { 'a.test.ts': 3 })[0] ?? ''; |
| 459 | + if (!graduated.includes(LEDGER_NAME)) { |
| 460 | + expect( |
| 461 | + `#8435 — the GRADUATED control no longer names ${LEDGER_NAME}, so it cannot prove the detector is ` |
| 462 | + + "keyed on the ACT rather than on the ledger's name. Re-point it at the delete-the-entry message", |
| 463 | + false, |
| 464 | + ); |
| 465 | + } else { |
| 466 | + expect( |
| 467 | + '#8435 — the detector does NOT reach the GRADUATED verdict, which names the ledger file while ' |
| 468 | + + `telling the author to DELETE an entry. This is the over-reach control: a detector keyed on ` |
| 469 | + + `${LEDGER_NAME} instead of on the "add the file to …" ACT would mark the ratchet-tightening ` |
| 470 | + + 'path maintainer-only', |
| 471 | + !RATCHET_EXPANSION_OFFER.test(graduated) && ratchetRemedyCarriesAuthority(graduated), |
| 472 | + ); |
| 473 | + } |
| 474 | + |
| 475 | + const grew = evaluate(new Map([['a.test.ts', 4]]), { 'a.test.ts': 3 })[0] ?? ''; |
| 476 | + if (!grew.includes('GREW')) { |
| 477 | + expect( |
| 478 | + '#8435 — the GREW control is no longer the debt-grew verdict, so it cannot prove the detector ' |
| 479 | + + 'leaves it alone. Re-point it at the grew message', |
| 480 | + false, |
| 481 | + ); |
| 482 | + } else { |
| 483 | + expect( |
| 484 | + '#8435 — the detector does NOT reach the GREW verdict (it offers no ledger-expanding path at all; ' |
| 485 | + + 'it tells the author to fix the new errors)', |
| 486 | + !RATCHET_EXPANSION_OFFER.test(grew) && ratchetRemedyCarriesAuthority(grew), |
| 487 | + ); |
| 488 | + } |
| 489 | + |
304 | 490 | if (failures.length) { |
305 | 491 | console.error(`✗ check:test-typecheck --self-test — ${failures.length} failure(s)\n`); |
306 | 492 | for (const f of failures) console.error(' • ' + f); |
307 | 493 | process.exit(1); |
308 | 494 | } |
309 | | - console.log(`✓ check:test-typecheck --self-test — ${cases.length} semantic case(s) + the parser hold.`); |
| 495 | + console.log( |
| 496 | + `✓ check:test-typecheck --self-test — ${cases.length} semantic case(s), the parser, and the #8435 ` |
| 497 | + + 'convention hold (the unledgered-file verdict keeps its ledger offer marked maintainer-only, ' |
| 498 | + + 'and the SHRANK / GRADUATED / GREW verdicts stay unmarked).', |
| 499 | + ); |
310 | 500 | } |
311 | 501 |
|
312 | 502 | if (process.argv.includes('--self-test')) { |
|
0 commit comments