Files
OmniRoute/scripts/check/check-dead-code.mjs
Diego Rodrigues de Sa e Souza 8b7afc0eba feat(quality): new-code mode for the complexity and dead-code ratchets (Clean as You Code) (#12142)
A global ratchet ("total ≤ baseline") reds an innocent PR whenever the base
drifted, and lets a PR that adds 10 violations pass as long as someone else
removed 11 — both happened this week. On pull_request events quality.yml now
passes --base-ref <PR base SHA> to check:complexity-ratchets and check:dead-code
(file-size already had it); in that mode the gate compares HEAD with the
merge-base RESTRICTED to the files the PR touched:

- blocking: violations / dead exports the PR added in files it changed
  (complexityNewCode=, cognitiveComplexityNewCode=, deadExportsNewCode=)
- advisory: the global total vs the frozen baseline (re-frozen at release,
  watched by the nightly headroom job)

scripts/check/newCodeMode.mjs holds the git side (merge-base, changed files,
throwaway `git worktree` of the base with node_modules linked — no stash, no
checkout) and the pure comparison helpers (13 unit tests). ESLint runs only on
the changed files in both trees (~20 s); knip runs twice (~70 s).

Exercised locally against the last 8 merges: complexity flagged
src/lib/credentialHealth/scheduler.ts (2→3, cognitive 1→2) and dead-code flagged
src/lib/resilience/settings.ts:CredentialHealthCheckSettings — findings the
global totals were hiding under the relaxed baselines.

workflow_dispatch, the release-green sweep and the headroom job have no PR base
and keep the absolute comparison. Docs: QUALITY_GATES.md → "New-code mode".
2026-08-30 19:08:32 -03:00

246 lines
8.4 KiB
JavaScript

#!/usr/bin/env node
// scripts/check/check-dead-code.mjs
// Gate de dead-code via knip — unused exports, unused files.
// Fase 7 INT: promovido de ADVISORY para RATCHET bloqueante.
// Lê o baseline de quality-baseline.json (metrics.deadExports), compara e
// falha com exit 1 se a contagem SUBIR. Suporta --update para ratchetar o baseline.
//
// Saída (stdout):
// DEAD_EXPORTS=<n> — exports/re-exports/tipos não utilizados
// DEAD_FILES=<n> — arquivos sem nenhum consumidor
// DEAD_TOTAL=<n> — soma de ambos (métrica primária para o ratchet)
//
// Use --json para imprimir o relatório completo do knip em JSON.
// Use --quiet para suprimir logs de diagnóstico.
// Use --update para ratchetar o baseline quando a contagem cair legitimamente.
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import {
baseRefArg,
listChangedFiles,
newDeadSymbols,
resolveMergeBase,
withBaseWorktree,
} from "./newCodeMode.mjs";
const ROOT = process.cwd();
const KNIP_BIN = path.join(ROOT, "node_modules", ".bin", "knip");
const QUIET = process.argv.includes("--quiet");
const BASE_REF = baseRefArg();
const PRINT_JSON = process.argv.includes("--json");
const UPDATE = process.argv.includes("--update");
const BASELINE_PATH = path.resolve(
process.argv.includes("--baseline")
? process.argv[process.argv.indexOf("--baseline") + 1]
: path.join(ROOT, "config/quality/quality-baseline.json")
);
/**
* Conta dead exports e dead files a partir do output JSON do knip.
*
* O reporter JSON do knip emite:
* { issues: Array<{ file, exports?, files?, types?, nsExports?, nsTypes?, ... }> }
*
* Cada entrada em `exports`, `types`, `nsExports`, `nsTypes` é um símbolo morto naquele
* arquivo. A presença do arquivo em si na lista (campo `files: []` não-vazio ou arquivo
* sem outros campos relevantes com `files: true` no include) indica arquivo morto.
*
* @param {object} knipJson - Objeto JSON parseado do output do knip
* @returns {{ deadExports: number, deadFiles: number, deadTotal: number }}
*/
export function parseKnipMetrics(knipJson) {
if (!knipJson || !Array.isArray(knipJson.issues)) {
return { deadExports: 0, deadFiles: 0, deadTotal: 0 };
}
let deadExports = 0;
let deadFiles = 0;
for (const fileEntry of knipJson.issues) {
// Dead file: o arquivo aparece na lista com campo `files` populado
// (knip emite um entry com files:[] indicando "este arquivo é morto")
if (Array.isArray(fileEntry.files) && fileEntry.files.length > 0) {
deadFiles += fileEntry.files.length;
}
// Alguns reporters indicam arquivo morto sem campo files — o entry existe
// sem exports/types = o arquivo inteiro não tem consumidor
// (conservador: só contar quando files[] está presente e populado)
// Dead exports: somar todos os símbolos mortos por tipo de export
const exportFields = [
"exports",
"types",
"nsExports",
"nsTypes",
"enumMembers",
"namespaceMembers",
"duplicates",
];
for (const field of exportFields) {
if (Array.isArray(fileEntry[field])) {
deadExports += fileEntry[field].length;
}
}
}
return {
deadExports,
deadFiles,
deadTotal: deadExports + deadFiles,
};
}
/**
* Avalia a contagem atual de dead-code total contra o baseline.
* Direction: down (contagem só pode CAIR).
*
* Exported for unit testing.
*
* @param {number} current
* @param {number} baseline
* @returns {{ regressed: boolean, improved: boolean }}
*/
export function evaluateDeadCode(current, baseline) {
return {
regressed: current > baseline,
improved: current < baseline,
};
}
function runKnip(cwd = ROOT) {
const args = [
"--reporter",
"json",
"--no-progress",
"--no-exit-code", // não falha por contagem — só coletamos métricas
];
if (!QUIET) {
process.stderr.write("[dead-code] Rodando knip --reporter json ...\n");
}
let stdout;
try {
stdout = execFileSync(KNIP_BIN, args, {
cwd,
encoding: "utf8",
maxBuffer: 128 * 1024 * 1024,
timeout: 300_000, // 5 min (knip pode ser lento em monorepos grandes)
});
} catch (err) {
// knip sai com código != 0 quando encontra issues; o JSON ainda vai no stdout.
stdout = err.stdout ? String(err.stdout) : "";
if (!stdout.trim()) {
process.stderr.write(`[dead-code] ERRO ao executar knip: ${err.message}\n`);
process.exit(2);
}
}
let knipJson;
try {
knipJson = JSON.parse(stdout);
} catch (parseErr) {
process.stderr.write(`[dead-code] ERRO ao parsear JSON do knip: ${parseErr.message}\n`);
process.stderr.write(`[dead-code] stdout (primeiros 500 chars): ${stdout.slice(0, 500)}\n`);
process.exit(2);
}
return knipJson;
}
/**
* New-code mode (PR events, `--base-ref <sha>`): knip on HEAD and on the merge-base; blocking
* only on dead symbols the PR introduced in files it touched. The global count is printed as
* an advisory (the release reconciliation re-freezes it). See newCodeMode.mjs.
*/
function mainNewCode(baselineValue) {
const mergeBase = resolveMergeBase(BASE_REF);
const changed = listChangedFiles(mergeBase, {
dirs: ["src", "open-sse", "electron", "bin", "scripts"],
exts: [".ts", ".tsx", ".js", ".mjs"],
});
const headKnip = runKnip();
const { deadTotal } = parseKnipMetrics(headKnip);
console.log(`DEAD_TOTAL=${deadTotal}`);
const over = deadTotal > baselineValue ? " — OVER, re-freeze at release" : "";
console.log(
`[dead-code] new-code mode: merge-base ${mergeBase.slice(0, 12)}, ${changed.length} changed file(s); global ${deadTotal} vs baseline ${baselineValue} (advisory${over})`
);
if (changed.length === 0) {
console.log("[dead-code] OK — no source files changed; nothing to compare.");
return;
}
const baseKnip = withBaseWorktree(mergeBase, (dir) => runKnip(dir));
const added = newDeadSymbols(headKnip, baseKnip, changed);
console.log(`deadExportsNewCode=${added.length}`);
if (added.length) {
process.stderr.write(
`[dead-code] REGRESSÃO (código novo) — ${added.length} símbolo(s) morto(s) introduzido(s) nos arquivos tocados:\n` +
added.map((k) => `${k}`).join("\n") +
"\n → remova o export (ou use-o). O total global do repo não conta aqui.\n"
);
process.exit(1);
}
console.log(
`[dead-code] OK (código novo) — nenhum símbolo morto novo nos ${changed.length} arquivo(s) tocado(s)`
);
}
function main() {
if (!fs.existsSync(BASELINE_PATH)) {
process.stderr.write(`[dead-code] FAIL — ${path.basename(BASELINE_PATH)} ausente.\n`);
process.exit(2);
}
const baselineJson = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
const baselineMetric = baselineJson.metrics && baselineJson.metrics.deadExports;
if (!baselineMetric || typeof baselineMetric.value !== "number") {
process.stderr.write(
"[dead-code] FAIL — metrics.deadExports ausente em quality-baseline.json.\n"
);
process.exit(2);
}
const baselineValue = baselineMetric.value;
if (BASE_REF && !PRINT_JSON && !UPDATE) return mainNewCode(baselineValue);
const knipJson = runKnip();
if (PRINT_JSON) {
process.stdout.write(JSON.stringify(knipJson, null, 2) + "\n");
return;
}
const { deadExports, deadFiles, deadTotal } = parseKnipMetrics(knipJson);
// Emitir em formato KEY=VALUE para o coletor de métricas (collect-metrics.mjs)
console.log(`DEAD_EXPORTS=${deadExports}`);
console.log(`DEAD_FILES=${deadFiles}`);
console.log(`DEAD_TOTAL=${deadTotal}`);
const { regressed, improved } = evaluateDeadCode(deadTotal, baselineValue);
if (UPDATE && improved) {
baselineJson.metrics.deadExports.value = deadTotal;
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baselineJson, null, 2) + "\n");
console.log(`[dead-code] baseline ratcheado: ${deadTotal} (era ${baselineValue})`);
}
if (regressed) {
process.stderr.write(
`[dead-code] REGRESSÃO — ${deadTotal} símbolos mortos > baseline ${baselineValue}\n` +
` → Remova exports/arquivos não utilizados ou rode\n` +
` 'node scripts/check/check-dead-code.mjs --update' se a contagem caiu legitimamente.\n`
);
process.exit(1);
}
console.log(`[dead-code] OK — ${deadTotal} símbolos mortos (baseline ${baselineValue})`);
process.exitCode = 0;
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();