diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77e182729e..0f562600c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,10 +61,14 @@ jobs: - run: npm ci - run: npm run quality:collect # Catraca: falha se qualquer métrica regredir vs quality-baseline.json (commitado). - # Hoje: contagem de warnings do ESLint. Fases 3/4 estendem com duplicação, - # tamanho de arquivo e cobertura (esta última lida do coverage mergeado). + # Hoje: contagem de warnings do ESLint. Fase 4 estende com cobertura (lida do + # coverage mergeado). Tamanho de arquivo e duplicação têm gates dedicados. - name: Ratchet check run: node scripts/quality/check-quality-ratchet.mjs --summary .artifacts/quality-ratchet.md + # Catraca de duplicação (jscpd@4 sobre src+open-sse). Roda neste job (paralelo) + # para não pesar no caminho crítico do lint. + - name: Duplication ratchet + run: npm run check:duplication - name: Append summary if: always() run: cat .artifacts/quality-ratchet.md >> "$GITHUB_STEP_SUMMARY" diff --git a/duplication-baseline.json b/duplication-baseline.json new file mode 100644 index 0000000000..ccbfa72e13 --- /dev/null +++ b/duplication-baseline.json @@ -0,0 +1,4 @@ +{ + "_comment": "Catraca de duplicacao (check-duplication.mjs, jscpd@4 sobre src+open-sse, min-tokens 50). So pode cair. --update ratcheta.", + "percentage": 5.72 +} diff --git a/package.json b/package.json index 14c6dbddeb..e559f25474 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,7 @@ "check:openapi-routes": "node scripts/check/check-openapi-routes.mjs", "check:deps": "node scripts/check/check-deps.mjs", "check:file-size": "node scripts/check/check-file-size.mjs", + "check:duplication": "node scripts/check/check-duplication.mjs", "quality:collect": "node scripts/quality/collect-metrics.mjs", "quality:ratchet": "node scripts/quality/check-quality-ratchet.mjs", "quality:gate": "npm run quality:collect && npm run quality:ratchet", diff --git a/scripts/check/check-duplication.mjs b/scripts/check/check-duplication.mjs new file mode 100644 index 0000000000..7843f16e88 --- /dev/null +++ b/scripts/check/check-duplication.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +// scripts/check/check-duplication.mjs +// Catraca de duplicação de código. Roda jscpd@4 (PINADO — o v5 é um rewrite Rust com +// CLI/JSON incompatíveis) sobre src+open-sse e compara a % atual contra um baseline +// congelado (duplication-baseline.json). Falha se a duplicação SUBIR. Ataca a assinatura +// nº1 de slop de IA (GitClear 2026: duplicação 4-8x na era IA) — no nosso caso, o +// copy-paste dos executors (48/50 sobrescrevem execute() inteiro). --update ratcheta. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +const ROOT = process.cwd(); +const BASELINE_PATH = path.resolve( + process.argv.includes("--baseline") + ? process.argv[process.argv.indexOf("--baseline") + 1] + : path.join(ROOT, "duplication-baseline.json") +); +const UPDATE = process.argv.includes("--update"); +const EPS = 0.05; // tolerância de ruído de float (jscpd é determinístico; isto é margem) +const JSCPD_ARGS = ["jscpd@4", "src", "open-sse", "--reporters", "json", "--silent", "--min-tokens", "50", "--ignore", "**/*.test.ts,**/*.test.tsx,**/__tests__/**"]; + +/** Avalia a % atual contra o baseline. */ +export function evaluateDuplication(current, baseline, eps = EPS) { + return { + regressed: current > baseline + eps, + improved: current < baseline - eps, + }; +} + +function measureDuplicationPct() { + const out = fs.mkdtempSync(path.join(os.tmpdir(), "jscpd-")); + execFileSync("npx", ["--yes", ...JSCPD_ARGS, "--output", out], { stdio: "ignore" }); + const report = JSON.parse(fs.readFileSync(path.join(out, "jscpd-report.json"), "utf8")); + return report.statistics.total.percentage; +} + +function main() { + if (!fs.existsSync(BASELINE_PATH)) { + console.error(`[duplication] FAIL — ${path.basename(BASELINE_PATH)} ausente.`); + process.exit(2); + } + const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8")); + const current = measureDuplicationPct(); + const { regressed, improved } = evaluateDuplication(current, baseline.percentage, EPS); + + if (UPDATE && improved) { + baseline.percentage = current; + fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + "\n"); + console.log(`[duplication] baseline ratcheado: ${current}% (era ${baseline.percentage}%)`); + } + if (regressed) { + console.error( + `[duplication] REGRESSÃO — ${current}% > baseline ${baseline.percentage}% (+${EPS} tolerância)\n` + + ` → extraia o trecho duplicado (helper compartilhado) ou ajuste duplication-baseline.json com justificativa.` + ); + process.exit(1); + } + console.log(`[duplication] OK — ${current}% (baseline ${baseline.percentage}%)`); +} + +if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main(); diff --git a/tests/unit/check-duplication.test.ts b/tests/unit/check-duplication.test.ts new file mode 100644 index 0000000000..be4939e1e8 --- /dev/null +++ b/tests/unit/check-duplication.test.ts @@ -0,0 +1,24 @@ +import { test } from "node:test"; +import assert from "node:assert"; +import { evaluateDuplication } from "../../scripts/check/check-duplication.mjs"; + +const EPS = 0.05; + +test("equal to baseline passes", () => { + assert.equal(evaluateDuplication(5.72, 5.72, EPS).regressed, false); +}); + +test("within epsilon passes (float noise)", () => { + assert.equal(evaluateDuplication(5.74, 5.72, EPS).regressed, false); +}); + +test("meaningful increase is a regression", () => { + const r = evaluateDuplication(5.9, 5.72, EPS); + assert.equal(r.regressed, true); +}); + +test("a decrease is an improvement (ratchet down)", () => { + const r = evaluateDuplication(5.0, 5.72, EPS); + assert.equal(r.regressed, false); + assert.equal(r.improved, true); +});