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-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/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(); 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 ──────── //