#!/usr/bin/env node // scripts/check/check-mutation-ratchet.mjs // Catraca de mutationScore (Quality Gate v2 / Fase 9 T5 — Onda 2, Task 3). // // Mirrors check-bundle-size.mjs: ADVISORY by default (always exit 0), BLOCKING only // with --ratchet — and even then exits 1 SE — E SOMENTE SE — um módulo medido REGREDIU // vs o baseline (direction: UP, o score só pode subir). Skip gracioso (exit 0) quando // não há mutation.json (ex.: o nightly não rodou) ou não há baseline para o módulo — // falta de dados NUNCA bloqueia, só uma regressão medida bloqueia. // // Score por módulo = COVERED score = detected / (detected + survived), onde // detected = Killed + Timeout. NoCoverage é EXCLUÍDO do denominador (é uma lacuna de // cobertura, não um sinal de qualidade-de-teste) — mesmo denominador que a radiografia // (scripts/quality/mutation-radiography.mjs). // // O nightly divide o `mutate` em batches paralelos (um reports/mutation/mutation.json // por job). Este script roda DENTRO de cada job sobre o report daquele batch e compara // só os módulos presentes nele. Aceita vários paths para uso local/agregado. // // Uso: // node scripts/check/check-mutation-ratchet.mjs (advisory; report default) // node scripts/check/check-mutation-ratchet.mjs reports/mutation/mutation.json // node scripts/check/check-mutation-ratchet.mjs ... --ratchet import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const ROOT = process.cwd(); const BASELINE_PATH = path.join(ROOT, "config/quality/quality-baseline.json"); const DEFAULT_REPORT = path.join(ROOT, "reports/mutation/mutation.json"); const BASELINE_PREFIX = "mutationScore."; const RATCHET = process.argv.includes("--ratchet"); // Anti-flake tolerance (percentage points). The tap-runner runs at concurrency 4 and a // mutant whose tests sit near the `timeoutMS` boundary can flip Killed/Timeout/Survived // between runs, jittering a module's covered score by a fraction of a point. Only a drop // LARGER than this counts as a regression — same idea as the coverage ratchet's `eps`. It // does NOT lower the baseline; it absorbs run-to-run noise so the blocking gate stays // deterministic. Real test-quality regressions (the headers.ts/telemetry-scale drops that // motivated the tap.testFiles coverage fix) are far larger than EPS and still fire. export const MUTATION_RATCHET_EPS = 1.0; const DETECTED = new Set(["Killed", "Timeout"]); const SURVIVED = new Set(["Survived"]); /** * Avalia o score MEDIDO de um módulo contra o baseline. Direction: UP (o score só * pode subir — menor = regressão), com tolerância anti-flake `eps`: só conta como * regressão uma queda MAIOR que `eps` pontos. * @param {number} current * @param {number} baseline * @param {number} [eps] tolerância anti-flake em pontos percentuais * @returns {{ regressed: boolean, improved: boolean }} */ export function evaluateMutationRatchet(current, baseline, eps = MUTATION_RATCHET_EPS) { return { regressed: current < baseline - eps, improved: current > baseline, }; } /** * Covered mutation score de um arquivo: detected/(detected+survived)*100. * NoCoverage/Ignored/RuntimeError/CompileError ficam fora do denominador. * @param {{mutants?: Array<{status: string}>}} fileData * @returns {number|null} score em %, ou null se não houver mutantes cobertos */ export function mutationScoreForFile(fileData) { let detected = 0; let survived = 0; for (const m of fileData?.mutants || []) { if (DETECTED.has(m.status)) detected += 1; else if (SURVIVED.has(m.status)) survived += 1; } const denom = detected + survived; return denom === 0 ? null : (detected / denom) * 100; } /** * Score por arquivo a partir de um ou mais reports (batches). Arquivos sem mutante * coberto (score null) são omitidos. * * ⭐ Sibling batches que fatiam o MESMO arquivo (auth.ts split em a1:1-1109 + a2:1110-2218 * por mutation range; accountFallback em b1/b2) carregam fatias DISJUNTAS dos mutantes * daquele arquivo. O score verdadeiro do arquivo precisa de TODAS as fatias juntas, então * unimos `files[].mutants` entre os reports ANTES de pontuar — não sobrescrever * (senão a última fatia venceria e reportaria só metade do arquivo). * @param {object|object[]} reportOrReports parsed mutation.json (ou array) * @returns {Record} */ export function measureMutationScores(reportOrReports) { const reports = Array.isArray(reportOrReports) ? reportOrReports : [reportOrReports]; const mutantsByFile = {}; for (const report of reports) { for (const [file, data] of Object.entries(report?.files || {})) { (mutantsByFile[file] ||= []).push(...(data?.mutants || [])); } } const out = {}; for (const [file, mutants] of Object.entries(mutantsByFile)) { const score = mutationScoreForFile({ mutants }); if (score !== null) out[file] = score; } return out; } /** * Lê metrics["mutationScore."].value do quality-baseline.json. * Retorna {} se o arquivo ou as chaves estiverem ausentes (sem baseline não há * ratchet possível — o caller trata como SKIP gracioso, exit 0). * @param {string} baselinePath * @returns {Record} */ export function readBaselineMutationScores(baselinePath = BASELINE_PATH) { if (!fs.existsSync(baselinePath)) return {}; let baselineJson; try { baselineJson = JSON.parse(fs.readFileSync(baselinePath, "utf8")); } catch { return {}; } const metrics = baselineJson?.metrics || {}; const out = {}; for (const [key, val] of Object.entries(metrics)) { if (key.startsWith(BASELINE_PREFIX) && val && typeof val.value === "number") { out[key.slice(BASELINE_PREFIX.length)] = val.value; } } return out; } function loadReport(p) { return JSON.parse(fs.readFileSync(p, "utf8")); } function main(argv) { const paths = argv.slice(2).filter((a) => !a.startsWith("--")); const reportPaths = paths.length > 0 ? paths : [DEFAULT_REPORT]; const existing = reportPaths.filter((p) => fs.existsSync(p)); if (existing.length === 0) { process.stdout.write("mutationScore=SKIP reason=no-report\n"); process.exit(0); } const measured = measureMutationScores(existing.map(loadReport)); const baseline = readBaselineMutationScores(); const regressions = []; const modules = Object.keys(measured).sort(); for (const mod of modules) { const current = measured[mod]; if (!(mod in baseline)) { process.stdout.write(`mutationScore.${mod}=${current.toFixed(2)} (no baseline — advisory)\n`); continue; } const { regressed } = evaluateMutationRatchet(current, baseline[mod]); const tag = regressed ? "REGRESSED" : "ok"; process.stdout.write( `mutationScore.${mod}=${current.toFixed(2)} baseline=${baseline[mod].toFixed(2)} ${tag}\n` ); if (regressed) regressions.push({ mod, current, baseline: baseline[mod] }); } if (regressions.length > 0 && RATCHET) { process.stderr.write( `\nMutation ratchet FAILED — ${regressions.length} module(s) dropped below baseline:\n` ); for (const r of regressions) { process.stderr.write(` ${r.mod}: ${r.current.toFixed(2)} < ${r.baseline.toFixed(2)}\n`); } process.exit(1); } process.exit(0); } if ( import.meta.url === `file://${process.argv[1]}` || process.argv[1] === fileURLToPath(import.meta.url) ) { main(process.argv); }