From a28076a9f6556fbeef94716abeb4d3c7af043aa3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 10:59:47 +0000 Subject: [PATCH 1/2] test(devx): give check:slot-lookup a --self-test and wire it like every sibling ratchet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #4251 sweep ratchet was one of two shrink-only ratchets under scripts/ with no selfTest() at all, and the only gate among them. Its verdict is the emptiness of a finding set, so weakening its rule can only shrink what it reports and the production run cannot tell a swept tree from a broken rule. The self-test drives the REAL rule (eslint.config.mjs, through the same measuring config the production run builds) over synthetic sources in both directions, plus the ratchet comparison and both refusals, which no clean tree reaches. The silent half pins the rule that EXISTS: TYPED, never COMPLETE — #11681 was closed not_planned, so a narrow per-consumer interface must stay silent here on purpose. scripts/slot-lookup-baseline.json is untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6 --- package.json | 2 +- scripts/check-slot-lookup-ratchet.mjs | 614 ++++++++++++++++++++------ 2 files changed, 480 insertions(+), 136 deletions(-) diff --git a/package.json b/package.json index f1a1937b1d..e917b8dea4 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "check:runner-env-posture": "node scripts/check-runner-env-posture.mjs --self-test && node scripts/check-runner-env-posture.mjs", "check:cli-test-child-env": "node scripts/check-cli-test-child-env.mjs --self-test && node scripts/check-cli-test-child-env.mjs", "check:authz-resolver": "node scripts/check-single-authz-resolver.mjs --self-test && node scripts/check-single-authz-resolver.mjs", - "check:slot-lookup": "node scripts/check-slot-lookup-ratchet.mjs", + "check:slot-lookup": "node scripts/check-slot-lookup-ratchet.mjs --self-test && node scripts/check-slot-lookup-ratchet.mjs", "check:query-options-erasure": "node scripts/check-query-options-erasure-ratchet.mjs --self-test && node scripts/check-query-options-erasure-ratchet.mjs", "check:verify-stand-in": "node scripts/check-verify-stand-in-erasure.mjs --self-test && node scripts/check-verify-stand-in-erasure.mjs", "check:service-providers": "node scripts/check-service-providers.mjs", diff --git a/scripts/check-slot-lookup-ratchet.mjs b/scripts/check-slot-lookup-ratchet.mjs index 09ecb6cd9f..a3d7557ee9 100644 --- a/scripts/check-slot-lookup-ratchet.mjs +++ b/scripts/check-slot-lookup-ratchet.mjs @@ -28,6 +28,7 @@ // why a fatal is the measurement failing rather than a finding. // // node scripts/check-slot-lookup-ratchet.mjs [--update] +// node scripts/check-slot-lookup-ratchet.mjs --self-test // // The counts are produced by running ESLint itself with the baseline's // `ignores` lifted, and reports are matched by the rule's exact message @@ -37,6 +38,51 @@ // Sweeping a file means typing its lookups (pass the slot's contract), then // `--update` to drop or shrink its entry. Entries only ever go down; a batch // that adds one is doing the opposite of the job. +// +// ## Why this gate ships a `--self-test` (#12052) +// +// This is a shrink-only ratchet, and its production verdict is the emptiness of +// a finding set: green means nothing was found, weakening the rule can only +// SHRINK what is found, and the empty set is the fixed point of shrinking +// (`scripts/check-self-test-wired.mjs` opens with that argument). Of the 33 +// shrink-only ratchets under `scripts/`, this one shipped NO `selfTest()` at +// all — and `check-self-test-wired.mjs` could not see the hole, because it +// enforces the mechanically decidable superset ("every script CI runs that +// ships a `--self-test` must have that self-test run by CI too"), which a gate +// shipping none is outside BY CONSTRUCTION. +// +// Two halves of this file are unreachable from a clean tree in the strict sense +// — no mutation of them moves the production verdict at all: +// +// • `diffRatchet()`. On a tree that matches its baseline every comparison +// branch is untaken, so deleting one is invisible. MEASURED (#12052): +// deleting the `count > allowed` branch left `node +// scripts/check-slot-lookup-ratchet.mjs` GREEN (exit 0, the same 107 +// site(s) in 25 file(s) line) while `--self-test` went RED naming the case. +// • the two REFUSALS below (`ruleBlockProblem`, `populationScopeProblem`). +// Both exist for a rule that has been renamed, rescoped or split, which is +// precisely the state in which nothing else in this file is meaningful. +// +// The DETECTOR half is different, and this file states the difference rather +// than claiming a uniform blindness: because a fall below the baseline is +// itself an error here, a weakening broad enough to erase live sites reddens +// the production run too (MEASURED: narrowing SLOT_LOOKUPS to `resolveService` +// alone reddens both). What that argument does not cover is a weakening over a +// shape with no live sites, an over-fire (which the ratchet cannot see at all +// once a file is already baselined), or either refusal above. So the self-test +// drives the REAL rule — the one `eslint.config.mjs` exports and the production +// run counts — over synthetic sources, in BOTH directions: every erasure shape +// proved to REPORT, and every canonical spelling proved to stay SILENT. +// +// ⚠️ The silent half pins the rule that EXISTS. `check:slot-lookup` enforces +// that a lookup is TYPED; it does not check that the named type is COMPLETE. +// #11681 recorded a hand-written per-consumer interface that under-stated its +// consumer by a whole method while this gate stayed green, and was closed +// `not_planned` on 2026-08-25 — a completeness check has no ground truth for +// deliberately narrow per-consumer interfaces (the #4251 B4 decision for an +// OPTIONAL slot). So the narrow-interface fixture below asserts SILENCE on +// purpose: a case asserting completeness would pin a rule this gate does not +// have, and would go red the day someone reads it as a bug. import { execFileSync } from 'node:child_process'; import { readFileSync, writeFileSync } from 'node:fs'; import { dirname, resolve, relative } from 'node:path'; @@ -46,13 +92,14 @@ import { requireDependency } from './import-prerequisite.mjs'; const { ESLint } = await requireDependency('eslint', () => import('eslint'), import.meta.url); const { default: eslintConfig, SLOT_LOOKUP_ANY_MESSAGE } = await requireDependency('../eslint.config.mjs', () => import('../eslint.config.mjs'), import.meta.url); -import { lintFilesStrict } from './eslint-fatal-guard.mjs'; +import { lintFilesStrict, lintTextStrict } from './eslint-fatal-guard.mjs'; import { ensureStackHeadroom } from './eslint-stack-headroom.mjs'; // This gate lints IN-PROCESS, so it does not inherit the `--stack-size` the // root `lint` script puts on ESLint's CLI entry, and this repo's deepest file -// does not parse without it (#10449). Re-exec once, before any linting, so the -// gate carries its own headroom whatever spelling invoked it. +// does not parse without it (#10449). Re-exec once, before any linting — and +// before `--self-test` too, whose fixtures are only a fact about this gate if +// they are linted on the stack the gate actually runs with. ensureStackHeadroom(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -117,45 +164,93 @@ const POPULATION_GLOB = 'packages/**'; /** What ESLint is asked to lint: every TS dialect inside the declared population. */ const LINT_TARGET = `${POPULATION_GLOB}/*.{ts,tsx,mts,cts}`; -const update = process.argv.includes('--update'); -const baseline = JSON.parse(readFileSync(resolve(repoRoot, BASELINE_PATH), 'utf8')); -const baselinedFiles = new Set(Object.keys(baseline)); - -// The lint config with the grandfathering removed — every baselined file is -// measured as if it were already swept. Only the block that carries this rule -// is touched; every other config entry passes through untouched so the run -// stays byte-identical to `pnpm lint` in all other respects. const carriesRule = (entry) => { const rule = entry?.rules?.['no-restricted-syntax']; return Array.isArray(rule) && rule.some((r) => r?.message === SLOT_LOOKUP_ANY_MESSAGE); }; -const measuringConfig = eslintConfig.map((entry) => - carriesRule(entry) - ? { ...entry, ignores: (entry.ignores ?? []).filter((p) => !baselinedFiles.has(p)) } - : entry, -); +/** + * The config with a chosen set of `ignores` entries dropped from the block that + * carries the rule — every baselined file measured as if it were already swept. + * Every other config entry passes through untouched, so the run stays + * byte-identical to `pnpm lint` in all other respects. + */ +function measuringConfig(drop, config = eslintConfig) { + return config.map((entry) => + carriesRule(entry) + ? { ...entry, ignores: (entry.ignores ?? []).filter((p) => !drop.has(p)) } + : entry, + ); +} + +/** + * THE COUNTING PREDICATE. The rule reports from four shapes (three selectors + * plus the `slot-lookup/no-any-assignment` plugin rule) under one exact + * message, so the count is matched on the message and this gate never has to + * know there are four. Shared by the production measurement and `--self-test`, + * so a self-test case cannot pass through a counter the gate does not use. + */ +const countRuleHits = (messages) => + (messages ?? []).filter((m) => m.message === SLOT_LOOKUP_ANY_MESSAGE).length; + +async function measure(drop) { + const eslint = new ESLint({ + cwd: repoRoot, + overrideConfigFile: true, + baseConfig: measuringConfig(drop), + // Match the root `lint` script: this repo lints with --no-inline-config on + // purpose, so an eslint-disable comment must not shrink a count here either. + allowInlineConfig: false, + }); + // Not `eslint.lintFiles`: a parse failure inside the population is the + // measurement failing, not a file with nothing to report, and it matches none + // of the filters below. The guard names the file and stops (#10123). + const results = await lintFilesStrict(eslint, [LINT_TARGET], { + gate: 'check-slot-lookup-ratchet', + repoRoot, + }); + const counts = {}; + for (const result of results) { + const hits = countRuleHits(result.messages); + if (hits > 0) counts[relative(repoRoot, result.filePath).replace(/\\/g, '/')] = hits; + } + return counts; +} + +const sortKeys = (counts) => + Object.fromEntries(Object.entries(counts).sort(([a], [b]) => a.localeCompare(b))); -if (!eslintConfig.some(carriesRule)) { - console.error( +/** + * REFUSAL 1: the rule is gone. Returns the refusal text, or null. + * + * A renamed rule, a removed one, or a changed message all produce the same + * observable — zero reports — which is indistinguishable from a swept tree. + */ +function ruleBlockProblem(config) { + if (config.some(carriesRule)) return null; + return ( 'check-slot-lookup-ratchet: no config block carries the slot-lookup rule.\n' + 'The rule was renamed, removed, or its message changed without updating\n' + 'SLOT_LOOKUP_ANY_MESSAGE — refusing to report "clean" for a rule that is\n' + - 'no longer being measured.', + 'no longer being measured.' ); - process.exit(2); } -// The declaration above states what this gate READS, and a dispatch pastes it -// into a prompt as a lead. It is only true while it agrees with the scope the -// RULE is configured for — the config block's own `files`. Copies in two files -// drift silently, so the agreement is asserted rather than assumed: if the rule -// is ever rescoped (another extension, another root, a second block), this -// refuses to run instead of measuring a population that no longer matches -// either what `pnpm lint` enforces or what `dispatch-gates` was told. -const ruleScopes = eslintConfig.filter(carriesRule).flatMap((entry) => entry.files ?? []); -if (ruleScopes.length !== 1 || ruleScopes[0] !== LINT_TARGET) { - console.error( +/** + * REFUSAL 2: the declaration above states what this gate READS, and a dispatch + * pastes it into a prompt as a lead. It is only true while it agrees with the + * scope the RULE is configured for — the config block's own `files`. Copies in + * two files drift silently, so the agreement is asserted rather than assumed: + * if the rule is ever rescoped (another extension, another root, a second + * block), this refuses to run instead of measuring a population that no longer + * matches either what `pnpm lint` enforces or what `dispatch-gates` was told. + * + * Returns the refusal text, or null. + */ +function populationScopeProblem(config) { + const ruleScopes = config.filter(carriesRule).flatMap((entry) => entry.files ?? []); + if (ruleScopes.length === 1 && ruleScopes[0] === LINT_TARGET) return null; + return ( 'check-slot-lookup-ratchet: the declared population no longer matches the\n' + 'scope the rule is configured for.\n' + ` declared here: ${LINT_TARGET}\n` + @@ -163,132 +258,381 @@ if (ruleScopes.length !== 1 || ruleScopes[0] !== LINT_TARGET) { 'Update POPULATION_GLOB/LINT_TARGET in this file to match eslint.config.mjs.\n' + 'This declaration is read by scripts/pm/dispatch-gates.mjs to decide which\n' + 'cards are told to run this gate, so a stale one is a wrong lead, not a\n' + - 'cosmetic mismatch — refusing rather than measuring the wrong set.', + 'cosmetic mismatch — refusing rather than measuring the wrong set.' ); - process.exit(2); } -const eslint = new ESLint({ - cwd: repoRoot, - overrideConfigFile: true, - baseConfig: measuringConfig, - // Match the root `lint` script: this repo lints with --no-inline-config on - // purpose, so an eslint-disable comment must not shrink a count here either. - allowInlineConfig: false, -}); - -// Not `eslint.lintFiles`: a parse failure inside the population is the -// measurement failing, not a file with nothing to report, and it matches none -// of the filters below. The guard names the file and stops (#10123). -const results = await lintFilesStrict(eslint, [LINT_TARGET], { - gate: 'check-slot-lookup-ratchet', - repoRoot, -}); - -const current = {}; -for (const result of results) { - const hits = result.messages.filter((m) => m.message === SLOT_LOOKUP_ANY_MESSAGE).length; - if (hits > 0) current[relative(repoRoot, result.filePath).replace(/\\/g, '/')] = hits; -} +/** + * Every failure this gate can report, as a pure function of the measured facts. + * Kept pure so --self-test can drive it in BOTH directions without needing a + * repo in a particular state — the comparison logic is the half of this script + * that a green run over a clean tree cannot exercise at all. + */ +function diffRatchet({ baseline, current, addedBaselineKeys }) { + const errors = []; -const sorted = Object.fromEntries(Object.entries(current).sort(([a], [b]) => a.localeCompare(b))); + for (const [file, count] of Object.entries(current)) { + const allowed = baseline[file]; + if (allowed === undefined) { + errors.push( + `${file}: NEW service-lookup erasure (${count} site(s)). Pass the slot's ` + + `contract type instead of \`any\` — see eslint.config.mjs and issue #4251. ` + + `This file is not grandfathered, and the baseline never grows.`, + ); + } else if (count > allowed) { + errors.push( + `${file}: erasure count grew ${allowed} → ${count}. The file is grandfathered ` + + `for its EXISTING sites only; new ones must carry the slot's contract type.`, + ); + } + } -if (update) { - writeFileSync(resolve(repoRoot, BASELINE_PATH), JSON.stringify(sorted, null, 2) + '\n'); - const files = Object.keys(sorted).length; - const sites = Object.values(sorted).reduce((a, b) => a + b, 0); - console.log(`slot-lookup baseline updated: ${sites} site(s) in ${files} file(s).`); - process.exit(0); -} + for (const [file, allowed] of Object.entries(baseline)) { + const now = current[file]; + if (now === undefined) { + errors.push( + `${file}: baselined file is clean/gone (was ${allowed}) — ratchet DOWN: run ` + + `\`pnpm check:slot-lookup --update\` and commit the baseline.`, + ); + } else if (now < allowed) { + errors.push( + `${file}: erasure count fell ${allowed} → ${now} — ratchet DOWN: run ` + + `\`pnpm check:slot-lookup --update\` and commit the baseline.`, + ); + } + } -const errors = []; -for (const [file, count] of Object.entries(sorted)) { - const allowed = baseline[file]; - if (allowed === undefined) { - errors.push( - `${file}: NEW service-lookup erasure (${count} site(s)). Pass the slot's ` + - `contract type instead of \`any\` — see eslint.config.mjs and issue #4251. ` + - `This file is not grandfathered, and the baseline never grows.`, - ); - } else if (count > allowed) { + // The key set must only ever SHRINK. Counts alone cannot see the last move: + // a genuinely-erasing NEW file added to the baseline matches its own count and + // sails through, which would turn the grandfather list into a general-purpose + // mute button. + for (const file of addedBaselineKeys ?? []) { errors.push( - `${file}: erasure count grew ${allowed} → ${count}. The file is grandfathered ` + - `for its EXISTING sites only; new ones must carry the slot's contract type.`, + `${file}: ADDED to the baseline. The grandfather list is not a mute button — it ` + + `only ever shrinks. Type this file's lookups instead; see issue #4251.`, ); } + + return errors; } -for (const [file, allowed] of Object.entries(baseline)) { - const now = sorted[file]; - if (now === undefined) { - errors.push( - `${file}: baselined file is clean/gone (was ${allowed}) — ratchet DOWN: run ` + - `\`pnpm check:slot-lookup --update\` and commit the baseline.`, - ); - } else if (now < allowed) { - errors.push( - `${file}: erasure count fell ${allowed} → ${now} — ratchet DOWN: run ` + - `\`pnpm check:slot-lookup --update\` and commit the baseline.`, - ); + +/** + * Baseline keys that are not present at the merge base with `main`, or null + * when that reference could not be read. + * + * The reference is the baseline as it stands on the merge base with origin/main + * — on a sweep branch keys only disappear, and on main the merge base is HEAD, + * so the comparison is a no-op there. + */ +function baselineKeysAddedSinceMergeBase(baselineKeys) { + try { + const git = (...args) => + execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + let base; + for (const ref of ['origin/main', 'main']) { + try { base = git('merge-base', 'HEAD', ref); break; } catch { /* try the next ref */ } + } + if (!base) return null; + const previous = JSON.parse(git('show', `${base}:${BASELINE_PATH}`)); + return { base: base.slice(0, 7), added: baselineKeys.filter((f) => !(f in previous)) }; + } catch { + // No git, a shallow clone without the base, or the baseline is new on this + // branch (`git show` fails). Reported by the caller rather than passed over + // — a check that cannot run must not read as a check that passed. + return null; } } -// The key set must only ever SHRINK. Counts alone cannot see the last move: -// a genuinely-erasing NEW file added to the baseline matches its own count and -// sails through, which would turn the grandfather list into a general-purpose -// mute button. The reference is the baseline as it stands on the merge base -// with origin/main — on a sweep branch keys only disappear, and on main the -// merge base is HEAD, so the comparison is a no-op there. -let monotonicity = null; -try { - const git = (...args) => - execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); - let base; - for (const ref of ['origin/main', 'main']) { - try { base = git('merge-base', 'HEAD', ref); break; } catch { /* try the next ref */ } +// --------------------------------------------------------------------------- +// --self-test + +/** A path inside the declared population, for fixtures linted as text. */ +const FIXTURE_FILE = 'packages/__slot_lookup_selftest__/src/fixture.ts'; + +/** + * A second fixture path, used ONLY as a synthetic grandfather entry. + * + * The grandfathering channel is what this whole gate exists to re-measure, and + * pinning it against a real baseline key would make the pin evaporate on the + * day the sweep finishes: at 0 entries there is no key to read, and a case that + * silently stops asserting is worth less than no case (#12050). So the witness + * is a PAIR built here — the same source, the same config, one synthetic + * `ignores` entry apart — which stays exercised at baseline zero. The real + * baseline is then checked too, when it has an entry to check. + */ +const FIXTURE_BASELINED = 'packages/__slot_lookup_selftest__/src/grandfathered.ts'; + +/** Erasure shapes that MUST report. One per shape the rule knows. */ +const REPORTS = [ + ['selector 1 — `: any` on the declarator', "const svc: any = await deps.resolveService('auth', env);"], + ['selector 2 — `as any` on the call', "const s = ctx.getService('settings') as any;"], + ['selector 3 — the type-argument form', "const q = ctx.getService('data');"], + [ + 'the plugin rule — declaration and lookup split apart (#4251)', + "let ql: any; try { ql = ctx.getService('objectql'); } catch { /* optional */ }", + ], + [ + 'the third lookup name is covered too', + "const k = await kernel.getRequestKernelService('auth');", + ], +]; + +/** + * Spellings that MUST stay silent. Every one of them is a shape the repo + * deliberately writes, and a rule that reported them would be argued back out. + */ +const SILENT = [ + ['a typed lookup is the whole point', "const s = ctx.getService('settings');"], + [ + // ⚠️ THE BOUNDARY, pinned deliberately (#11681, closed not_planned). + // `SettingsReadSurface` names one method; the call reaches another. The + // gate is TYPED-ness, never COMPLETEness — see this file's header. + 'a narrow per-consumer interface stays silent even where the call exceeds it', + 'interface SettingsReadSurface { get(key: string): Promise; }\n' + + "const s = ctx.getService('settings');\n" + + "await s.getMany(['a', 'b']);", + ], + ['an UNCONTRACTED_SLOTS slot is exempt BY NAME', "const p: any = ctx.getService('protocol');"], + ['the same exemption through the type-argument form', "const m = ctx.getService('mcp');"], + ['`getObjectQL` is not a slot lookup', 'const q = ctx.getObjectQL() as any;'], + ['an `as any` with no lookup in it', 'const x = compute() as any;'], + [ + 'the typed split declaration — the shape the sweep produces', + "let i18n: II18nService | undefined; i18n = ctx.getService('i18n');", + ], + ['a non-any split declaration', "let ql: unknown; ql = ctx.getService('objectql');"], +]; + +/** The ratchet comparison, in both directions. `base` is two files deep. */ +const DIFF_CASES = (() => { + const baseline = { 'a.ts': 2, 'b.ts': 1 }; + return [ + ['identical is clean', { baseline, current: { ...baseline }, addedBaselineKeys: [] }, 0], + ['a new file fails', { baseline, current: { ...baseline, 'c.ts': 1 }, addedBaselineKeys: [] }, 1], + ['growth in a baselined file fails', { baseline, current: { 'a.ts': 3, 'b.ts': 1 }, addedBaselineKeys: [] }, 1], + ['a fall must be ratcheted down', { baseline, current: { 'a.ts': 1, 'b.ts': 1 }, addedBaselineKeys: [] }, 1], + ['a cleaned file must be dropped', { baseline, current: { 'a.ts': 2 }, addedBaselineKeys: [] }, 1], + ['a key added to the baseline fails', { baseline, current: { ...baseline }, addedBaselineKeys: ['b.ts'] }, 1], + ]; +})(); + +async function selfTest() { + const failures = []; + const assert = (cond, msg) => { if (!cond) failures.push(msg); }; + + /** Lint one fixture through a chosen config and COUNT it the way the gate does. */ + const hitsUnder = async (config, code, filePath = FIXTURE_FILE) => { + const eslint = new ESLint({ + cwd: repoRoot, + overrideConfigFile: true, + baseConfig: config, + allowInlineConfig: false, + }); + // Counted, therefore guarded (#10599). A fixture that stops parsing yields + // zero matching messages — exactly what the SILENT cases assert — so an + // unguarded count here would read a TYPO as proof the rule is correctly + // quiet. A fatal becomes a self-test failure naming the fixture. + const [result] = await lintTextStrict(eslint, code, { + filePath, + warnIgnored: false, + gate: 'check-slot-lookup-ratchet --self-test fixture', + repoRoot, + onFatal: (report) => { failures.push(report); return []; }, + }); + return countRuleHits(result?.messages); + }; + + // ── 1. The rule, in both directions, over synthetic sources. ────────────── + // + // The config is the REAL one from eslint.config.mjs with the baseline's + // grandfathering lifted — the same construction `measure()` runs — so these + // cases move with the rule and cannot be satisfied by a re-implementation. + const baseline = JSON.parse(readFileSync(resolve(repoRoot, BASELINE_PATH), 'utf8')); + const measuring = measuringConfig(new Set(Object.keys(baseline))); + + for (const [name, code] of REPORTS) { + assert(await hitsUnder(measuring, code) >= 1, `expected a report for ${name}: ${code}`); } - if (base) { - const previous = JSON.parse(git('show', `${base}:${BASELINE_PATH}`)); - const added = Object.keys(baseline).filter((f) => !(f in previous)); - monotonicity = { base: base.slice(0, 7), added }; + for (const [name, code] of SILENT) { + const hits = await hitsUnder(measuring, code); + assert(hits === 0, `expected NO report for ${name} (got ${hits}): ${code}`); } -} catch { - // No git, a shallow clone without the base, or the baseline is new on this - // branch (`git show` fails). Reported below rather than passed over — a - // check that cannot run must not read as a check that passed. -} -if (monotonicity?.added.length) { - for (const file of monotonicity.added) { - errors.push( - `${file}: ADDED to the baseline (not present at ${monotonicity.base}). The ` + - `grandfather list is not a mute button — it only ever shrinks. Type this ` + - `file's lookups instead; see issue #4251.`, + // ── 2. The grandfathering channel, as a synthetic witness pair. ─────────── + // + // Same source, same config, one `ignores` entry apart. This is the move the + // whole gate is built around — an ignored file is ignored COMPLETELY — so it + // is proved rather than assumed, and proved in a way that survives the + // baseline shrinking to zero entries. + { + const code = "const s = ctx.getService('data');"; + const withEntry = eslintConfig.map((entry) => + carriesRule(entry) + ? { ...entry, ignores: [...(entry.ignores ?? []), FIXTURE_BASELINED] } + : entry, ); + const lifted = measuringConfig(new Set([FIXTURE_BASELINED]), withEntry); + assert( + (await hitsUnder(withEntry, code, FIXTURE_BASELINED)) === 0, + 'a grandfathered path must be silent while its entry stands — otherwise the ' + + 'baseline is not what silences a listed file, and this gate measures nothing new', + ); + assert( + (await hitsUnder(lifted, code, FIXTURE_BASELINED)) >= 1, + 'the SAME path must report once its entry is lifted — that lift is the whole ' + + 'measurement this gate performs', + ); + assert( + (await hitsUnder(withEntry, code)) >= 1, + 'a path that is not grandfathered must report under the blocking config', + ); + + // And the real baseline, when it still has an entry to prove it with. The + // pair above is what keeps this half honest once it does not. + const realEntry = Object.keys(baseline)[0]; + if (realEntry !== undefined) { + assert( + (await hitsUnder(eslintConfig, code, realEntry)) === 0, + `a baselined path must be silent under the blocking config (${realEntry})`, + ); + assert( + (await hitsUnder(measuring, code, realEntry)) >= 1, + `and must be measured once this gate lifts the grandfathering (${realEntry})`, + ); + } + } + + // ── 3. The ratchet comparison, in both directions. ──────────────────────── + for (const [name, input, expected] of DIFF_CASES) { + const got = diffRatchet(input).length; + assert(got === expected, `diffRatchet: ${name} — expected ${expected} error(s), got ${got}`); } + + // ── 4. Both refusals, in both directions. ───────────────────────────────── + // + // Neither is reachable from a tree where the rule is healthy, so a green + // production run says nothing about either — they are the strictest case of + // the argument in this file's header. + { + assert(ruleBlockProblem(eslintConfig) === null, 'the live config must carry the slot-lookup rule'); + const renamed = eslintConfig.map((entry) => + carriesRule(entry) + ? { ...entry, rules: { ...entry.rules, 'no-restricted-syntax': ['error', { selector: 'X', message: 'renamed' }] } } + : entry, + ); + assert( + ruleBlockProblem(renamed) !== null, + 'a config whose rule message changed must REFUSE, not report a clean tree', + ); + + assert( + populationScopeProblem(eslintConfig) === null, + 'the declared population must match the scope the rule is configured for', + ); + const rescoped = eslintConfig.map((entry) => + carriesRule(entry) ? { ...entry, files: ['packages/runtime/**/*.{ts,tsx,mts,cts}'] } : entry, + ); + assert( + populationScopeProblem(rescoped) !== null, + 'a rule narrowed back to one package must REFUSE — that narrower scope is the ' + + 'pre-#4251 state in which 77 of 80 known sites went unlinted while looking covered', + ); + const doubled = [...eslintConfig, ...eslintConfig.filter(carriesRule)]; + assert( + populationScopeProblem(doubled) !== null, + 'a SECOND block carrying the rule must REFUSE — the declaration names one scope', + ); + } + + // ── 5. The counter and the rule cannot disagree. ────────────────────────── + // + // `countRuleHits` matches on the exact message, which is what lets four + // report shapes be counted by a gate that knows of none of them. + assert( + countRuleHits([{ message: SLOT_LOOKUP_ANY_MESSAGE }, { message: 'something else' }]) === 1, + 'the counter must match the rule message exactly', + ); + assert( + /#4251/.test(SLOT_LOOKUP_ANY_MESSAGE) && /UNCONTRACTED_SLOTS/.test(SLOT_LOOKUP_ANY_MESSAGE), + 'the rule message must keep naming the sweep card and the one reviewable exemption ' + + 'channel — this repo lints with --no-inline-config, so an author who reads only the ' + + 'message must still be told where a legitimate `any` is declared', + ); + + if (failures.length > 0) { + console.error(`✗ check-slot-lookup-ratchet --self-test: ${failures.length} failure(s).\n`); + for (const f of failures) console.error(` • ${f}`); + process.exit(1); + } + + console.log( + `✓ check-slot-lookup-ratchet --self-test: the live rule reports all ${REPORTS.length} ` + + `erasure shape(s) and stays silent on all ${SILENT.length} canonical spelling(s) ` + + `(including the narrow per-consumer interface #11681 closed not_planned — this gate ` + + `checks TYPED, never COMPLETE); the grandfathering channel proved both ways on a ` + + `synthetic witness pair that survives baseline zero; ${DIFF_CASES.length} ratchet ` + + `comparison case(s); and both refusals proved in both directions.`, + ); } -const totalSites = Object.values(sorted).reduce((a, b) => a + b, 0); -const totalFiles = Object.keys(sorted).length; +// --------------------------------------------------------------------------- +// main + +async function main() { + const blocked = ruleBlockProblem(eslintConfig) ?? populationScopeProblem(eslintConfig); + if (blocked !== null) { + console.error(blocked); + process.exit(2); + } -if (errors.length > 0) { - console.error(`✗ slot-lookup ratchet (${errors.length} problem(s)):\n`); - for (const e of errors) console.error(` • ${e}`); - console.error( - `\nUnswept: ${totalSites} site(s) in ${totalFiles} file(s). ` + - `Sweeping is #4251's batch work — see SLOT_LOOKUP_UNSWEPT in eslint.config.mjs.`, + const update = process.argv.includes('--update'); + const baseline = JSON.parse(readFileSync(resolve(repoRoot, BASELINE_PATH), 'utf8')); + const sorted = sortKeys(await measure(new Set(Object.keys(baseline)))); + + if (update) { + writeFileSync(resolve(repoRoot, BASELINE_PATH), JSON.stringify(sorted, null, 2) + '\n'); + const files = Object.keys(sorted).length; + const sites = Object.values(sorted).reduce((a, b) => a + b, 0); + console.log(`slot-lookup baseline updated: ${sites} site(s) in ${files} file(s).`); + process.exit(0); + } + + const monotonicity = baselineKeysAddedSinceMergeBase(Object.keys(baseline)); + const errors = diffRatchet({ + baseline, + current: sorted, + addedBaselineKeys: monotonicity?.added ?? [], + }); + + const totalSites = Object.values(sorted).reduce((a, b) => a + b, 0); + const totalFiles = Object.keys(sorted).length; + + if (errors.length > 0) { + console.error(`✗ slot-lookup ratchet (${errors.length} problem(s)):\n`); + for (const e of errors) console.error(` • ${e}`); + console.error( + `\nUnswept: ${totalSites} site(s) in ${totalFiles} file(s). ` + + `Sweeping is #4251's batch work — see SLOT_LOOKUP_UNSWEPT in eslint.config.mjs.`, + ); + process.exit(1); + } + + console.log( + `✓ slot-lookup ratchet holds: ${totalSites} unswept site(s) in ${totalFiles} file(s), ` + + `none new, and every file in the population parsed. Every other file under ` + + `packages/ is covered by \`pnpm lint\`.`, + ); + console.log( + monotonicity + ? ` baseline key set verified against ${monotonicity.base}: no files added.` + : ` NOT verified: could not read the baseline at the merge base with main ` + + `(no git, shallow clone, or the baseline is new here), so "no files added" ` + + `is unchecked this run.`, ); - process.exit(1); } -console.log( - `✓ slot-lookup ratchet holds: ${totalSites} unswept site(s) in ${totalFiles} file(s), ` + - `none new, and every file in the population parsed. Every other file under ` + - `packages/ is covered by \`pnpm lint\`.`, -); -console.log( - monotonicity - ? ` baseline key set verified against ${monotonicity.base}: no files added.` - : ` NOT verified: could not read the baseline at the merge base with main ` + - `(no git, shallow clone, or the baseline is new here), so "no files added" ` + - `is unchecked this run.`, -); +if (process.argv.includes('--self-test')) { + await selfTest(); + process.exit(0); +} +await main(); From 9fe3f66d5a9002483300e618f2127e2c2a8b6b4b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 11:10:01 +0000 Subject: [PATCH 2/2] docs(scripts): repair the two prose statements this self-test falsifies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both files state that `pnpm check:slot-lookup` has no --self-test hook — a premise for why the guard-adoption walk in check-query-options-erasure-ratchet's self-test is the only coverage of the other gate's call site. The wiring change in this PR makes the first clause false while the conclusion stays true, so the sentences are re-spelled rather than deleted. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6 --- scripts/check-query-options-erasure-ratchet.mjs | 7 ++++--- scripts/eslint-fatal-guard.mjs | 5 +++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/scripts/check-query-options-erasure-ratchet.mjs b/scripts/check-query-options-erasure-ratchet.mjs index a1f0b9d397..ea17669445 100644 --- a/scripts/check-query-options-erasure-ratchet.mjs +++ b/scripts/check-query-options-erasure-ratchet.mjs @@ -809,9 +809,10 @@ async function selfTest() { ); } - // And the live tree. This is also the only wired coverage of the OTHER - // gate's call site: `pnpm check:slot-lookup` has no --self-test hook, and - // CI runs this one before the gate itself. + // And the live tree. This is also the only coverage of the OTHER gate's + // call site: `pnpm check:slot-lookup` ships a `--self-test` of its own + // (#12052) but does not repeat this adoption walk, and CI runs this one + // ahead of either gate. for (const problem of checkGuardAdoption(repoRoot)) assert(false, problem); } diff --git a/scripts/eslint-fatal-guard.mjs b/scripts/eslint-fatal-guard.mjs index c6978e1c93..c1abe63231 100644 --- a/scripts/eslint-fatal-guard.mjs +++ b/scripts/eslint-fatal-guard.mjs @@ -69,8 +69,9 @@ // about every gate in GUARDED_GATES by reading their source. That assertion is // driven by check-query-options-erasure-ratchet.mjs's `--self-test`, which CI // runs ahead of the gate itself (`pnpm check:query-options-erasure`); -// `pnpm check:slot-lookup` has no self-test hook of its own, so the coverage of -// ITS call site is the source assertion, not a second wired self-test. +// `pnpm check:slot-lookup` ships a `--self-test` of its own (#12052) but does +// not repeat this walk, so the coverage of ITS call site is still the source +// assertion here, not a second copy of it. // // ── MEASURED (#10458): reading the source has to mean reading CODE ──────── //