|
| 1 | +#!/usr/bin/env node |
| 2 | +// check-slot-lookup-ratchet — the #4251 sweep ratchet, enforced. |
| 3 | +// |
| 4 | +// `eslint.config.mjs` bans erasing a service-lookup result to `any` |
| 5 | +// (#4127/#4214/#4251) across all of packages/, and grandfathers the files that |
| 6 | +// still hold pre-existing sites by listing them in |
| 7 | +// scripts/slot-lookup-baseline.json. ESLint's `ignores` alone cannot express a |
| 8 | +// ratchet: an ignored file is ignored completely, so NEW erasures added to a |
| 9 | +// listed file ride the existing entry in total silence — the same |
| 10 | +// declared-but-unchecked shape this whole work line keeps finding (#4320's |
| 11 | +// options configured a block that never ran; nothing checked the promise). |
| 12 | +// |
| 13 | +// So the baseline carries COUNTS, and this script is what makes them mean |
| 14 | +// something. It fails when: |
| 15 | +// • a file NOT in the baseline reports a site (that already fails `pnpm lint` |
| 16 | +// — reported here too so one command explains the whole picture), or |
| 17 | +// • a baselined file's count INCREASES (new erasure hiding behind an old |
| 18 | +// entry — the invisible move), or |
| 19 | +// • a baselined file's count DECREASED or the file is clean/gone (progress!) |
| 20 | +// — run with --update to ratchet the baseline down and commit it. |
| 21 | +// |
| 22 | +// node scripts/check-slot-lookup-ratchet.mjs [--update] |
| 23 | +// |
| 24 | +// The counts are produced by running ESLint itself with the baseline's |
| 25 | +// `ignores` lifted, and reports are matched by the rule's exact message |
| 26 | +// (imported from the config). The counter therefore cannot drift from the |
| 27 | +// rule: change the selectors and this re-measures against them. |
| 28 | +// |
| 29 | +// Sweeping a file means typing its lookups (pass the slot's contract), then |
| 30 | +// `--update` to drop or shrink its entry. Entries only ever go down; a batch |
| 31 | +// that adds one is doing the opposite of the job. |
| 32 | +import { execFileSync } from 'node:child_process'; |
| 33 | +import { readFileSync, writeFileSync } from 'node:fs'; |
| 34 | +import { dirname, resolve, relative } from 'node:path'; |
| 35 | +import { fileURLToPath } from 'node:url'; |
| 36 | + |
| 37 | +import { ESLint } from 'eslint'; |
| 38 | + |
| 39 | +import eslintConfig, { SLOT_LOOKUP_ANY_MESSAGE } from '../eslint.config.mjs'; |
| 40 | + |
| 41 | +const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 42 | +const repoRoot = resolve(__dirname, '..'); |
| 43 | +const BASELINE_PATH = 'scripts/slot-lookup-baseline.json'; |
| 44 | + |
| 45 | +const update = process.argv.includes('--update'); |
| 46 | +const baseline = JSON.parse(readFileSync(resolve(repoRoot, BASELINE_PATH), 'utf8')); |
| 47 | +const baselinedFiles = new Set(Object.keys(baseline)); |
| 48 | + |
| 49 | +// The lint config with the grandfathering removed — every baselined file is |
| 50 | +// measured as if it were already swept. Only the block that carries this rule |
| 51 | +// is touched; every other config entry passes through untouched so the run |
| 52 | +// stays byte-identical to `pnpm lint` in all other respects. |
| 53 | +const carriesRule = (entry) => { |
| 54 | + const rule = entry?.rules?.['no-restricted-syntax']; |
| 55 | + return Array.isArray(rule) && rule.some((r) => r?.message === SLOT_LOOKUP_ANY_MESSAGE); |
| 56 | +}; |
| 57 | + |
| 58 | +const measuringConfig = eslintConfig.map((entry) => |
| 59 | + carriesRule(entry) |
| 60 | + ? { ...entry, ignores: (entry.ignores ?? []).filter((p) => !baselinedFiles.has(p)) } |
| 61 | + : entry, |
| 62 | +); |
| 63 | + |
| 64 | +if (!eslintConfig.some(carriesRule)) { |
| 65 | + console.error( |
| 66 | + 'check-slot-lookup-ratchet: no config block carries the slot-lookup rule.\n' + |
| 67 | + 'The rule was renamed, removed, or its message changed without updating\n' + |
| 68 | + 'SLOT_LOOKUP_ANY_MESSAGE — refusing to report "clean" for a rule that is\n' + |
| 69 | + 'no longer being measured.', |
| 70 | + ); |
| 71 | + process.exit(2); |
| 72 | +} |
| 73 | + |
| 74 | +const eslint = new ESLint({ |
| 75 | + cwd: repoRoot, |
| 76 | + overrideConfigFile: true, |
| 77 | + baseConfig: measuringConfig, |
| 78 | + // Match the root `lint` script: this repo lints with --no-inline-config on |
| 79 | + // purpose, so an eslint-disable comment must not shrink a count here either. |
| 80 | + allowInlineConfig: false, |
| 81 | +}); |
| 82 | + |
| 83 | +const results = await eslint.lintFiles(['packages/**/*.{ts,tsx,mts,cts}']); |
| 84 | + |
| 85 | +const current = {}; |
| 86 | +for (const result of results) { |
| 87 | + const hits = result.messages.filter((m) => m.message === SLOT_LOOKUP_ANY_MESSAGE).length; |
| 88 | + if (hits > 0) current[relative(repoRoot, result.filePath).replace(/\\/g, '/')] = hits; |
| 89 | +} |
| 90 | + |
| 91 | +const sorted = Object.fromEntries(Object.entries(current).sort(([a], [b]) => a.localeCompare(b))); |
| 92 | + |
| 93 | +if (update) { |
| 94 | + writeFileSync(resolve(repoRoot, BASELINE_PATH), JSON.stringify(sorted, null, 2) + '\n'); |
| 95 | + const files = Object.keys(sorted).length; |
| 96 | + const sites = Object.values(sorted).reduce((a, b) => a + b, 0); |
| 97 | + console.log(`slot-lookup baseline updated: ${sites} site(s) in ${files} file(s).`); |
| 98 | + process.exit(0); |
| 99 | +} |
| 100 | + |
| 101 | +const errors = []; |
| 102 | +for (const [file, count] of Object.entries(sorted)) { |
| 103 | + const allowed = baseline[file]; |
| 104 | + if (allowed === undefined) { |
| 105 | + errors.push( |
| 106 | + `${file}: NEW service-lookup erasure (${count} site(s)). Pass the slot's ` + |
| 107 | + `contract type instead of \`any\` — see eslint.config.mjs and issue #4251. ` + |
| 108 | + `This file is not grandfathered, and the baseline never grows.`, |
| 109 | + ); |
| 110 | + } else if (count > allowed) { |
| 111 | + errors.push( |
| 112 | + `${file}: erasure count grew ${allowed} → ${count}. The file is grandfathered ` + |
| 113 | + `for its EXISTING sites only; new ones must carry the slot's contract type.`, |
| 114 | + ); |
| 115 | + } |
| 116 | +} |
| 117 | +for (const [file, allowed] of Object.entries(baseline)) { |
| 118 | + const now = sorted[file]; |
| 119 | + if (now === undefined) { |
| 120 | + errors.push( |
| 121 | + `${file}: baselined file is clean/gone (was ${allowed}) — ratchet DOWN: run ` + |
| 122 | + `\`pnpm check:slot-lookup --update\` and commit the baseline.`, |
| 123 | + ); |
| 124 | + } else if (now < allowed) { |
| 125 | + errors.push( |
| 126 | + `${file}: erasure count fell ${allowed} → ${now} — ratchet DOWN: run ` + |
| 127 | + `\`pnpm check:slot-lookup --update\` and commit the baseline.`, |
| 128 | + ); |
| 129 | + } |
| 130 | +} |
| 131 | + |
| 132 | +// The key set must only ever SHRINK. Counts alone cannot see the last move: |
| 133 | +// a genuinely-erasing NEW file added to the baseline matches its own count and |
| 134 | +// sails through, which would turn the grandfather list into a general-purpose |
| 135 | +// mute button. The reference is the baseline as it stands on the merge base |
| 136 | +// with origin/main — on a sweep branch keys only disappear, and on main the |
| 137 | +// merge base is HEAD, so the comparison is a no-op there. |
| 138 | +let monotonicity = null; |
| 139 | +try { |
| 140 | + const git = (...args) => |
| 141 | + execFileSync('git', args, { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); |
| 142 | + let base; |
| 143 | + for (const ref of ['origin/main', 'main']) { |
| 144 | + try { base = git('merge-base', 'HEAD', ref); break; } catch { /* try the next ref */ } |
| 145 | + } |
| 146 | + if (base) { |
| 147 | + const previous = JSON.parse(git('show', `${base}:${BASELINE_PATH}`)); |
| 148 | + const added = Object.keys(baseline).filter((f) => !(f in previous)); |
| 149 | + monotonicity = { base: base.slice(0, 7), added }; |
| 150 | + } |
| 151 | +} catch { |
| 152 | + // No git, a shallow clone without the base, or the baseline is new on this |
| 153 | + // branch (`git show` fails). Reported below rather than passed over — a |
| 154 | + // check that cannot run must not read as a check that passed. |
| 155 | +} |
| 156 | + |
| 157 | +if (monotonicity?.added.length) { |
| 158 | + for (const file of monotonicity.added) { |
| 159 | + errors.push( |
| 160 | + `${file}: ADDED to the baseline (not present at ${monotonicity.base}). The ` + |
| 161 | + `grandfather list is not a mute button — it only ever shrinks. Type this ` + |
| 162 | + `file's lookups instead; see issue #4251.`, |
| 163 | + ); |
| 164 | + } |
| 165 | +} |
| 166 | + |
| 167 | +const totalSites = Object.values(sorted).reduce((a, b) => a + b, 0); |
| 168 | +const totalFiles = Object.keys(sorted).length; |
| 169 | + |
| 170 | +if (errors.length > 0) { |
| 171 | + console.error(`✗ slot-lookup ratchet (${errors.length} problem(s)):\n`); |
| 172 | + for (const e of errors) console.error(` • ${e}`); |
| 173 | + console.error( |
| 174 | + `\nUnswept: ${totalSites} site(s) in ${totalFiles} file(s). ` + |
| 175 | + `Sweeping is #4251's batch work — see SLOT_LOOKUP_UNSWEPT in eslint.config.mjs.`, |
| 176 | + ); |
| 177 | + process.exit(1); |
| 178 | +} |
| 179 | + |
| 180 | +console.log( |
| 181 | + `✓ slot-lookup ratchet holds: ${totalSites} unswept site(s) in ${totalFiles} file(s), ` + |
| 182 | + `none new. Every other file under packages/ is covered by \`pnpm lint\`.`, |
| 183 | +); |
| 184 | +console.log( |
| 185 | + monotonicity |
| 186 | + ? ` baseline key set verified against ${monotonicity.base}: no files added.` |
| 187 | + : ` NOT verified: could not read the baseline at the merge base with main ` + |
| 188 | + `(no git, shallow clone, or the baseline is new here), so "no files added" ` + |
| 189 | + `is unchecked this run.`, |
| 190 | +); |
0 commit comments