Files
OmniRoute/scripts/check/check-file-size.mjs

264 lines
10 KiB
JavaScript

#!/usr/bin/env node
// scripts/check/check-file-size.mjs
// Catraca de tamanho de arquivo (mata o god-component). Modelado no
// check-t11-any-budget.mjs: um baseline congelado por arquivo (file-size-baseline.json).
// - arquivo congelado: só pode ENCOLHER (nunca crescer);
// - arquivo NOVO (fora do baseline): não pode passar do CAP.
// Assim o próximo arquivo de 12.760 linhas é impossível, e os 91 atuais só melhoram.
// --update ratcheta o baseline para baixo: encolhimentos + remove toda entrada que
// já cabe no cap, inclusive quem NÃO encolheu nesta rodada (loc === frozen[file]).
// Antes a remoção só acontecia dentro do ramo de encolhimento, então uma entrada
// igual ao próprio teto ficava presa no baseline para sempre — ver #8584.
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const ROOT = 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 = path.resolve(
getArg("--baseline", path.join(ROOT, "config/quality/file-size-baseline.json"))
);
const UPDATE = process.argv.includes("--update");
const BASE_REF = getArg("--base-ref"); // SHA for PR base-relative mode (#8522)
const SCAN_DIRS = ["src", "open-sse", "electron", "bin"];
// Test files live under tests/ plus co-located *.test.ts(x) inside the source dirs.
const TEST_SCAN_DIRS = ["tests", ...SCAN_DIRS];
// Directories to skip when walking — build artifacts and installed packages.
const SKIP_DIRS = new Set(["node_modules", "dist-electron", ".next", ".build", "dist", "coverage"]);
/**
* Avalia LOC atuais contra o baseline congelado.
*
* `redundant` (#8584): entrada que NAO precisa mais existir — o arquivo esta
* exatamente no valor congelado E ja cabe no cap de arquivo novo. Antes, a
* remocao do baseline so acontecia dentro do ramo de `improvements`
* (loc < frozen), entao uma entrada igual ao proprio teto nunca saia da lista,
* por mais abaixo do cap que estivesse (3 casos reais no v3.8.49).
*
* Quando `baseLocByFile` e fornecido (modo PR), a violacao e computada contra
* o MAIOR entre o valor congelado e o valor na base -- assim um PR inocente
* (head === base no arquivo) nao e penalizado por drift herdado (#8522).
*
* @param {Object} currentLocByFile — LOC atuais (head)
* @param {Object} frozen — baseline congelado
* @param {number} cap — teto para arquivos novos
* @param {Object} [baseLocByFile] — LOC na branch base (opcional, modo PR)
* @returns {{violations: string[], improvements: [string, number][], redundant: string[]}}
*/
export function evaluateFileSizes(currentLocByFile, frozen, cap, baseLocByFile) {
const violations = [];
const improvements = [];
const redundant = [];
for (const [file, loc] of Object.entries(currentLocByFile)) {
if (file in frozen) {
const threshold = baseLocByFile
? Math.max(frozen[file], baseLocByFile[file] ?? frozen[file])
: frozen[file];
if (loc > threshold)
violations.push(`${file}: ${loc} > congelado ${frozen[file]} (não pode crescer)`);
else if (loc < frozen[file]) improvements.push([file, loc]);
else if (loc <= cap) redundant.push(file);
} else if (loc > cap) {
if (!baseLocByFile) {
violations.push(`${file}: ${loc} > cap ${cap} (arquivo novo acima do limite)`);
} else {
// Modo PR: so viola se cresceu alem do que ja estava na base
const baseLoc = baseLocByFile[file] ?? 0;
const prThreshold = Math.max(cap, baseLoc);
if (loc > prThreshold)
violations.push(`${file}: ${loc} > cap ${cap} (arquivo novo acima do limite)`);
}
}
}
return { violations, improvements, redundant };
}
function countLines(file) {
return fs.readFileSync(file, "utf8").split("\n").length;
}
function walk(dir, acc = []) {
if (!fs.existsSync(dir)) return acc;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) {
if (!SKIP_DIRS.has(e.name)) walk(p, acc);
} else if (
/\.(ts|tsx)$/.test(e.name) &&
!/\.test\.tsx?$/.test(e.name) &&
!/\.d\.ts$/.test(e.name)
) {
acc.push(p);
}
}
return acc;
}
function collectLoc() {
const out = {};
for (const d of SCAN_DIRS)
for (const f of walk(path.join(ROOT, d)))
out[path.relative(ROOT, f).replace(/\\/g, "/")] = countLines(f);
return out;
}
// Walk for TEST files: collects *.test.ts / *.test.tsx (the inverse of walk(),
// which deliberately excludes them). Skips .d.ts and the same SKIP_DIRS.
function walkTests(dir, acc = []) {
if (!fs.existsSync(dir)) return acc;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) {
if (!SKIP_DIRS.has(e.name)) walkTests(p, acc);
} else if (/\.test\.tsx?$/.test(e.name) && !/\.d\.ts$/.test(e.name)) {
acc.push(p);
}
}
return acc;
}
function collectTestLoc() {
const out = {};
for (const d of TEST_SCAN_DIRS)
for (const f of walkTests(path.join(ROOT, d)))
out[path.relative(ROOT, f).replace(/\\/g, "/")] = countLines(f);
return out;
}
/**
* Computa LOC por arquivo a partir de um ref git (branch, SHA, tag).
* Usado pelo modo --base-ref para obter a contagem na base do PR (#8522).
* @param {string} ref — git ref (e.g. SHA da branch base)
* @param {string[]} files — lista de paths relativos ao ROOT
* @returns {Object} mapa file → line count
*/
function getBaseLoc(ref, files) {
const out = {};
for (const file of files) {
try {
const buf = execFileSync("git", ["show", `${ref}:${file}`], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 5000,
});
out[file] = buf.split("\n").length;
} catch {
// Arquivo nao existe na base (novo no PR) — tratado como 0
}
}
return out;
}
function main() {
if (!fs.existsSync(BASELINE_PATH)) {
console.error(`[file-size] FAIL — ${path.basename(BASELINE_PATH)} ausente.`);
process.exit(2);
}
const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
const cap = baseline.cap;
const frozen = baseline.frozen || {};
const current = collectLoc();
// Modo PR: computa LOC na branch base para comparacao relativa (#8522)
const baseLoc = BASE_REF ? getBaseLoc(BASE_REF, Object.keys(current)) : undefined;
if (BASE_REF) {
const baseKeys = Object.keys(baseLoc).length;
console.log(
`[file-size] modo PR (--base-ref ${BASE_REF.slice(0, 12)}): ${baseKeys} arquivos da base computados`
);
}
const { violations, improvements, redundant } = evaluateFileSizes(current, frozen, cap, baseLoc);
// Test-file gate (Layer 1 anti-reinflation): same shrink-only + new-≤cap semantics,
// reusing evaluateFileSizes against the testFrozen baseline + testCap.
const testCap = baseline.testCap;
const testFrozen = baseline.testFrozen || {};
const currentTests = collectTestLoc();
const {
violations: testViolations,
improvements: testImprovements,
redundant: testRedundant,
} = typeof testCap === "number"
? evaluateFileSizes(currentTests, testFrozen, testCap, BASE_REF ? baseLoc : undefined)
: { violations: [], improvements: [], redundant: [] };
if (UPDATE) {
let changed = false;
if (violations.length === 0 && (improvements.length || redundant.length)) {
for (const [file, loc] of improvements) {
if (loc <= cap)
delete frozen[file]; // caiu para dentro do cap → sai do baseline
else frozen[file] = loc; // continua grande mas encolheu → trava no novo valor
}
// #8584: entradas que já cabem no cap sem terem encolhido nesta rodada
// (loc === frozen[file]) também saem — antes ficavam presas para sempre.
for (const file of redundant) delete frozen[file];
baseline.frozen = Object.fromEntries(Object.entries(frozen).sort());
changed = true;
console.log(
`[file-size] baseline ratcheado: ${improvements.length} arquivo(s) encolheram` +
(redundant.length ? `, ${redundant.length} entrada(s) redundante(s) removida(s)` : "")
);
}
if (
typeof testCap === "number" &&
testViolations.length === 0 &&
(testImprovements.length || testRedundant.length)
) {
for (const [file, loc] of testImprovements) {
if (loc <= testCap)
delete testFrozen[file]; // caiu para dentro do testCap → sai do baseline
else testFrozen[file] = loc; // continua grande mas encolheu → trava no novo valor
}
for (const file of testRedundant) delete testFrozen[file];
baseline.testFrozen = Object.fromEntries(Object.entries(testFrozen).sort());
changed = true;
console.log(
`[test-file-size] baseline ratcheado: ${testImprovements.length} arquivo(s) de teste encolheram` +
(testRedundant.length
? `, ${testRedundant.length} entrada(s) redundante(s) removida(s)`
: "")
);
}
if (changed) fs.writeFileSync(BASELINE_PATH, JSON.stringify(baseline, null, 2) + "\n");
}
let failed = false;
if (violations.length) {
console.error(
`[file-size] ${violations.length} violação(ões):\n` +
violations.map((v) => " ✗ " + v).join("\n") +
`\n → modularize/extraia (DRY) para encolher, ou (último caso) ajuste file-size-baseline.json com justificativa.`
);
failed = true;
} else {
console.log(
`[file-size] OK — ${Object.keys(frozen).length} arquivos congelados, cap ${cap} para novos (${Object.keys(current).length} arquivos verificados)`
);
}
if (typeof testCap === "number") {
if (testViolations.length) {
console.error(
`[test-file-size] ${testViolations.length} test file violation(s) (testCap ${testCap}):\n` +
testViolations.map((v) => " ✗ " + v).join("\n") +
`\n → split the test file (extract helpers/sub-suites) to shrink it, or (last resort) adjust testFrozen in file-size-baseline.json with justification.`
);
failed = true;
} else {
console.log(
`[test-file-size] OK — ${Object.keys(testFrozen).length} test files congelados, testCap ${testCap} para novos (${Object.keys(currentTests).length} test files verificados)`
);
}
}
if (failed) process.exit(1);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();