ratchet-eslint-rules.ts425 lines · main
| 1 | /* eslint-disable turbo/no-undeclared-env-vars */ |
| 2 | /** |
| 3 | * Ratchet ESLint violations for selected rules. |
| 4 | * |
| 5 | * Examples: |
| 6 | * # Initialize baselines for two rules |
| 7 | * tsx scripts/ratchet-eslint-rules.ts --init \ |
| 8 | * --rule react-hooks/exhaustive-deps --rule no-console |
| 9 | * |
| 10 | * # Compare current counts vs baselines |
| 11 | * tsx scripts/ratchet-eslint-rules.ts \ |
| 12 | * --rule react-hooks/exhaustive-deps --rule no-console |
| 13 | * |
| 14 | # Decrease baselines when improvements occur |
| 15 | * tsx scripts/ratchet-eslint-rules.ts \ |
| 16 | * --rule react-hooks/exhaustive-deps --rule no-console \ |
| 17 | * --decrease-baselines |
| 18 | * |
| 19 | * Flags: |
| 20 | * --metadata <path> Path to baseline file (default .github/eslint-rule-baselines.json) |
| 21 | * --init Write current counts for the provided --rule(s) into metadata and exit 0 |
| 22 | * --eslint "<cmd>" ESLint command to run (default "npx eslint"). Do not pass untrusted input. |
| 23 | * --eslint-args "<...>" Extra args/paths for ESLint (e.g., "."). Do not pass untrusted input. |
| 24 | * --rule <id>[,<id>...] Rule id(s). Repeat flag or comma-separate. REQUIRED. |
| 25 | * --decrease-baselines When improvements occur, lower stored baselines to match the new counts. |
| 26 | * |
| 27 | * Notes: |
| 28 | * - Counts occurrences regardless of severity (warn/error). |
| 29 | * - Fails if any selected rule has currentCount > baselineCount. |
| 30 | */ |
| 31 | |
| 32 | import { spawnSync } from 'node:child_process' |
| 33 | import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' |
| 34 | import path from 'node:path' |
| 35 | import { pathToFileURL } from 'node:url' |
| 36 | |
| 37 | interface Args { |
| 38 | metadata: string |
| 39 | init: boolean |
| 40 | eslint: string |
| 41 | eslintArgs: string |
| 42 | decreaseBaselines: boolean |
| 43 | rules: string[] |
| 44 | } |
| 45 | |
| 46 | interface ESLintMessage { |
| 47 | ruleId?: string | null |
| 48 | } |
| 49 | |
| 50 | interface ESLintResult { |
| 51 | filePath?: string |
| 52 | messages?: ESLintMessage[] |
| 53 | } |
| 54 | |
| 55 | interface ESLintExecutionResult { |
| 56 | results: ESLintResult[] |
| 57 | stderr: string |
| 58 | } |
| 59 | |
| 60 | interface BaselineData { |
| 61 | rules: Record<string, number> |
| 62 | ruleFiles?: Record<string, Record<string, number>> |
| 63 | } |
| 64 | |
| 65 | interface RuleSnapshot { |
| 66 | total: number |
| 67 | files: Record<string, number> |
| 68 | } |
| 69 | |
| 70 | function parseArgs(argv: string[]): Args { |
| 71 | const args: Args = { |
| 72 | metadata: '.github/eslint-rule-baselines.json', |
| 73 | init: false, |
| 74 | eslint: 'npx eslint', |
| 75 | eslintArgs: '', |
| 76 | decreaseBaselines: false, |
| 77 | rules: [], |
| 78 | } |
| 79 | |
| 80 | for (let i = 2; i < argv.length; i += 1) { |
| 81 | const a = argv[i] |
| 82 | if (a === '--init') { |
| 83 | args.init = true |
| 84 | } else if (a === '--metadata') { |
| 85 | args.metadata = argv[++i] |
| 86 | } else if (a === '--eslint') { |
| 87 | args.eslint = argv[++i] |
| 88 | } else if (a === '--eslint-args') { |
| 89 | args.eslintArgs = argv[++i] |
| 90 | } else if (a === '--rule') { |
| 91 | const val = (argv[++i] ?? '').trim() |
| 92 | if (val) { |
| 93 | args.rules.push( |
| 94 | ...val |
| 95 | .split(',') |
| 96 | .map((s) => s.trim()) |
| 97 | .filter(Boolean) |
| 98 | ) |
| 99 | } |
| 100 | } else if (a === '--decrease-baselines') { |
| 101 | args.decreaseBaselines = true |
| 102 | } else { |
| 103 | console.warn(`Unknown argument: ${a}`) |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | if (args.rules.length === 0) { |
| 108 | console.error('Error: You must provide at least one --rule <rule-id>.') |
| 109 | console.error('Example: --rule exhaustive-deps --rule no-console') |
| 110 | process.exit(2) |
| 111 | } |
| 112 | |
| 113 | const dedupedRules = new Set(args.rules) |
| 114 | args.rules = Array.from(dedupedRules) |
| 115 | |
| 116 | return args |
| 117 | } |
| 118 | |
| 119 | /** |
| 120 | * SECURITY: |
| 121 | * Directly spawns a command from its arguments. Should not be called with |
| 122 | * untrusted input. |
| 123 | */ |
| 124 | function dangerouslyRunEsLint(eslintCmd: string, eslintArgs: string): ESLintExecutionResult { |
| 125 | const fullCmd = `${eslintCmd} ${eslintArgs || ''} --format json`.trim() |
| 126 | const proc = spawnSync(fullCmd, { |
| 127 | shell: true, |
| 128 | encoding: 'utf8', |
| 129 | stdio: ['ignore', 'pipe', 'pipe'], |
| 130 | env: process.env, |
| 131 | maxBuffer: 32 * 1024 * 1024, // allow large ESLint JSON payloads |
| 132 | }) |
| 133 | |
| 134 | const stdout = typeof proc.stdout === 'string' ? proc.stdout : '' |
| 135 | const stderr = typeof proc.stderr === 'string' ? proc.stderr : '' |
| 136 | |
| 137 | if (!stdout.trim()) { |
| 138 | console.error('ESLint did not produce JSON output. stderr:\n', stderr) |
| 139 | process.exit(2) |
| 140 | } |
| 141 | |
| 142 | let results: ESLintResult[] |
| 143 | try { |
| 144 | results = JSON.parse(stdout) as ESLintResult[] |
| 145 | } catch (e) { |
| 146 | console.error('Failed to parse ESLint JSON output:', e) |
| 147 | console.error('Raw output (truncated to 4k):\n', stdout.slice(0, 4096)) |
| 148 | process.exit(2) |
| 149 | } |
| 150 | |
| 151 | return { results, stderr } |
| 152 | } |
| 153 | |
| 154 | function normalizeFilePath(filePath?: string | null): string | null { |
| 155 | if (!filePath) return null |
| 156 | const rel = path.relative(process.cwd(), filePath) |
| 157 | const normalized = rel || path.basename(filePath) |
| 158 | return normalized.split(path.sep).join('/') |
| 159 | } |
| 160 | |
| 161 | function collectRuleSnapshots( |
| 162 | results: ESLintResult[], |
| 163 | ruleIds: string[] |
| 164 | ): Record<string, RuleSnapshot> { |
| 165 | const checkedIds = new Set(ruleIds) |
| 166 | const snapshots: Record<string, RuleSnapshot> = {} |
| 167 | |
| 168 | for (const id of ruleIds) { |
| 169 | snapshots[id] = { total: 0, files: {} } |
| 170 | } |
| 171 | |
| 172 | for (const file of results) { |
| 173 | if (!file || !Array.isArray(file.messages)) continue |
| 174 | const normalizedPath = normalizeFilePath(file.filePath) |
| 175 | for (const msg of file.messages) { |
| 176 | const id = msg?.ruleId ?? '' |
| 177 | if (id && checkedIds.has(id)) { |
| 178 | const snapshot = snapshots[id] ?? { total: 0, files: {} } |
| 179 | snapshot.total += 1 |
| 180 | if (normalizedPath) { |
| 181 | snapshot.files[normalizedPath] = (snapshot.files[normalizedPath] ?? 0) + 1 |
| 182 | } |
| 183 | snapshots[id] = snapshot |
| 184 | } |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | return snapshots |
| 189 | } |
| 190 | |
| 191 | function readBaselines(fp: string): BaselineData { |
| 192 | if (!existsSync(fp)) return { rules: {}, ruleFiles: {} } |
| 193 | try { |
| 194 | const data = JSON.parse(readFileSync(fp, 'utf8')) as Partial<BaselineData> |
| 195 | if (data && typeof data === 'object' && data.rules && typeof data.rules === 'object') { |
| 196 | return { rules: data.rules, ruleFiles: data.ruleFiles ?? {} } |
| 197 | } |
| 198 | } catch { |
| 199 | // ignore invalid metadata files and fall back to blank baselines |
| 200 | } |
| 201 | return { rules: {}, ruleFiles: {} } |
| 202 | } |
| 203 | |
| 204 | function writeBaselines(fp: string, updates: Record<string, RuleSnapshot>, merge = true): void { |
| 205 | const dir = path.dirname(fp) |
| 206 | mkdirSync(dir, { recursive: true }) |
| 207 | |
| 208 | let current: BaselineData = { rules: {}, ruleFiles: {} } |
| 209 | if (merge && existsSync(fp)) { |
| 210 | current = readBaselines(fp) |
| 211 | } |
| 212 | |
| 213 | const nextRules = merge ? { ...current.rules } : {} |
| 214 | const nextRuleFiles = merge ? { ...(current.ruleFiles ?? {}) } : {} |
| 215 | |
| 216 | for (const [rule, snapshot] of Object.entries(updates)) { |
| 217 | nextRules[rule] = snapshot.total |
| 218 | nextRuleFiles[rule] = snapshot.files |
| 219 | } |
| 220 | |
| 221 | const next: BaselineData = { rules: nextRules, ruleFiles: nextRuleFiles } |
| 222 | writeFileSync(fp, `${JSON.stringify(next, null, 2)}\n`, 'utf8') |
| 223 | } |
| 224 | |
| 225 | function writeSummary(markdown: string): void { |
| 226 | const summaryFile = process.env.GITHUB_STEP_SUMMARY |
| 227 | if (summaryFile) { |
| 228 | try { |
| 229 | appendFileSync(summaryFile, `${markdown}\n`, 'utf8') |
| 230 | } catch { |
| 231 | // ignore summary write errors because they shouldn't block the script |
| 232 | } |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | export function runRatchet(argv: string[], runEslint = dangerouslyRunEsLint): number { |
| 237 | const args = parseArgs(argv) |
| 238 | |
| 239 | // SECURITY: |
| 240 | // Offloaded to user. Must document that they should not pass untrusted input |
| 241 | // via --eslint or --eslint-args. |
| 242 | const { results, stderr } = runEslint(args.eslint, args.eslintArgs) |
| 243 | |
| 244 | // Filter out test files. |
| 245 | const filteredResults = results.filter((result) => !result.filePath?.includes('.test.')) |
| 246 | |
| 247 | const currentSnapshots = collectRuleSnapshots(filteredResults, args.rules) |
| 248 | const currentCounts: Record<string, number> = {} |
| 249 | for (const rule of args.rules) { |
| 250 | currentCounts[rule] = currentSnapshots[rule]?.total ?? 0 |
| 251 | } |
| 252 | |
| 253 | if (args.init) { |
| 254 | writeBaselines(args.metadata, currentSnapshots, true) |
| 255 | |
| 256 | const rows = Object.entries(currentCounts) |
| 257 | .map(([rule, count]) => `| \`${rule}\` | **${count}** |`) |
| 258 | .join('\n') |
| 259 | |
| 260 | writeSummary( |
| 261 | [ |
| 262 | `### ESLint rule baselines initialized`, |
| 263 | `Metadata: \`${args.metadata}\``, |
| 264 | ``, |
| 265 | `| Rule | Baseline |`, |
| 266 | `| --- | ---: |`, |
| 267 | rows, |
| 268 | ``, |
| 269 | ].join('\n') |
| 270 | ) |
| 271 | |
| 272 | console.log( |
| 273 | `Initialized/updated baselines for: ${args.rules.join(', ')} (saved to ${args.metadata}).` |
| 274 | ) |
| 275 | return 0 |
| 276 | } |
| 277 | |
| 278 | const baselineData = readBaselines(args.metadata) |
| 279 | const baselineRules = baselineData.rules || {} |
| 280 | const baselineRuleFiles = baselineData.ruleFiles || {} |
| 281 | |
| 282 | const missing = args.rules.filter((r) => typeof baselineRules[r] !== 'number') |
| 283 | if (missing.length) { |
| 284 | const msg = `Missing baselines for: ${missing.join(', ')} in ${args.metadata}. Run with --init to set them.` |
| 285 | console.error(msg) |
| 286 | writeSummary(`### ESLint rule ratchet\n${msg}`) |
| 287 | console.log(`::error title=Missing baselines::${msg}`) |
| 288 | return 2 |
| 289 | } |
| 290 | |
| 291 | let failed = false |
| 292 | const tableRows: string[] = [] |
| 293 | const improvedRules: string[] = [] |
| 294 | const decreasedBaselines: Record<string, { from: number; to: number; snapshot: RuleSnapshot }> = |
| 295 | {} |
| 296 | for (const rule of args.rules) { |
| 297 | const baseline = baselineRules[rule] ?? 0 |
| 298 | const current = currentCounts[rule] ?? 0 |
| 299 | const delta = current - baseline |
| 300 | const currentSnapshot = currentSnapshots[rule] ?? { total: 0, files: {} } |
| 301 | const baselineFiles = baselineRuleFiles[rule] ?? {} |
| 302 | |
| 303 | tableRows.push( |
| 304 | `| \`${rule}\` | **${baseline}** | **${current}** | ${delta >= 0 ? '+' : '-'}${delta} |` |
| 305 | ) |
| 306 | |
| 307 | if (current > baseline) { |
| 308 | failed = true |
| 309 | const delta = current - baseline |
| 310 | const baselineHasFiles = Object.hasOwn(baselineRuleFiles, rule) |
| 311 | const fileSummary = describeFileRegression( |
| 312 | baselineFiles, |
| 313 | currentSnapshot.files, |
| 314 | baselineHasFiles |
| 315 | ) |
| 316 | const msgParts = [ |
| 317 | `You added ${delta === 1 ? 'a new violation' : `${delta} new violations`} of ${rule}. Please fix it: baseline=${baseline}, current=${current}`, |
| 318 | ] |
| 319 | if (fileSummary) { |
| 320 | msgParts.push( |
| 321 | `Affected files: ${fileSummary}${baselineHasFiles ? '' : ' (baseline missing file breakdown; rerun with --init to capture it)'}` |
| 322 | ) |
| 323 | } |
| 324 | const msg = msgParts.join(' ') |
| 325 | console.error(msg) |
| 326 | console.log(`::error title=New violations::${msg}`) |
| 327 | } else if (current < baseline) { |
| 328 | improvedRules.push(rule) |
| 329 | if (args.decreaseBaselines) { |
| 330 | decreasedBaselines[rule] = { from: baseline, to: current, snapshot: currentSnapshot } |
| 331 | } |
| 332 | } |
| 333 | } |
| 334 | |
| 335 | const summaryLines = [ |
| 336 | `### ESLint rule ratchet`, |
| 337 | `Metadata: \`${args.metadata}\``, |
| 338 | ``, |
| 339 | `| Rule | Baseline | Current | Δ |`, |
| 340 | `| --- | ---: | ---: | ---: |`, |
| 341 | ...tableRows, |
| 342 | ``, |
| 343 | ] |
| 344 | |
| 345 | if (args.decreaseBaselines && Object.keys(decreasedBaselines).length > 0) { |
| 346 | const updates: Record<string, RuleSnapshot> = {} |
| 347 | const details: string[] = [] |
| 348 | const logParts: string[] = [] |
| 349 | for (const [rule, { from, to, snapshot }] of Object.entries(decreasedBaselines)) { |
| 350 | updates[rule] = snapshot |
| 351 | details.push(`- \`${rule}\`: ${from} -> ${to}`) |
| 352 | logParts.push(`${rule}: ${from} -> ${to}`) |
| 353 | } |
| 354 | writeBaselines(args.metadata, updates, true) |
| 355 | summaryLines.push('', 'Baselines decreased for improved rules:', ...details, '') |
| 356 | console.log(`Baselines decreased for improved rules: ${logParts.join(', ')}`) |
| 357 | } |
| 358 | |
| 359 | writeSummary(summaryLines.join('\n')) |
| 360 | |
| 361 | if (failed) { |
| 362 | if (stderr && stderr.trim()) console.error('\nESLint stderr:\n', stderr) |
| 363 | return 1 |
| 364 | } else { |
| 365 | console.log( |
| 366 | improvedRules.length > 0 |
| 367 | ? 'Nice! Some rules improved.' |
| 368 | : 'Stable: No regressions for selected rules.' |
| 369 | ) |
| 370 | return 0 |
| 371 | } |
| 372 | } |
| 373 | |
| 374 | function main(): void { |
| 375 | const exitCode = runRatchet(process.argv, dangerouslyRunEsLint) |
| 376 | process.exit(exitCode) |
| 377 | } |
| 378 | |
| 379 | if (process.argv[1]) { |
| 380 | const invokedPath = pathToFileURL(path.resolve(process.argv[1])).href |
| 381 | if (import.meta.url === invokedPath) { |
| 382 | main() |
| 383 | } |
| 384 | } |
| 385 | |
| 386 | function describeFileRegression( |
| 387 | baselineFiles: Record<string, number>, |
| 388 | currentFiles: Record<string, number>, |
| 389 | baselineHasFiles: boolean |
| 390 | ): string { |
| 391 | const MAX_FILES = 5 |
| 392 | if (baselineHasFiles) { |
| 393 | const entries = Object.entries(currentFiles) |
| 394 | .map(([file, count]) => ({ |
| 395 | file, |
| 396 | delta: count - (baselineFiles[file] ?? 0), |
| 397 | })) |
| 398 | .filter(({ delta }) => delta > 0) |
| 399 | .sort((a, b) => b.delta - a.delta || a.file.localeCompare(b.file)) |
| 400 | |
| 401 | if (!entries.length) return '' |
| 402 | |
| 403 | return formatFileList( |
| 404 | entries.map(({ file, delta }) => `${file} (+${delta})`), |
| 405 | MAX_FILES |
| 406 | ) |
| 407 | } |
| 408 | |
| 409 | const currentEntries = Object.entries(currentFiles) |
| 410 | .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) |
| 411 | .map(([file, count]) => `${file} (${count} current)`) |
| 412 | |
| 413 | if (!currentEntries.length) return '' |
| 414 | |
| 415 | return formatFileList(currentEntries, MAX_FILES) |
| 416 | } |
| 417 | |
| 418 | function formatFileList(entries: string[], maxFiles: number): string { |
| 419 | if (entries.length <= maxFiles) { |
| 420 | return entries.join(', ') |
| 421 | } |
| 422 | const remainder = entries.length - maxFiles |
| 423 | const plural = remainder === 1 ? 'file' : 'files' |
| 424 | return `${entries.slice(0, maxFiles).join(', ')}, +${remainder} more ${plural}` |
| 425 | } |