Spaces:
Runtime error
Runtime error
| // 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(); | |