diff --git a/package.json b/package.json index 5f61e8d8a1..391f3fd9d9 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "check:doc-authoring": "node scripts/check-doc-authoring.mjs --self-test && node scripts/check-doc-authoring.mjs", "check:doc-anchors": "node scripts/check-doc-anchors.mjs --self-test && node scripts/check-doc-anchors.mjs", "check:docs-audit-scope": "node scripts/docs-audit/affected-docs.mjs --self-test && node scripts/docs-audit/check-audit-scope.mjs --self-test && node scripts/docs-audit/check-audit-scope.mjs", - "check:role-word": "node scripts/check-role-word.mjs", + "check:role-word": "node scripts/check-role-word.mjs --self-test && node scripts/check-role-word.mjs", "check:quick-reference-counts": "node scripts/check-quick-reference-counts.mjs --self-test && node scripts/check-quick-reference-counts.mjs", "check:skill-frame-sync": "node scripts/check-skill-frame-sync.mjs --self-test && node scripts/check-skill-frame-sync.mjs", "check:skill-frame-freshness": "node scripts/check-skill-frame-freshness.mjs --self-test && node scripts/check-skill-frame-freshness.mjs", diff --git a/scripts/check-driver-conformance.mjs b/scripts/check-driver-conformance.mjs index d121c79c08..55a733eb22 100644 --- a/scripts/check-driver-conformance.mjs +++ b/scripts/check-driver-conformance.mjs @@ -440,6 +440,76 @@ const CASE_SETS = [ const LEDGER = []; +// ── The ratchet-remedy authority convention (#8435) ───────────────────────── +// +// The rule above is the authority rule, and until now it was written only here +// — where a maintainer reading the script sees it and the author who trips the +// gate never does. CONSUMED offered "write the suite, or add a ledger entry" as +// two co-equal options, which is precisely the reading the LEDGER comment says +// is wrong. The convention landed for check-engine-double-contract.mjs and +// check-type-check-coverage.mjs; the twin blocks there are the reference. +// +// The words below are lifted from the LEDGER comment on purpose rather than +// invented: one rule stated twice in two voices is two rules by the next +// reading. +// +// ⛔ This STRENGTHENS ledger governance and weakens nothing. No cell's verdict +// moves, no entry is added, and the matrix this gate reports is byte-for-byte +// the one it reported before — only the diagnostic text changes. + +/** Kept identical to the other gates' token so the convention is greppable. */ +const RATCHET_AUTHORITY_MARKER = '⛔ MAINTAINER-ONLY'; + +/** The ledger as the message spells it — this script IS the ledger's home. */ +const LEDGER_REL = 'scripts/check-driver-conformance.mjs'; + +/** + * How this gate OFFERS the privileged path, as a detector rather than a string + * compare, so the self-test can prove it still reaches its subject: a reworded + * offer that stopped matching would make the convention check pass vacuously on + * every message. + * + * RECONCILED is deliberately out of its reach — that message tells the author to + * DELETE an entry, which is the ledger tightening and squarely the author's job. + */ +const RATCHET_EXPANSION_OFFER = new RegExp( + `add a measured DEBT/EXEMPT entry to the ledger in\\s+${LEDGER_REL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, +); + +/** + * The convention: a message that hands the author the ledger-expanding path must + * say in the same breath that the path is not theirs. A message offering no such + * path is unaffected — this is an authority label, not a vocabulary ban. + * + * @param {string} message + * @returns {boolean} + */ +function ratchetRemedyCarriesAuthority(message) { + if (!RATCHET_EXPANSION_OFFER.test(message)) return true; + return message.includes(RATCHET_AUTHORITY_MARKER); +} + +/** + * CONSUMED's text, named and pure so the self-test can assert on the exact + * string the author reads. Extracted from `audit()` for that reason — a message + * built inline is a message no assertion can reach. + * + * @param {string} driver + * @param {{marker: string, what: string}} caseSet + * @returns {string} + */ +function consumedMessage(driver, caseSet) { + return ( + `CONSUMED: ${driver} does not run ${caseSet.marker} (${caseSet.what}). Add a suite that ` + + 'drives the shared cases. That is the fix, and the only one of the two you can take on ' + + `your own. ${RATCHET_AUTHORITY_MARKER}, NOT a co-equal option: add a measured DEBT/EXEMPT ` + + `entry to the ledger in ${LEDGER_REL} saying why not. A ledger entry is a MEASURED, ` + + 'tracked exception the maintainer has agreed to, never the cheaper half of "enroll the ' + + 'driver" — do not take this path to get CI green.' + ); +} + + // ── Discovery ─────────────────────────────────────────────────────────────── /** A declared scan root that could not be resolved to a directory. Carries the names. */ @@ -710,11 +780,7 @@ function audit() { } else if (entry) { rows.push({ driver, marker: c.marker, state: entry.kind.toLowerCase() }); } else { - errors.push( - `CONSUMED: ${driver} does not run ${c.marker} (${c.what}). Add a suite that drives ` - + 'the shared cases, or add a measured DEBT/EXEMPT entry to the ledger in ' - + 'scripts/check-driver-conformance.mjs saying why not.', - ); + errors.push(consumedMessage(driver, c)); rows.push({ driver, marker: c.marker, state: 'MISSING' }); } } @@ -960,6 +1026,54 @@ function selfTest() { rmSync(tmpRoots, { recursive: true, force: true }); } + // ── The ratchet-remedy authority convention (#8435) ──────────────────────── + // + // Three assertions, deliberately non-overlapping, so each way this can rot is + // caught by exactly one NAMED failure: + // + // (1) the detector still reaches its subject — the only one that fails if + // the offer is reworded out from under `RATCHET_EXPANSION_OFFER`, which + // would make (3) pass vacuously forever after; + // (2) the real emitted message carries the marker — the only one that fails + // if the label is dropped from CONSUMED's text; + // (3) an offer WITHOUT the marker is REJECTED — the only one that fails if + // the predicate stops discriminating (e.g. is reduced to `return true`). + // + // (3) is what makes (2) worth having: without it, a predicate that approves + // everything would keep this block green while the convention is gone. + const consumed = consumedMessage('driver-example', { + marker: 'PAGINATION_CASES', + what: 'a sorted paged read is a partition', + }); + expect('#8435 — the ratchet-offer DETECTOR still matches CONSUMED (else the check below is ' + + 'vacuous)', + RATCHET_EXPANSION_OFFER.test(consumed)); + expect(`#8435 — CONSUMED marks the ledger path ${RATCHET_AUTHORITY_MARKER} (the LEDGER comment ` + + 'calls an entry a MEASURED exception the maintainer has agreed to, so the author must be ' + + 'told that where they read it, not only where a maintainer does)', + ratchetRemedyCarriesAuthority(consumed)); + + { + // (3)'s fixture is SYNTHETIC rather than the real message with the marker + // stripped out: derived, it also fires on a rewording — two named failures + // for one rot, the second misdescribing the cause. + const unmarkedOffer = + `CONSUMED: driver-example does not run PAGINATION_CASES. Add a suite that drives the shared ` + + `cases, or add a measured DEBT/EXEMPT entry to the ledger in ${LEDGER_REL} saying why not.`; + // if/else, not two flat asserts: a fixture that stopped being an offer would + // ALSO fail the discrimination check, and that second failure would + // misdescribe the cause. Exactly one of these two can fire. + if (!RATCHET_EXPANSION_OFFER.test(unmarkedOffer)) { + expect('#8435 — the synthetic unmarked-offer fixture is no longer recognised as an offer, so ' + + 'it cannot test discrimination at all. Re-spell it to match RATCHET_EXPANSION_OFFER', + false); + } else { + expect('#8435 — ratchetRemedyCarriesAuthority() REJECTS an offer carrying no marker (proves ' + + 'the predicate discriminates rather than approving everything)', + !ratchetRemedyCarriesAuthority(unmarkedOffer)); + } + } + if (failures.length) { for (const f of failures) console.error(` x self-test: ${f}`); console.error(`\ncheck-driver-conformance --self-test: ${failures.length} failure(s).\n`); @@ -967,8 +1081,9 @@ function selfTest() { } console.log( 'OK self-test: detects driven / unused / re-declared fixtures, discovers both axes, accounts for ' - + 'every entry under DRIVERS_DIR (a dropped or manifestless row is red, not a smaller matrix), and ' - + 'holds the dead-root hard error (red when a scan root is renamed, green when restored).', + + 'every entry under DRIVERS_DIR (a dropped or manifestless row is red, not a smaller matrix), ' + + 'holds the dead-root hard error (red when a scan root is renamed, green when restored), and ' + + 'keeps CONSUMED\'s ledger offer marked maintainer-only (#8435).', ); } diff --git a/scripts/check-durability-degradation-log-level.mjs b/scripts/check-durability-degradation-log-level.mjs index 89c97de118..dba0f488d8 100644 --- a/scripts/check-durability-degradation-log-level.mjs +++ b/scripts/check-durability-degradation-log-level.mjs @@ -1597,6 +1597,73 @@ function readInventionKey(f) { return `${f.file}::${f.fn ?? ''}`; } +// ── The ratchet-remedy authority convention (#8435) ────────────────────────── +// +// The read-seam report's `OR :` line hands the author the baseline-expanding +// path. That baseline is shrink-only and hand-edited — the message said so, and +// still presented the path as the second of two things the author may do. The +// convention landed for check-engine-double-contract.mjs and +// check-type-check-coverage.mjs; the twin blocks there are the reference. +// +// Deliberately NOT extended to this file's other two ledgers, both of which are +// declaration registries rather than debt ratchets: FAILURE_PROPAGATION_CALLEES +// / FAILURE_PROPAGATION_SITES record HOW a failure is delivered (declaring one +// is the correct fix, not a weakening), and the two baselines' stale-entry +// messages tell the author to DELETE an entry, which is the ratchet tightening +// and squarely their job. +// +// ⛔ This STRENGTHENS ratchet governance and weakens nothing. No seam's verdict +// moves, no baseline entry is added, and the findings this rule reports are +// byte-for-byte the ones it reported before — only the diagnostic text changes. + +/** Kept identical to the other gates' token so the convention is greppable. */ +const RATCHET_AUTHORITY_MARKER = '⛔ MAINTAINER-ONLY'; + +/** The baseline as the message spells it (the PATH constant is absolute). */ +const READ_INVENTION_BASELINE_REL = 'scripts/durability-read-invention.baseline.json'; + +/** + * How this rule OFFERS the privileged path, as a detector rather than a string + * compare, so the self-test can prove it still reaches its subject: a reworded + * offer that stopped matching would make the convention check pass vacuously on + * every message. `\s+` rather than a space because the offer is wrapped across + * lines with a hanging indent. + */ +const RATCHET_EXPANSION_OFFER = new RegExp( + `add an entry naming why to\\s+${READ_INVENTION_BASELINE_REL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, +); + +/** + * The convention: a message that hands the author the baseline-expanding path + * must say in the same breath that the path is not theirs. A message offering no + * such path is unaffected — this is an authority label, not a vocabulary ban. + * + * @param {string} message + * @returns {boolean} + */ +function ratchetRemedyCarriesAuthority(message) { + if (!RATCHET_EXPANSION_OFFER.test(message)) return true; + return message.includes(RATCHET_AUTHORITY_MARKER); +} + +/** + * The `OR :` line's text, named and pure so the self-test can assert on the + * exact string the author reads. Extracted from the report loop for that reason + * — a message built inline is a message no assertion can reach. + * + * @returns {string} + */ +function readInventionBaselineOffer() { + return ( + ` OR : ${RATCHET_AUTHORITY_MARKER}, NOT a co-equal option — the fix above is the one\n` + + ' you can take on your own. If the seam is a REVIEWED, legitimate degradation,\n' + + ` add an entry naming why to ${READ_INVENTION_BASELINE_REL}.\n` + + ' That baseline is shrink-only and hand-edited, so an entry weakens a ratchet\n' + + ' and needs a maintainer to agree the degradation is legitimate first — do not\n' + + ' take this path to get CI green.\n' + ); +} + /** Run the read-seam invention rule (#5186) over its narrowed scan scope. */ function runReadSeamRule({ list = false } = {}) { const findings = []; @@ -1698,10 +1765,7 @@ function runReadSeamRule({ list = false } = {}) { ' that the failure be distinguishable, never that the value change.', ); } - console.error( - ' OR : if the seam is a REVIEWED, legitimate degradation, add an entry naming why to\n' + - ' scripts/durability-read-invention.baseline.json (shrink-only, hand-edited).\n', - ); + console.error(readInventionBaselineOffer()); } } @@ -3015,11 +3079,72 @@ function selfTestReadSeams() { console.log(` ✓ ${c.name}`); } } + // ── The ratchet-remedy authority convention (#8435) ──────────────────────── + // + // Three assertions, deliberately non-overlapping, so each way this can rot is + // caught by exactly one NAMED failure: + // + // (1) the detector still reaches its subject — the only one that fails if + // the offer is reworded out from under `RATCHET_EXPANSION_OFFER`, which + // would make (3) pass vacuously forever after; + // (2) the real emitted line carries the marker — the only one that fails if + // the label is dropped from the `OR :` text; + // (3) an offer WITHOUT the marker is REJECTED — the only one that fails if + // the predicate stops discriminating (e.g. is reduced to `return true`). + // + // (3) is what makes (2) worth having: without it, a predicate that approves + // everything would keep this block green while the convention is gone. + const offer = readInventionBaselineOffer(); + if (!RATCHET_EXPANSION_OFFER.test(offer)) { + failures++; + console.error( + ' ✗ #8435 convention — the ratchet-offer DETECTOR no longer matches the `OR :` line it is ' + + 'written against. Either the offer was reworded (re-point RATCHET_EXPANSION_OFFER at the ' + + 'new wording) or the baseline path was removed (delete the convention block). Until then ' + + 'the convention check passes vacuously on every message.', + ); + } + if (!ratchetRemedyCarriesAuthority(offer)) { + failures++; + console.error( + ` ✗ #8435 convention — the \`OR :\` line offers ${READ_INVENTION_BASELINE_REL} without the ` + + `${RATCHET_AUTHORITY_MARKER} marker. That baseline is shrink-only, so the path is a ` + + 'maintainer action; presenting it unmarked next to the real fix is what let "add a ' + + 'baseline entry" read as the author\'s second option.', + ); + } + { + // (3)'s fixture is SYNTHETIC rather than the real line with the marker + // stripped out: derived, it also fires on a rewording — two named failures + // for one rot, and the second one misdescribes the cause. + const unmarkedOffer = + ' OR : if the seam is a REVIEWED, legitimate degradation, add an entry naming why to\n' + + ` ${READ_INVENTION_BASELINE_REL} (shrink-only, hand-edited).\n`; + if (!RATCHET_EXPANSION_OFFER.test(unmarkedOffer)) { + failures++; + console.error( + ' ✗ #8435 convention — the synthetic unmarked-offer fixture is no longer recognised as an ' + + 'offer, so it cannot test discrimination at all. Re-spell it to match ' + + 'RATCHET_EXPANSION_OFFER.', + ); + } else if (ratchetRemedyCarriesAuthority(unmarkedOffer)) { + failures++; + console.error( + ' ✗ #8435 convention — ratchetRemedyCarriesAuthority() ACCEPTED a message that offers the ' + + 'baseline-expanding path with no marker at all. The predicate is not discriminating, so ' + + 'the assertion above proves nothing.', + ); + } + } + if (failures > 0) { console.error(`\n✗ self-test (read-seam invention rule): ${failures} case(s) failed\n`); return 1; } - console.log(`\n✓ self-test (read-seam invention rule): ${cases.length} case(s) passed\n`); + console.log( + `\n✓ self-test (read-seam invention rule): ${cases.length} case(s) passed, and the baseline ` + + 'offer stays marked maintainer-only (#8435)\n', + ); return 0; } diff --git a/scripts/check-role-word.mjs b/scripts/check-role-word.mjs index 78277d6860..df83f5bf90 100644 --- a/scripts/check-role-word.mjs +++ b/scripts/check-role-word.mjs @@ -19,6 +19,11 @@ // run with --update to ratchet the baseline down and commit it. // // node scripts/check-role-word.mjs [--update] +// node scripts/check-role-word.mjs --self-test # verify the checker's own rules +// +// `--update` expands the baseline, which is the shrink-only direction of this +// ratchet — the NEW-use message marks that path `⛔ MAINTAINER-ONLY` per the +// #8435 convention, and the self-test holds the marker in place. // // Scope: content/docs (hand-written; references/ is generated from spec and // excluded — the spec source is the fix site there) and skills/. File and @@ -49,6 +54,139 @@ function countMatches(text) { return m ? m.length : 0; } +// ── The ratchet-remedy authority convention (#8435) ────────────────────────── +// +// This gate's second remedy is `--update`, which expands the baseline. That is +// a shrink-only ratchet, so taking that path WEAKENS the gate — and the message +// used to offer it in the same breath as the real fix, with nothing saying whose +// path it is. The convention landed for check-engine-double-contract.mjs and +// check-type-check-coverage.mjs; the twin blocks there are the reference. +// +// The marker here has to label the ACT, not the file. `--update` does not append +// a line: it rewrites the entire baseline from the current tree (see the +// `update` branch below), so an author reaching for it to admit one new +// occurrence also re-baselines every other file in the same stroke. +// +// ⛔ This STRENGTHENS ratchet governance and weakens nothing. No threshold +// moves, no baseline entry is added, and the verdicts this gate reaches are +// byte-for-byte the ones it reached before — only the diagnostic text changes. + +/** Kept identical to the other gates' token so the convention is greppable. */ +const RATCHET_AUTHORITY_MARKER = '⛔ MAINTAINER-ONLY'; + +/** + * How this gate OFFERS the privileged path, as a detector rather than a string + * compare, so the self-test can prove it still reaches its subject: a reworded + * offer that stopped matching would make the convention check pass vacuously on + * every message. + * + * Deliberately keyed on the baseline-EXPANDING phrasing ("add it to ") and + * not on `--update` alone. The two ratchet-DOWN messages below also name + * `--update`, and ratcheting down is squarely the author's job — a detector that + * caught those would force the maintainer-only marker onto a message where it is + * actively wrong. + */ +const RATCHET_EXPANSION_OFFER = new RegExp( + `add it to\\s+${BASELINE_PATH.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, +); + +/** + * The convention: a message that hands the author the baseline-expanding path + * must say in the same breath that the path is not theirs. A message offering no + * such path is unaffected — this is an authority label, not a vocabulary ban. + * + * @param {string} message + * @returns {boolean} + */ +function ratchetRemedyCarriesAuthority(message) { + if (!RATCHET_EXPANSION_OFFER.test(message)) return true; + return message.includes(RATCHET_AUTHORITY_MARKER); +} + +/** + * The NEW-use verdict's text, named and pure so the self-test can assert on the + * exact string the author reads. A message built inline is a message no + * assertion can reach. + * + * @param {string} file + * @param {number} count + * @returns {string} + */ +function newUseMessage(file, count) { + return ( + `${file}: NEW use of the reserved word "role" (${count} occurrence(s)). ` + + 'ADR-0090 D3: use permission_set / position / business_unit. That is the fix, and the ' + + `only one of the two you can take on your own. ${RATCHET_AUTHORITY_MARKER}, NOT a co-equal ` + + 'option: for a genuine boundary (better-auth, ARIA, quoted history), add it to ' + + `${BASELINE_PATH} by running \`node scripts/check-role-word.mjs --update\`. The gated thing ` + + 'is that ACT, not the file — `--update` rewrites the whole baseline from the current tree, ' + + 'so it admits your occurrence and re-baselines every other file in one stroke. The baseline ' + + 'is shrink-only, so this weakens a ratchet and needs a maintainer to agree the boundary is ' + + 'genuine first — do not take this path to get CI green.' + ); +} + +function selfTest() { + const failures = []; + const expect = (label, cond) => { + if (!cond) failures.push(label); + }; + + // ── The ratchet-remedy authority convention (#8435) ──────────────────────── + // + // Four assertions, deliberately non-overlapping, so each way this can rot is + // caught by exactly one NAMED failure: (1) the detector still reaches its + // subject, (2) the real message carries the marker, (3) an unmarked offer is + // REJECTED, (4) the detector does NOT reach the ratchet-DOWN messages. (3) is + // what makes (2) worth having — a predicate that approved everything would + // keep (2) green with the convention gone. (4) is this gate's own hazard: + // both directions of its ratchet are spelled `--update`, and marking the + // improvement path maintainer-only would teach the opposite of the rule. + const real = newUseMessage('content/docs/example.mdx', 2); + expect('#8435 — the ratchet-offer DETECTOR still matches the NEW-use message (else the check ' + + 'below is vacuous)', + RATCHET_EXPANSION_OFFER.test(real)); + expect(`#8435 — the NEW-use message marks the baseline path ${RATCHET_AUTHORITY_MARKER} (the ` + + 'baseline is shrink-only, so running --update is a maintainer action, not the author\'s ' + + 'second option)', + ratchetRemedyCarriesAuthority(real)); + + // (3)'s fixture is SYNTHETIC rather than the real message with the marker + // stripped: derived, it also fires on a rewording and misdescribes the cause. + // if/else, not two flat asserts: a fixture that stopped being an offer would + // ALSO fail the discrimination check, and the second failure would misdescribe + // the cause ("the predicate is not discriminating" when the fixture is what + // broke). Exactly one of these two can fire. + const unmarkedOffer = `example.mdx: NEW use. add it to ${BASELINE_PATH} with --update.`; + if (!RATCHET_EXPANSION_OFFER.test(unmarkedOffer)) { + expect('#8435 — the synthetic unmarked-offer fixture is no longer recognised as an offer, so ' + + 'it cannot test discrimination at all. Re-spell it to match RATCHET_EXPANSION_OFFER', false); + } else { + expect('#8435 — ratchetRemedyCarriesAuthority() REJECTS an offer carrying no marker (proves ' + + 'the predicate discriminates rather than approving everything)', + !ratchetRemedyCarriesAuthority(unmarkedOffer)); + } + + const ratchetDown = `content/docs/example.mdx: role-word count improved 4 → 2 — ratchet DOWN: ` + + 'run `node scripts/check-role-word.mjs --update` and commit the baseline.'; + expect('#8435 — the detector does NOT match the ratchet-DOWN message, which also names --update ' + + '(marking the improvement path maintainer-only would teach the opposite of the rule)', + !RATCHET_EXPANSION_OFFER.test(ratchetDown) && ratchetRemedyCarriesAuthority(ratchetDown)); + + if (failures.length) { + for (const f of failures) console.error(` x self-test: ${f}`); + console.error(`\ncheck-role-word --self-test: ${failures.length} failure(s).\n`); + process.exit(1); + } + console.log( + 'OK self-test: the NEW-use remedy marks baseline expansion as maintainer-only, the predicate ' + + 'rejects an unmarked offer, and the ratchet-DOWN remedy stays the author\'s own.', + ); + process.exit(0); +} + +if (process.argv.includes('--self-test')) selfTest(); + const files = []; for (const root of ROOTS) if (existsSync(root)) walk(root, files); @@ -76,9 +214,7 @@ const errors = []; for (const [file, count] of Object.entries(current)) { const allowed = baseline[file]; if (allowed === undefined) { - errors.push(`${file}: NEW use of the reserved word "role" (${count} occurrence(s)). ` + - `ADR-0090 D3: use permission_set / position / business_unit — or, for a genuine ` + - `boundary (better-auth, ARIA, quoted history), add it to ${BASELINE_PATH} with --update.`); + errors.push(newUseMessage(file, count)); } else if (count > allowed) { errors.push(`${file}: role-word count grew ${allowed} → ${count}. New occurrences are banned (ADR-0090 D3).`); }