// @lifecycle canonical - Prevents ESLint debt from increasing (ratchet).
/**
* ESLint Ratchet
*
* Goal: allow incremental cleanup by preventing new lint violations from being introduced
* while the existing backlog is paid down.
*
* The ratchet compares the current lint error/warn counts (by ruleId) against a committed
* baseline and fails if any rule count increases.
*
* Usage:
* - Update baseline (intentional): `npm run lint:ratchet:baseline`
* - Check (default in CI): `npm run lint:ratchet`
*/
import { readFile, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { spawnSync } from 'node:child_process';
const BASELINE_PATH = path.resolve(process.cwd(), '.eslint-ratchet-baseline.json');
function getEslintBinPath() {
const binName = process.platform === 'win32' ? 'eslint.cmd' : 'eslint';
return path.resolve(process.cwd(), 'node_modules', '.bin', binName);
}
function runEslintJsonReport() {
const reportPath = path.join(os.tmpdir(), `eslint-ratchet-${Date.now()}.json`);
const eslintBin = getEslintBinPath();
const result = spawnSync(
eslintBin,
['src', '--format', 'json', '--output-file', reportPath],
{
stdio: 'inherit',
encoding: 'utf8',
}
);
// ESLint exit codes:
// 0 -> no problems
// 1 -> lint problems found
// 2 -> config/runtime error (should fail)
if (result.status !== 0 && result.status !== 1) {
const detail =
typeof result.status === 'number' ? `exit code ${result.status}` : 'unknown failure';
throw new Error(`ESLint failed to run (${detail}).`);
}
return reportPath;
}
function summarizeEslintReport(results) {
const summary = {
totals: { errors: 0, warnings: 0 },
byRule: {},
};
for (const fileResult of results) {
for (const message of fileResult.messages ?? []) {
const ruleId = message.ruleId ?? '__unknown__';
const severity = message.severity ?? 0;
if (!summary.byRule[ruleId]) {
summary.byRule[ruleId] = { errors: 0, warnings: 0 };
}
if (severity === 2) {
summary.totals.errors += 1;
summary.byRule[ruleId].errors += 1;
} else if (severity === 1) {
summary.totals.warnings += 1;
summary.byRule[ruleId].warnings += 1;
}
}
}
return summary;
}
function compareSummaries(baseline, current) {
const regressions = [];
const allRuleIds = new Set([
...Object.keys(baseline.byRule ?? {}),
...Object.keys(current.byRule ?? {}),
]);
for (const ruleId of allRuleIds) {
const baselineCounts = baseline.byRule?.[ruleId] ?? { errors: 0, warnings: 0 };
const currentCounts = current.byRule?.[ruleId] ?? { errors: 0, warnings: 0 };
if (currentCounts.errors > baselineCounts.errors) {
regressions.push({
ruleId,
type: 'errors',
baseline: baselineCounts.errors,
current: currentCounts.errors,
});
}
if (currentCounts.warnings > baselineCounts.warnings) {
regressions.push({
ruleId,
type: 'warnings',
baseline: baselineCounts.warnings,
current: currentCounts.warnings,
});
}
}
return regressions;
}
async function loadJson(filePath) {
const content = await readFile(filePath, 'utf8');
return JSON.parse(content);
}
async function writeBaseline(summary) {
const baseline = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
eslintTarget: 'src',
totals: summary.totals,
byRule: summary.byRule,
};
await writeFile(BASELINE_PATH, `${JSON.stringify(baseline, null, 2)}\n`, 'utf8');
}
async function handleUpdateBaseline() {
const reportPath = runEslintJsonReport();
const results = await loadJson(reportPath);
const summary = summarizeEslintReport(results);
await writeBaseline(summary);
// Provide quick visibility for reviewers.
console.log(
`[eslint-ratchet] Baseline updated: ${summary.totals.errors} errors, ${summary.totals.warnings} warnings`
);
}
async function handleCheck() {
let baseline;
try {
baseline = await loadJson(BASELINE_PATH);
} catch {
throw new Error(
`[eslint-ratchet] Missing baseline at ${path.relative(process.cwd(), BASELINE_PATH)}. Run: npm run lint:ratchet:baseline`
);
}
const reportPath = runEslintJsonReport();
const results = await loadJson(reportPath);
const current = summarizeEslintReport(results);
const regressions = compareSummaries(baseline, current);
if (regressions.length === 0) {
console.log(
`[eslint-ratchet] OK: ${current.totals.errors} errors, ${current.totals.warnings} warnings (no regressions)`
);
return;
}
const lines = [
`[eslint-ratchet] FAIL: ${regressions.length} rule regressions detected.`,
'',
'Rules that increased (fix these or intentionally regenerate the baseline):',
...regressions
.sort((a, b) => a.ruleId.localeCompare(b.ruleId) || a.type.localeCompare(b.type))
.map(
(r) =>
`- ${r.ruleId} (${r.type}): baseline=${r.baseline} current=${r.current} (+${
r.current - r.baseline
})`
),
];
console.error(lines.join('\n'));
process.exitCode = 1;
}
const mode = process.argv[2] ?? 'check';
try {
if (mode === 'update-baseline') {
await handleUpdateBaseline();
} else if (mode === 'check') {
await handleCheck();
} else {
throw new Error(
`[eslint-ratchet] Unknown mode "${mode}". Expected: "check" or "update-baseline".`
);
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}