feat(ui): track frontend lint counts in a committed snapshot (#31157)
Some checks are pending
LiteLLM Rust / rustfmt, clippy, test (push) Waiting to run
GitHub Actions Security Analysis / zizmor (push) Waiting to run

* feat(ui): track frontend lint counts in a committed snapshot

Persist the eslint budget-rule counts (no-explicit-any, complexity,
max-depth) to eslint-metrics.json so the trend is queryable straight
from git history and can later feed a dashboard. A CI drift check
regenerated from the same lint report keeps the snapshot honest, so a
PR that shifts a count has to run npm run lint:metrics and commit it

* fix(ui): harden lint-metrics drift check and eslint failure handling

Make the drift comparison symmetric over the union of committed and
actual keys so a phantom rule left in eslint-metrics.json (for example
after a rule is dropped from eslint-budgets.json) is caught instead of
silently passing. Only swallow eslint's lint-errors exit code in the
generator and rethrow anything else, so a fatal eslint failure surfaces
its real output rather than a confusing ENOENT on the missing report
This commit is contained in:
ryan-crabbe-berri 2026-06-24 11:35:32 -07:00 • committed by GitHub
parent 8f4389246d
commit f2f6cacb19
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 140 additions and 13 deletions

View file

@ -111,4 +111,4 @@ jobs:
if: ${{ !cancelled() && steps.changed.outputs.has_files == 'true' }}
run: |
npx eslint . -f json -o "$RUNNER_TEMP/lint-report.json" || true
node scripts/check-lint-budgets.mjs "$RUNNER_TEMP/lint-report.json" eslint-budgets.json
node scripts/check-lint-budgets.mjs "$RUNNER_TEMP/lint-report.json" eslint-budgets.json --check eslint-metrics.json

View file

@ -0,0 +1,5 @@
{
"@typescript-eslint/no-explicit-any": 2027,
"complexity": 128,
"max-depth": 61
}

View file

@ -8,6 +8,7 @@
"build": "next build",
"start": "next start",
"lint": "eslint .",
"lint:metrics": "node scripts/update-lint-metrics.mjs",
"test": "vitest",
"test:dot": "vitest --reporter=dot",
"test:watch": "vitest -w",

View file

@ -1,22 +1,25 @@
import { readFileSync } from "fs";
import { countBudgetViolations, findDrift } from "./lint-budget-lib.mjs";
const [, , reportPath, budgetsPath] = process.argv;
const report = JSON.parse(readFileSync(reportPath, "utf8"));
const budgets = JSON.parse(readFileSync(budgetsPath, "utf8"));
const counts = {};
for (const file of report) {
for (const message of file.messages) {
if (message.ruleId in budgets) {
counts[message.ruleId] = (counts[message.ruleId] || 0) + 1;
}
const argv = process.argv.slice(2);
const positional = [];
const flags = {};
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === "--check") {
flags.check = argv[(i += 1)];
} else {
positional.push(argv[i]);
}
}
const [reportPath, budgetsPath] = positional;
const report = JSON.parse(readFileSync(reportPath, "utf8"));
const budgets = JSON.parse(readFileSync(budgetsPath, "utf8"));
const counts = countBudgetViolations(report, budgets);
let failed = false;
for (const [rule, { max, target }] of Object.entries(budgets)) {
const count = counts[rule] || 0;
const count = counts[rule];
const note = count > max ? "OVER BUDGET" : count <= target ? "at target" : `${max - count} of headroom`;
console.log(`${rule}: ${count} | max: ${max} | target: ${target} | ${note}`);
if (count > max) {
@ -27,4 +30,20 @@ for (const [rule, { max, target }] of Object.entries(budgets)) {
}
}
if (flags.check) {
const committed = JSON.parse(readFileSync(flags.check, "utf8"));
const drift = findDrift(committed, counts);
for (const { rule, committed: was, actual } of drift) {
console.error(
`::error::${flags.check} is stale for ${rule}: committed ${was ?? "missing"}, actual ${actual ?? "not a tracked rule"}.`,
);
}
if (drift.length > 0) {
console.error(`::error::Run \`npm run lint:metrics\` and commit ${flags.check}.`);
failed = true;
} else {
console.log(`${flags.check} is up to date.`);
}
}
process.exit(failed ? 1 : 0);

View file

@ -0,0 +1,22 @@
export function countBudgetViolations(report, budgets) {
const counts = {};
for (const file of report) {
for (const message of file.messages) {
if (message.ruleId in budgets) {
counts[message.ruleId] = (counts[message.ruleId] || 0) + 1;
}
}
}
return Object.fromEntries(
Object.keys(budgets)
.sort()
.map((rule) => [rule, counts[rule] || 0]),
);
}
export function findDrift(committed, actual) {
const rules = [...new Set([...Object.keys(actual), ...Object.keys(committed)])].sort();
return rules
.filter((rule) => committed[rule] !== actual[rule])
.map((rule) => ({ rule, committed: committed[rule] ?? null, actual: actual[rule] ?? null }));
}

View file

@ -0,0 +1,25 @@
import { execSync } from "child_process";
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { countBudgetViolations } from "./lint-budget-lib.mjs";
const ESLINT_EXIT_LINT_ERRORS = 1;
const budgets = JSON.parse(readFileSync("eslint-budgets.json", "utf8"));
const dir = mkdtempSync(join(tmpdir(), "litellm-lint-"));
const reportPath = join(dir, "report.json");
try {
execSync(`npx eslint . -f json -o "${reportPath}"`, { stdio: "inherit" });
} catch (err) {
if (err.status !== ESLINT_EXIT_LINT_ERRORS) throw err;
}
const report = JSON.parse(readFileSync(reportPath, "utf8"));
rmSync(dir, { recursive: true, force: true });
const metrics = countBudgetViolations(report, budgets);
writeFileSync("eslint-metrics.json", JSON.stringify(metrics, null, 2) + "\n");
console.log("Updated eslint-metrics.json");
console.table(metrics);

View file

@ -0,0 +1,55 @@
import { describe, it, expect } from "vitest";
import { countBudgetViolations, findDrift } from "../scripts/lint-budget-lib.mjs";
const budgets = {
"@typescript-eslint/no-explicit-any": { max: 10, target: 5 },
complexity: { max: 10, target: 5 },
};
const file = (...ruleIds: (string | null)[]) => ({ messages: ruleIds.map((ruleId) => ({ ruleId })) });
describe("countBudgetViolations", () => {
it("counts only budgeted rules and sums across files", () => {
const report = [
file("@typescript-eslint/no-explicit-any", "complexity", "max-depth"),
file("@typescript-eslint/no-explicit-any", "no-var", null),
];
expect(countBudgetViolations(report, budgets)).toEqual({
"@typescript-eslint/no-explicit-any": 2,
complexity: 1,
});
});
it("reports 0 for a budgeted rule with no violations", () => {
expect(countBudgetViolations([file("complexity")], budgets)).toEqual({
"@typescript-eslint/no-explicit-any": 0,
complexity: 1,
});
});
it("emits keys in sorted order so the committed snapshot diffs stably", () => {
const unsorted = { complexity: { max: 1, target: 1 }, "@typescript-eslint/no-explicit-any": { max: 1, target: 1 } };
expect(Object.keys(countBudgetViolations([], unsorted))).toEqual([
"@typescript-eslint/no-explicit-any",
"complexity",
]);
});
});
describe("findDrift", () => {
it("reports no drift when the snapshot matches the actual counts", () => {
expect(findDrift({ complexity: 5 }, { complexity: 5 })).toEqual([]);
});
it("detects a changed count", () => {
expect(findDrift({ complexity: 5 }, { complexity: 7 })).toEqual([{ rule: "complexity", committed: 5, actual: 7 }]);
});
it("detects a rule missing from the committed snapshot", () => {
expect(findDrift({}, { complexity: 7 })).toEqual([{ rule: "complexity", committed: null, actual: 7 }]);
});
it("detects a phantom rule the committed snapshot still carries", () => {
expect(findDrift({ "removed-rule": 3 }, {})).toEqual([{ rule: "removed-rule", committed: 3, actual: null }]);
});
});