From 3bc66f5403dd0c3fb4adceda0993f07816577335 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 9 Jun 2026 00:45:45 -0300 Subject: [PATCH] feat(quality): generic ratchet comparator (multi-metric, regression-only) --- scripts/quality/check-quality-ratchet.mjs | 89 +++++++++++++++++++++++ tests/unit/quality-ratchet.test.ts | 59 +++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 scripts/quality/check-quality-ratchet.mjs create mode 100644 tests/unit/quality-ratchet.test.ts diff --git a/scripts/quality/check-quality-ratchet.mjs b/scripts/quality/check-quality-ratchet.mjs new file mode 100644 index 0000000000..09142f9d9c --- /dev/null +++ b/scripts/quality/check-quality-ratchet.mjs @@ -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)`); diff --git a/tests/unit/quality-ratchet.test.ts b/tests/unit/quality-ratchet.test.ts new file mode 100644 index 0000000000..edca607b93 --- /dev/null +++ b/tests/unit/quality-ratchet.test.ts @@ -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); +});