#!/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= — exports/re-exports/tipos não utilizados // DEAD_FILES= — arquivos sem nenhum consumidor // DEAD_TOTAL= — 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 `): 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();