mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
feat(ui): track frontend lint counts in a committed snapshot (#31157)
* 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:
parent
8f4389246d
commit
f2f6cacb19
7 changed files with 140 additions and 13 deletions
2
.github/workflows/test-litellm-ui-build.yml
vendored
2
.github/workflows/test-litellm-ui-build.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
5
ui/litellm-dashboard/eslint-metrics.json
Normal file
5
ui/litellm-dashboard/eslint-metrics.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"@typescript-eslint/no-explicit-any": 2027,
|
||||
"complexity": 128,
|
||||
"max-depth": 61
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
22
ui/litellm-dashboard/scripts/lint-budget-lib.mjs
Normal file
22
ui/litellm-dashboard/scripts/lint-budget-lib.mjs
Normal 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 }));
|
||||
}
|
||||
25
ui/litellm-dashboard/scripts/update-lint-metrics.mjs
Normal file
25
ui/litellm-dashboard/scripts/update-lint-metrics.mjs
Normal 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);
|
||||
55
ui/litellm-dashboard/tests/lint-budget-lib.test.ts
Normal file
55
ui/litellm-dashboard/tests/lint-budget-lib.test.ts
Normal 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 }]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue