286 lines
10 KiB
JavaScript
286 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
// scripts/check/check-bundle-size.mjs
|
|
// Catraca de bundle size (Task 12 — Fase 7).
|
|
//
|
|
// MODO PREFERENCIAL — size-limit + @size-limit/file (ou outro plugin):
|
|
// Rodar `size-limit --json` via .size-limit.json; extrair o campo `size` de cada
|
|
// entry e somar. Emite `bundleSize=<bytes>`.
|
|
//
|
|
// MODO FALLBACK — raw fs.statSync() (sem plugins instalados):
|
|
// Quando size-limit retorna "no plugins" (isEmpty — só o core está instalado), o
|
|
// script lê os `path` declarados em .size-limit.json diretamente via fs.statSync()
|
|
// e soma os bytes. Mesmas entradas, mesma métrica. Emite `bundleSize=<bytes>`.
|
|
//
|
|
// MODO SKIP — entradas inexistentes:
|
|
// Se nenhuma das entradas do .size-limit.json existir (ex: build não rodou e os
|
|
// arquivos apontados são artefatos gerados), emite `bundleSize=SKIP reason=no-build`
|
|
// e sai 0.
|
|
//
|
|
// Por default é ADVISORY: sempre sai 0 independente do resultado. Passe --ratchet
|
|
// para tornar BLOQUEANTE: lê metrics.bundleSize.value de
|
|
// config/quality/quality-baseline.json, compara o total MEDIDO e SAI 1 SE — E SOMENTE
|
|
// SE — o medido for MAIOR que o baseline (regressão real, direction:down).
|
|
//
|
|
// IMPORTANTE: o baseline (5601) é o valor GZIP do size-limit + @size-limit/file
|
|
// (instalado por 'npm ci' no CI). O modo FALLBACK-stat lê bytes CRUS (uma métrica
|
|
// DIFERENTE e maior) — comparar fallback-stat contra o baseline gzip seria um falso-
|
|
// positivo. Por isso o --ratchet SÓ bloqueia quando a medição veio do size-limit
|
|
// REAL (plugin presente); o fallback-stat e o no-build são SKIP gracioso (exit 0)
|
|
// mesmo com --ratchet — falta de plugin/build nunca bloqueia, só uma regressão
|
|
// medida na MESMA métrica do baseline bloqueia.
|
|
//
|
|
// Uso:
|
|
// node scripts/check/check-bundle-size.mjs
|
|
// node scripts/check/check-bundle-size.mjs --json (força saída JSON de size-limit se possível)
|
|
// node scripts/check/check-bundle-size.mjs --ratchet (falha exit 1 numa regressão)
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { execFileSync } from "node:child_process";
|
|
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
|
|
const ROOT = process.cwd();
|
|
const SIZE_LIMIT_CONFIG = path.join(ROOT, ".size-limit.json");
|
|
const SIZE_LIMIT_BIN = path.join(ROOT, "node_modules", ".bin", "size-limit");
|
|
const BASELINE_PATH = path.join(ROOT, "config/quality/quality-baseline.json");
|
|
const RATCHET = process.argv.includes("--ratchet");
|
|
|
|
/**
|
|
* Tenta rodar size-limit --json e retorna o array de resultados.
|
|
* Lança se size-limit não tiver plugins instalados (plugins.isEmpty).
|
|
*
|
|
* @returns {Array<{name: string, size: number, sizeLimit?: number, passed?: boolean}>}
|
|
* @throws {SizeLimitNoPluginsError}
|
|
*/
|
|
export function runSizeLimit(cwd = ROOT, binPath = SIZE_LIMIT_BIN) {
|
|
if (!fs.existsSync(binPath)) {
|
|
throw Object.assign(new Error("size-limit binary not found"), { code: "SL_NO_BIN" });
|
|
}
|
|
let stdout;
|
|
try {
|
|
stdout = execFileSync("node", [binPath, "--json"], {
|
|
encoding: "utf8",
|
|
cwd,
|
|
maxBuffer: 8 * 1024 * 1024,
|
|
});
|
|
} catch (err) {
|
|
const combined = (err.stdout || "") + (err.stderr || "");
|
|
if (
|
|
combined.includes("Install Size Limit preset") ||
|
|
combined.includes("plugins.isEmpty") ||
|
|
combined.includes("@size-limit/preset")
|
|
) {
|
|
throw Object.assign(new Error("size-limit: no plugins installed"), {
|
|
code: "SL_NO_PLUGINS",
|
|
});
|
|
}
|
|
throw err;
|
|
}
|
|
return JSON.parse(stdout.trim());
|
|
}
|
|
|
|
/**
|
|
* Parseia o JSON de saída do size-limit e retorna o total em bytes.
|
|
* Lança se o JSON não tiver o campo `size` em pelo menos uma entrada.
|
|
*
|
|
* @param {Array<{name: string, size?: number}>} results
|
|
* @returns {number} total em bytes
|
|
*/
|
|
export function parseSizeLimitResults(results) {
|
|
if (!Array.isArray(results)) {
|
|
throw new TypeError("parseSizeLimitResults: esperado array de resultados");
|
|
}
|
|
let total = 0;
|
|
let hasMeasured = false;
|
|
for (const entry of results) {
|
|
if (typeof entry.size === "number") {
|
|
total += entry.size;
|
|
hasMeasured = true;
|
|
}
|
|
}
|
|
if (!hasMeasured) {
|
|
throw new Error("parseSizeLimitResults: nenhuma entrada com campo `size` numérico");
|
|
}
|
|
return total;
|
|
}
|
|
|
|
/**
|
|
* Fallback: lê os `path` do .size-limit.json via fs.statSync().
|
|
* Retorna {total, entries, allMissing} onde:
|
|
* - total: soma dos bytes dos arquivos encontrados
|
|
* - entries: [{name, path, size}]
|
|
* - allMissing: true se NENHUM arquivo existia (skip)
|
|
*
|
|
* @param {string} configPath
|
|
* @param {string} cwd
|
|
*/
|
|
export function measureViaFileStat(configPath = SIZE_LIMIT_CONFIG, cwd = ROOT) {
|
|
if (!fs.existsSync(configPath)) {
|
|
return { total: 0, entries: [], allMissing: true };
|
|
}
|
|
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
let total = 0;
|
|
let found = 0;
|
|
const entries = [];
|
|
for (const entry of config) {
|
|
const entryPath = path.isAbsolute(entry.path) ? entry.path : path.join(cwd, entry.path);
|
|
if (!fs.existsSync(entryPath)) {
|
|
entries.push({ name: entry.name, path: entry.path, size: null });
|
|
continue;
|
|
}
|
|
const size = fs.statSync(entryPath).size;
|
|
total += size;
|
|
found++;
|
|
entries.push({ name: entry.name, path: entry.path, size });
|
|
}
|
|
return { total, entries, allMissing: found === 0 };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Ratchet (direction:down) — exported for tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Avalia o total MEDIDO de bytes contra o baseline.
|
|
* Direction: down (o tamanho só pode CAIR — maior = regressão).
|
|
*
|
|
* @param {number} current - Total de bytes medido agora (gzip, via size-limit).
|
|
* @param {number} baseline - Total congelado em quality-baseline.json.
|
|
* @returns {{ regressed: boolean, improved: boolean }}
|
|
*/
|
|
export function evaluateBundleSizeRatchet(current, baseline) {
|
|
return {
|
|
regressed: current > baseline,
|
|
improved: current < baseline,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Lê metrics.bundleSize.value do quality-baseline.json.
|
|
* Retorna null se o arquivo ou a métrica estiverem ausentes (sem baseline não há
|
|
* ratchet possível — o caller trata como SKIP gracioso, exit 0).
|
|
*
|
|
* @param {string} baselinePath
|
|
* @returns {number|null}
|
|
*/
|
|
export function readBaselineBundleSizeValue(baselinePath = BASELINE_PATH) {
|
|
if (!fs.existsSync(baselinePath)) return null;
|
|
let baselineJson;
|
|
try {
|
|
baselineJson = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
const metric = baselineJson?.metrics?.bundleSize;
|
|
if (!metric || typeof metric.value !== "number") return null;
|
|
return metric.value;
|
|
}
|
|
|
|
/**
|
|
* Aplica o ratchet (direction:down) sobre o total medido vs o baseline.
|
|
* Sem --ratchet: advisory (exit 0). Com --ratchet + medição comparável (gzip via
|
|
* size-limit): exit 1 numa regressão real (medido > baseline). Baseline ausente →
|
|
* SKIP gracioso (exit 0). Define process.exitCode; não lança.
|
|
*
|
|
* @param {number} totalBytes - Total MEDIDO pelo size-limit (gzip).
|
|
*/
|
|
function applyRatchet(totalBytes) {
|
|
if (!RATCHET) {
|
|
process.exitCode = 0;
|
|
return;
|
|
}
|
|
|
|
const baselineValue = readBaselineBundleSizeValue(BASELINE_PATH);
|
|
if (baselineValue === null) {
|
|
console.log("[bundle-size] --ratchet: baseline ausente (metrics.bundleSize) — SKIP, sai 0.");
|
|
process.exitCode = 0;
|
|
return;
|
|
}
|
|
|
|
const { regressed } = evaluateBundleSizeRatchet(totalBytes, baselineValue);
|
|
if (regressed) {
|
|
console.error(
|
|
`[bundle-size] REGRESSÃO — ${totalBytes} bytes > baseline ${baselineValue}.\n` +
|
|
" → Reduza o tamanho dos entrypoints, ou re-baseline metrics.bundleSize em\n" +
|
|
" config/quality/quality-baseline.json se o crescimento for legítimo e justificado."
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
console.log(
|
|
`[bundle-size] --ratchet OK — ${totalBytes} bytes, baseline ${baselineValue} (sem regressão).`
|
|
);
|
|
process.exitCode = 0;
|
|
}
|
|
|
|
function main() {
|
|
// Step 1: tenta com size-limit + plugin instalado
|
|
let totalBytes = null;
|
|
let mode = "size-limit";
|
|
|
|
try {
|
|
const results = runSizeLimit(ROOT, SIZE_LIMIT_BIN);
|
|
totalBytes = parseSizeLimitResults(results);
|
|
} catch (err) {
|
|
if (err.code === "SL_NO_PLUGINS" || err.code === "SL_NO_BIN") {
|
|
// Step 2: fallback para leitura direta de arquivo
|
|
mode = "fallback-stat";
|
|
const { total, entries, allMissing } = measureViaFileStat(SIZE_LIMIT_CONFIG, ROOT);
|
|
|
|
if (allMissing) {
|
|
// Step 3: skip gracioso — entradas não existem (build necessário).
|
|
// SKIP sai 0 mesmo com --ratchet (build ausente nunca bloqueia).
|
|
console.log("bundleSize=SKIP reason=no-build");
|
|
if (process.env.CI) {
|
|
console.log(
|
|
"::notice::check-bundle-size skipped — entradas do .size-limit.json não encontradas (build necessário)"
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
totalBytes = total;
|
|
for (const e of entries) {
|
|
if (e.size !== null) {
|
|
const kb = (e.size / 1024).toFixed(2);
|
|
console.log(` ${e.name}: ${kb} KB (${e.size} bytes)`);
|
|
} else {
|
|
console.log(` ${e.name}: ausente (não contabilizado)`);
|
|
}
|
|
}
|
|
} else {
|
|
// Erro inesperado — reporta mas não falha (advisory).
|
|
// SKIP sai 0 mesmo com --ratchet (erro de medição nunca bloqueia).
|
|
console.error(`[bundle-size] Aviso: size-limit retornou erro inesperado: ${err.message}`);
|
|
console.log("bundleSize=SKIP reason=size-limit-error");
|
|
return;
|
|
}
|
|
}
|
|
|
|
const kb = (totalBytes / 1024).toFixed(2);
|
|
console.log(`bundleSize=${totalBytes}`);
|
|
|
|
// O ratchet só pode comparar a MESMA métrica que congelou o baseline (gzip via
|
|
// size-limit + @size-limit/file). O fallback-stat lê bytes CRUS — uma métrica
|
|
// diferente e maior — então com --ratchet ele faz SKIP gracioso (exit 0) em vez
|
|
// de um falso-positivo. Sem --ratchet, ambos os modos só reportam (advisory).
|
|
if (mode !== "size-limit") {
|
|
if (RATCHET) {
|
|
console.log(
|
|
`[bundle-size] ${mode}: total ${kb} KB (${totalBytes} bytes) — ` +
|
|
"--ratchet SKIP (medição não-comparável ao baseline gzip; instale @size-limit/file)."
|
|
);
|
|
process.exitCode = 0;
|
|
return;
|
|
}
|
|
console.log(`[bundle-size] ${mode}: total ${kb} KB (${totalBytes} bytes) — advisory, saindo 0`);
|
|
return;
|
|
}
|
|
|
|
if (!RATCHET) {
|
|
console.log(`[bundle-size] ${mode}: total ${kb} KB (${totalBytes} bytes) — advisory, saindo 0`);
|
|
}
|
|
applyRatchet(totalBytes);
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
|