feat(quality): generic ratchet comparator (multi-metric, regression-only)

This commit is contained in:
diegosouzapw
2026-06-09 00:45:45 -03:00
parent 500197846d
commit 3bc66f5403
2 changed files with 148 additions and 0 deletions

View File

@@ -0,0 +1,89 @@
#!/usr/bin/env node
// scripts/quality/check-quality-ratchet.mjs
// Catraca genérica multi-métrica. Clona o espírito de check-t11-any-budget.mjs:
// um baseline congelado por métrica; falha em qualquer regressão; só anda num sentido.
import fs from "node:fs";
import path from "node:path";
const cwd = process.cwd();
function getArg(name, fallback) {
const i = process.argv.indexOf(name);
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
}
const BASELINE = path.resolve(getArg("--baseline", path.join(cwd, "quality-baseline.json")));
const METRICS = path.resolve(getArg("--metrics", path.join(cwd, "quality-metrics.json")));
const SUMMARY = getArg("--summary", null);
const UPDATE = process.argv.includes("--update");
const EPS = 0.01;
function load(p) {
if (!fs.existsSync(p)) {
console.error(`[quality-ratchet] arquivo ausente: ${p}`);
process.exit(2);
}
return JSON.parse(fs.readFileSync(p, "utf8"));
}
const baseline = load(BASELINE);
const metrics = load(METRICS);
const failures = [];
const improvements = [];
const rows = [];
for (const [key, spec] of Object.entries(baseline.metrics)) {
const current = metrics[key];
const base = spec.value;
const dir = spec.direction; // "down" = menor-é-melhor | "up" = maior-é-melhor
if (current === undefined) {
failures.push(`métrica "${key}" ausente em ${path.basename(METRICS)}`);
rows.push([key, base, "—", "MISSING"]);
continue;
}
let status = "ok";
if (dir === "down") {
if (current > base + EPS) {
failures.push(`${key}: ${current} > baseline ${base} (não pode aumentar)`);
status = "REGRESSÃO";
} else if (current < base - EPS) {
improvements.push([key, current]);
status = "↑ melhorou";
}
} else {
if (current < base - EPS) {
failures.push(`${key}: ${current} < baseline ${base} (não pode cair)`);
status = "REGRESSÃO";
} else if (current > base + EPS) {
improvements.push([key, current]);
status = "↑ melhorou";
}
}
rows.push([key, base, current, status]);
}
if (SUMMARY) {
const md = [
"# Quality Ratchet",
"",
"| Métrica | Baseline | Atual | Status |",
"|---|---|---|---|",
...rows.map(([k, b, c, s]) => `| ${k} | ${b} | ${c} | ${s} |`),
"",
failures.length
? `**${failures.length} regressão(ões) — gate BLOQUEADO.**`
: "**Sem regressões — gate OK.**",
].join("\n");
fs.mkdirSync(path.dirname(SUMMARY), { recursive: true });
fs.writeFileSync(SUMMARY, md + "\n");
}
if (UPDATE && failures.length === 0 && improvements.length) {
for (const [key, val] of improvements) baseline.metrics[key].value = val;
fs.writeFileSync(BASELINE, JSON.stringify(baseline, null, 2) + "\n");
console.log(`[quality-ratchet] baseline ratcheado: ${improvements.length} métrica(s) melhoraram`);
}
if (failures.length) {
console.error("[quality-ratchet] FALHOU:\n" + failures.map((f) => " ✗ " + f).join("\n"));
process.exit(1);
}
console.log(`[quality-ratchet] OK (${rows.length} métricas, ${improvements.length} melhoraram)`);

View File

@@ -0,0 +1,59 @@
import { test } from "node:test";
import assert from "node:assert";
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const SCRIPT = path.resolve("scripts/quality/check-quality-ratchet.mjs");
function run(baseline: unknown, metrics: unknown, extraArgs: string[] = []) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ratchet-"));
const bPath = path.join(dir, "baseline.json");
const mPath = path.join(dir, "metrics.json");
fs.writeFileSync(bPath, JSON.stringify(baseline));
fs.writeFileSync(mPath, JSON.stringify(metrics));
try {
const out = execFileSync("node", [SCRIPT, "--baseline", bPath, "--metrics", mPath, ...extraArgs], {
encoding: "utf8",
});
return { code: 0, out, dir, bPath };
} catch (e: any) {
return { code: e.status as number, out: (e.stdout || "") + (e.stderr || ""), dir, bPath };
}
}
test("passes when metrics equal baseline", () => {
const b = {
metrics: {
eslintWarnings: { value: 100, direction: "down" },
"coverage.lines": { value: 80, direction: "up" },
},
};
assert.equal(run(b, { eslintWarnings: 100, "coverage.lines": 80 }).code, 0);
});
test("fails when a 'down' metric regresses (more warnings)", () => {
const b = { metrics: { eslintWarnings: { value: 100, direction: "down" } } };
const r = run(b, { eslintWarnings: 101 });
assert.equal(r.code, 1);
assert.match(r.out, /eslintWarnings/);
});
test("fails when an 'up' metric regresses (coverage drops)", () => {
const b = { metrics: { "coverage.lines": { value: 80, direction: "up" } } };
assert.equal(run(b, { "coverage.lines": 79 }).code, 1);
});
test("passes on improvement; --update ratchets the baseline", () => {
const b = { metrics: { eslintWarnings: { value: 100, direction: "down" } } };
const r = run(b, { eslintWarnings: 90 }, ["--update"]);
assert.equal(r.code, 0);
const updated = JSON.parse(fs.readFileSync(r.bPath, "utf8"));
assert.equal(updated.metrics.eslintWarnings.value, 90);
});
test("fails when a baseline metric is missing from collected metrics", () => {
const b = { metrics: { eslintWarnings: { value: 100, direction: "down" } } };
assert.equal(run(b, {}).code, 1);
});