#!/usr/bin/env node // Asserts that the React Router web build stays within hard byte budgets so // regressions in initial payload / route splitting are caught in CI. Reads the // generated `apps/web/build/client/assets/` directory, classifies each JS chunk // into root/route/vendor/other/lazy-runtime groups and totals CSS separately, // then compares each group total against a hard-coded byte budget. // // Budgets were seeded from the May 2026 build output plus ~20% headroom and // must be re-evaluated whenever an intentional bundle change lands. The gate // SKIPs gracefully when no build artifacts are present so it can run on a // clean tree without forcing an expensive `pnpm build`. // // Classification rules (kept intentionally narrow so unrelated chunk renames // do not silently shift mass between groups): // root -> entry.client-*, root-*, manifest-* (initial payload) // vendor -> chunk-*, vendor-* (manualChunks output) // route -> React Router v7 route id chunks, including route groups, // dynamic segments, and top-level route ids // other -> every other JS chunk (component-level splits, libs) // lazy_runtime -> self-hosted optional feature runtimes and Web Workers // css -> *.css files // // `initial_payload_js` follows the React Router manifest's entry + root modules // and recursively walks their emitted static imports. Dynamic imports and // route-only modules remain outside the common boot budget. import fs from "node:fs/promises"; import fsSync from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { brotliCompressSync, gzipSync } from "node:zlib"; const FILENAME = fileURLToPath(import.meta.url); const REPO_ROOT = path.resolve(FILENAME, "..", ".."); const DEFAULT_ASSETS_DIR = path.join(REPO_ROOT, "apps", "web", "build", "client", "assets"); const DEFAULT_BUDGETS_CONFIG_PATH = path.join(REPO_ROOT, "scripts", "bundle-budgets.json"); // Hard budgets (bytes). Seeded from current build + tight headroom. // If you intentionally grow the bundle, update these and call it out in the // commit so reviewers can sanity-check the new floor. // // 2026-06-26: absorbs auth WF-P2 + P19 growth. The merge of the new auth // screens and P19 grew the route manifest and aggregate JS/CSS past the // May-2026 floors with no per-route regression (per-route soft gate stays // green). Raised the exceeded groups to current-build + ~10% headroom; vendor // and other still pass and are left untouched: // root 790_964 -> 1_135_000 (build 1_031_227, +30.4% over old) // initial_payload_js now measures the static boot graph rather than filename // groups; raw/gzip/brotli budgets include tight headroom. // manifest_js 699_563 -> 1_040_000 (build 944_428, +35.0% over old) // route 2_650_000 -> 3_175_000 (build 2_883_556, +8.8% over old) // application_js 6_776_505 -> 7_750_000 (build 7_033_988, +3.8% over old) // css 770_317 -> 1_020_000 (build 923_551, +19.9% over old) // // 2026-07-12: Monaco and PDF.js executable code was moved from third-party // CDNs into same-origin, lazy feature chunks. The former application budget is // preserved as `application_js`; `lazy_runtime` separately caps the now-visible // optional editor/PDF/worker payload, and `total_js` caps both together. // 2026-07-11: absorbs the AK-P16d signature interim screens (project signature // panel, wizard dialog, restyled public sign page). The route group grew +0.6% // past the June floor with no per-route regression (per-route soft gate green, // all other groups pass). Raised only the exceeded group to current-build // + ~10% headroom: // route 3_175_000 -> 3_500_000 (build 3_193_560, +9.6% headroom) // // 2026-07-13: absorbs the AK-P21 privacy interim screens (privacy hub, export // jobs, deletion wizard, subprocessors, AVV) landing on top of the AK-P19 // merge. The growth is almost entirely the React Router manifest entries for // the new routes (+30.7 KB manifest, +2 tiny icon chunks) — not new static // imports in the boot graph; the compressed budgets stay untouched and green // (gzip -8.6%, brotli -12.2%). Raised only the exceeded raw group to // current-build + ~10% headroom: // initial_payload_js 1_700_000 -> 1_875_000 (build 1_703_691, +10.1% headroom) // // 2026-07-16: absorbs the reviewed R-LEADS and VA-P26 route surfaces after // integrating both feature branches. Every per-route gzip budget remains green // (594/594), and all other hard groups stay below their existing caps. Raised // only the aggregate route group to the combined build + ~10% headroom: // route 3_500_000 -> 3_880_000 (build 3_526_222, +10.0% headroom) // // 2026-07-20: absorbs the AK-P19 restwork surfaces (tenant creation, tenant // operations panel, support ticket detail, admin interim page headers). The // growth is again the React Router manifest entries for the new admin routes // (+16.5 KB raw, manifest_js +45 bytes over its floor) — the compressed boot // budgets stay green (gzip -4.7%, brotli -9.8%) and every per-route gzip // budget passes (605/605). Raised only the exceeded raw groups to // current-build + ~10% headroom: // initial_payload_js 1_875_000 -> 2_080_000 (build 1_891_470, +10.0% headroom) // manifest_js 1_040_000 -> 1_145_000 (build 1_040_045, +10.1% headroom) // // 2026-07-22: absorbs the AK-SET-01 settings-hub consolidation and its legacy // redirect compatibility routes. The raw root group grew with the React Router // manifest, while the measured static boot graph remains below all raw and // compressed budgets (gzip -5.0%, brotli -10.3%) and all 653 per-route gzip // budgets pass. Raised only the exceeded raw root group to the current build // plus ~10% headroom: // root 1_135_000 -> 1_270_000 (build 1_154_135, +10.0% headroom) // // 2026-08-02: enabling the Gitea bundle gate exposed stale aggregate raw-JS // floors on the current application. This repository cleanup adds no runtime // imports; all 765 per-route budgets and the raw/gzip/brotli boot graph remain // green. Raised only the four exceeded aggregate groups to the measured build // plus ~10% headroom: // root 1_270_000 -> 1_420_000 (build 1_290_398, +10.0% headroom) // route 3_880_000 -> 4_515_000 (build 4_103_703, +10.0% headroom) // manifest_js 1_145_000 -> 1_330_000 (build 1_208_245, +10.1% headroom) // application_js 7_750_000 -> 9_395_000 (build 8_539_172, +10.0% headroom) // // 2026-08-02: absorbs the reviewed global action-feedback and i18n contracts. // The static boot graph grew 0.7% past its previous raw floor, while its gzip // and brotli budgets remain green and all 766 per-route budgets pass. Raised // only the exceeded raw boot-graph budget to the measured build plus ~10% // headroom: // initial_payload_js 2_080_000 -> 2_305_000 (build 2_093_881, +10.1% headroom) export const BUDGETS = Object.freeze({ root: 1_420_000, initial_payload_js: 2_305_000, initial_payload_gzip: 370_000, initial_payload_brotli: 320_000, manifest_js: 1_330_000, vendor: 1_690_674, route: 4_515_000, other: 1_821_650, application_js: 9_395_000, lazy_runtime: 15_750_000, total_js: 22_500_000, css: 1_020_000, }); export const EMPTY_JS_CHUNK_ALLOWLIST = Object.freeze([ "___entity.export", "callback", "dry-run", "healthcheck", "healthz", "logout", "onboarding._id", "postman_collection_._json", "preferences", "readyz", "stream", "submit", "swagger", ]); const ROOT_PATTERNS = [/^entry\.client-/, /^root-/, /^manifest-/]; const VENDOR_PATTERNS = [/^chunk-/, /^vendor-/]; const LAZY_RUNTIME_PATTERNS = [/\.worker(?:\.min)?-/, /^lazy-(?:monaco|pdfjs)-/, /^MonacoEditor(?:\.client)?-/, /^PdfJsLoader\.client-/, /^pdf(?:\.min)?-/]; const SIMPLE_HASH_SEGMENT = /^[A-Za-z0-9_]{6,}$/; const BASE64URL_HASH_SEGMENT = /^[A-Za-z0-9_-]+$/; export function isJavaScriptAsset(name) { return /\.(?:m?js)$/.test(name); } export function isLazyRuntimeChunkName(name) { return isJavaScriptAsset(name) && LAZY_RUNTIME_PATTERNS.some((re) => re.test(name)); } function isLazyRuntimeBaselineKey(key) { return ( /\.worker(?:\.min)?$/.test(key) || /^lazy-(?:monaco|pdfjs)-/.test(key) || /^MonacoEditor(?:\.client)?$/.test(key) || /^PdfJsLoader\.client$/.test(key) || /^pdf(?:\.min)?$/.test(key) ); } export function classifyChunk(name) { if (name.endsWith(".css")) { return "css"; } if (!isJavaScriptAsset(name)) { return null; } if (isLazyRuntimeChunkName(name)) { return "lazy_runtime"; } if (ROOT_PATTERNS.some((re) => re.test(name))) { return "root"; } if (VENDOR_PATTERNS.some((re) => re.test(name))) { return "vendor"; } if (isRouteChunkName(name)) { return "route"; } return "other"; } export function evaluateBudgets(files, budgets = BUDGETS, initialPayload = null) { const groups = { root: 0, vendor: 0, route: 0, other: 0, lazy_runtime: 0, css: 0 }; const counts = { root: 0, vendor: 0, route: 0, other: 0, lazy_runtime: 0, css: 0 }; for (const file of files) { const group = classifyChunk(file.name); if (!group) { continue; } groups[group] += file.size; counts[group] += 1; } const applicationJs = groups.root + groups.vendor + groups.route + groups.other; const totalJs = applicationJs + groups.lazy_runtime; const initialPayloadJs = initialPayload?.rawSize ?? groups.root; const initialPayloadCount = initialPayload?.count ?? counts.root; const manifestJs = files.filter((file) => file.name.startsWith("manifest-") && file.name.endsWith(".js")).reduce((total, file) => total + file.size, 0); const results = []; for (const group of ["root", "vendor", "route", "other", "lazy_runtime"]) { results.push({ group, total: groups[group], count: counts[group], budget: budgets[group], pass: groups[group] <= budgets[group], }); } results.push({ group: "initial_payload_js", total: initialPayloadJs, count: initialPayloadCount, budget: budgets.initial_payload_js, pass: initialPayloadJs <= budgets.initial_payload_js, }); if (initialPayload) { results.push({ group: "initial_payload_gzip", total: initialPayload.gzipSize, count: initialPayload.count, budget: budgets.initial_payload_gzip, pass: initialPayload.gzipSize <= budgets.initial_payload_gzip, }); results.push({ group: "initial_payload_brotli", total: initialPayload.brotliSize, count: initialPayload.count, budget: budgets.initial_payload_brotli, pass: initialPayload.brotliSize <= budgets.initial_payload_brotli, }); } results.push({ group: "manifest_js", total: manifestJs, count: files.filter((file) => file.name.startsWith("manifest-") && file.name.endsWith(".js")).length, budget: budgets.manifest_js, pass: manifestJs <= budgets.manifest_js, }); results.push({ group: "application_js", total: applicationJs, count: counts.root + counts.vendor + counts.route + counts.other, budget: budgets.application_js, pass: applicationJs <= budgets.application_js, }); results.push({ group: "total_js", total: totalJs, count: counts.root + counts.vendor + counts.route + counts.other + counts.lazy_runtime, budget: budgets.total_js, pass: totalJs <= budgets.total_js, }); results.push({ group: "css", total: groups.css, count: counts.css, budget: budgets.css, pass: groups.css <= budgets.css, }); return results; } export function parseReactRouterManifest(source) { const marker = "window.__reactRouterManifest="; const start = source.indexOf(marker); if (start < 0) { throw new Error("React Router manifest assignment not found"); } const jsonStart = source.indexOf("{", start + marker.length); let depth = 0; let inString = false; let escaped = false; for (let index = jsonStart; index < source.length; index += 1) { const character = source[index]; if (inString) { if (escaped) escaped = false; else if (character === "\\") escaped = true; else if (character === '"') inString = false; continue; } if (character === '"') inString = true; else if (character === "{") depth += 1; else if (character === "}") { depth -= 1; if (depth === 0) { return JSON.parse(source.slice(jsonStart, index + 1)); } } } throw new Error("React Router manifest JSON is incomplete"); } export function parseStaticImportSpecifiers(source) { const imports = []; const pattern = /\bimport(?!\s*\()\s*(?:[^;"']*?\bfrom\s*)?["']([^"']+)["']/g; for (const match of source.matchAll(pattern)) { imports.push(match[1]); } return imports; } export async function measureInitialPayload({ assetsDir = DEFAULT_ASSETS_DIR, files: assetFiles = null } = {}) { const files = assetFiles ?? (await readAssetFiles(assetsDir)); const manifestFiles = (files ?? []).filter((file) => /^manifest-.+\.js$/.test(file.name)); if (manifestFiles.length !== 1) { throw new Error(`Expected exactly one React Router manifest asset, found ${manifestFiles.length}`); } const buffers = new Map(); const readAsset = async (name) => { if (!buffers.has(name)) { buffers.set(name, await fs.readFile(path.join(assetsDir, name))); } return buffers.get(name); }; const manifestFile = manifestFiles[0].name; const manifest = parseReactRouterManifest((await readAsset(manifestFile)).toString("utf8")); const root = manifest.routes?.root; if (!manifest.entry?.module || !root?.module) { throw new Error("React Router manifest is missing entry or root module metadata"); } const available = new Set(files.map((file) => file.name)); const pending = [manifestFile, manifest.entry.module, ...(manifest.entry.imports ?? []), root.module, ...(root.imports ?? [])].map((specifier) => normalizeManifestAssetName(specifier) ); const closure = new Set(); while (pending.length > 0) { const name = pending.pop(); if (!name || !isJavaScriptAsset(name) || closure.has(name)) continue; if (!available.has(name)) { throw new Error(`Initial payload references missing asset: ${name}`); } closure.add(name); const source = (await readAsset(name)).toString("utf8"); for (const specifier of parseStaticImportSpecifiers(source)) { const importedName = resolveStaticImportName(name, specifier); if (importedName && !closure.has(importedName)) pending.push(importedName); } } let rawSize = 0; let gzipSize = 0; let brotliSize = 0; for (const name of closure) { const contents = await readAsset(name); rawSize += contents.byteLength; gzipSize += gzipSync(contents).byteLength; brotliSize += brotliCompressSync(contents).byteLength; } return { names: [...closure].sort(), count: closure.size, rawSize, gzipSize, brotliSize }; } function normalizeManifestAssetName(specifier) { return specifier.replace(/^\/assets\//, "").replace(/^\.\//, ""); } function resolveStaticImportName(importerName, specifier) { if (specifier.startsWith("/assets/")) return normalizeManifestAssetName(specifier); if (!specifier.startsWith(".")) return null; return path.posix.normalize(path.posix.join(path.posix.dirname(importerName), specifier)); } export function normalizeAssetBaselineName(name) { const ext = path.extname(name); const withoutExt = ext ? name.slice(0, -ext.length) : name; const parts = withoutExt.split("-"); if (parts.length < 2) { return withoutExt; } const last = parts.at(-1) ?? ""; if (SIMPLE_HASH_SEGMENT.test(last)) { return parts.slice(0, -1).join("-"); } const previous = parts.at(-2) ?? ""; const splitHash = `${previous}-${last}`; if (splitHash.length === 8 && BASE64URL_HASH_SEGMENT.test(splitHash)) { return parts.slice(0, -2).join("-"); } return withoutExt; } export function findUnexpectedEmptyJsChunks(files, allowlist = EMPTY_JS_CHUNK_ALLOWLIST) { const allowed = new Set(allowlist); return files .filter((file) => isJavaScriptAsset(file.name) && file.size === 0) .map((file) => ({ ...file, baselineName: normalizeAssetBaselineName(file.name) })) .filter((file) => !allowed.has(file.baselineName)) .sort((left, right) => left.name.localeCompare(right.name)); } export function getLargestJsChunks(files, limit = 10) { return files .filter((file) => isJavaScriptAsset(file.name) && file.size > 0) .sort((left, right) => { if (right.size !== left.size) { return right.size - left.size; } return left.name.localeCompare(right.name); }) .slice(0, limit); } export function countRouteFiles(routesDir = path.join(REPO_ROOT, "apps", "web", "app", "routes")) { if (!fsSync.existsSync(routesDir)) { return null; } let count = 0; const visit = (dir) => { for (const entry of fsSync.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { visit(full); } else if ( entry.isFile() && /\.(ts|tsx)$/.test(entry.name) && !/\.test\.(ts|tsx)$/.test(entry.name) && !/\.spec\.(ts|tsx)$/.test(entry.name) && !/\.server\.(ts|tsx)$/.test(entry.name) ) { count += 1; } } }; visit(routesDir); return count; } export function formatDriftPercent(total, budget) { if (budget <= 0) { return "n/a"; } const drift = ((total - budget) / budget) * 100; const sign = drift >= 0 ? "+" : ""; return `${sign}${drift.toFixed(1)}%`; } // ----- Per-route bundle-budget enforcement (#71) --------------------------- // Reads scripts/bundle-budgets.json and matches each emitted JS asset to a // route or vendor-chunk budget. Sizes are measured in gzip-bytes so the // numbers reflect what the client actually downloads. // // Status taxonomy: // - "ok" gzip <= warningKb // - "warning" warningKb < gzip <= limitKb // - "exceeds" gzip > limitKb (reported, never fails CI) // "exceeds" is intentionally non-fatal: PR-comment + colored CI output draw // attention without blocking legitimate feature work; reviewers decide. export function loadBudgetsConfig(configPath = DEFAULT_BUDGETS_CONFIG_PATH) { const raw = fsSync.readFileSync(configPath, "utf8"); const parsed = JSON.parse(raw); if (!parsed.default || !parsed.routes || !parsed.vendorChunks || !parsed.lazyRuntimeChunks) { throw new Error(`bundle-budgets.json missing required sections (default/routes/vendorChunks/lazyRuntimeChunks): ${configPath}`); } return parsed; } const VENDOR_CHUNK_BASELINE = /^(vendor|chunk)-/i; // Stable name → budget key. Strips the rollup content-hash suffix and the // file extension so "vendor-react-DXdLCaXs.js" → "vendor-react" and // "auth.magic-link-BjyKP6Px.js" → "auth.magic-link". export function baselineAssetKey(name) { return normalizeAssetBaselineName(name); } // Map a baselineKey back to a route path used in bundle-budgets.json. // React Router v7 emits route ids like "auth.magic-link", // "app.$tenant.dashboard", "kb.$slug", "(_lang)._index", "_index". // We support both dot- and slash-segment lookups so the config stays // readable for humans. export function lookupBudget(baselineKey, config) { if (!baselineKey) return null; if (isLazyRuntimeBaselineKey(baselineKey)) { const canonicalBaselineKey = baselineKey.replace(/^lazy-(?:monaco|pdfjs)-/u, ""); const configuredRuntimeKey = Object.keys(config.lazyRuntimeChunks ?? {}) .filter((key) => canonicalBaselineKey === key || canonicalBaselineKey.startsWith(`${key}-`)) .sort((left, right) => right.length - left.length)[0]; const runtimeKey = configuredRuntimeKey ?? canonicalBaselineKey; return { kind: "lazy-runtime", key: runtimeKey, budget: config.lazyRuntimeChunks?.[runtimeKey] ?? null, }; } if (VENDOR_CHUNK_BASELINE.test(baselineKey)) { const configuredChunkKey = Object.keys(config.vendorChunks) .filter((key) => baselineKey === key || baselineKey.startsWith(`${key}-`)) .sort((left, right) => right.length - left.length)[0]; const chunkKey = configuredChunkKey ?? baselineKey; if (config.vendorChunks[chunkKey]) { return { kind: "vendor", key: chunkKey, budget: config.vendorChunks[chunkKey] }; } return { kind: "vendor", key: chunkKey, budget: null }; } // Route lookup: try dot form first, then slash form derived from it. const dotForm = baselineKey; const slashForm = baselineKey.replaceAll(".", "/"); for (const candidate of [dotForm, slashForm]) { if (config.routes[candidate]) { return { kind: "route", key: candidate, budget: config.routes[candidate] }; } } return { kind: "route", key: baselineKey, budget: null }; } export function classifyBudgetStatus(gzipBytes, budget) { if (!budget) return "unbudgeted"; const limit = budget.limitKb * 1024; const warn = budget.warningKb * 1024; if (gzipBytes > limit) return "exceeds"; if (gzipBytes > warn) return "warning"; return "ok"; } export async function computeGzipSize(filePath) { const buf = await fs.readFile(filePath); return gzipSync(buf).length; } export function isVendorChunkName(name) { return /^(vendor|chunk)-/.test(name); } export function isRouteChunkName(name) { if (!isJavaScriptAsset(name) || isLazyRuntimeChunkName(name)) return false; if (name.startsWith("entry.client") || name.startsWith("root-") || name.startsWith("manifest-")) { return false; } if (isVendorChunkName(name)) return false; // Route chunks: start with "(" (route groups), contain "._" segments // (RR7 dynamic segments), or match top-level route ids known to be routes. return name.startsWith("(") || /\._/.test(name) || /^[a-z][\w.-]*-[A-Za-z0-9_]{6,}\.(?:m?js)$/.test(name); } export async function evaluatePerAssetBudgets({ assetsDir = DEFAULT_ASSETS_DIR, config, files: assetFiles = null }) { const files = assetFiles ?? (await readAssetFiles(assetsDir)); if (!files || files.length === 0) { return { skipped: true, entries: [] }; } const entries = []; for (const file of files) { if (!isJavaScriptAsset(file.name)) continue; if (file.size === 0) continue; const baselineKey = baselineAssetKey(file.name); const lookup = lookupBudget(baselineKey, config); // Only report explicit lazy runtimes, vendor chunks or route-like assets, // to avoid noise from every shared component split. const isLazyRuntime = isLazyRuntimeChunkName(file.name); const isVendor = isVendorChunkName(file.name); const isRoute = isRouteChunkName(file.name); if (!isLazyRuntime && !isVendor && !isRoute) continue; const filePath = path.join(assetsDir, file.name); const gzipSize = await computeGzipSize(filePath); const effectiveBudget = lookup?.budget ?? config.default; const status = classifyBudgetStatus(gzipSize, effectiveBudget); entries.push({ file: file.name, key: lookup?.key ?? baselineKey, kind: lookup?.kind ?? (isLazyRuntime ? "lazy-runtime" : isVendor ? "vendor" : "route"), rawSize: file.size, gzipSize, budget: effectiveBudget, hasExplicitBudget: Boolean(lookup?.budget), status, }); } return { skipped: false, entries }; } export function summarizePerAssetEntries(entries) { const totals = { ok: 0, warning: 0, exceeds: 0, unbudgeted: 0 }; for (const e of entries) totals[e.status] += 1; return totals; } export function formatPerAssetReport(entries, { logger = console } = {}) { if (entries.length === 0) { logger.log("Per-route budget: no eligible assets found"); return; } // Sort by status severity then gzip size desc. const order = { exceeds: 0, warning: 1, unbudgeted: 2, ok: 3 }; const sorted = [...entries].sort((a, b) => { if (order[a.status] !== order[b.status]) return order[a.status] - order[b.status]; return b.gzipSize - a.gzipSize; }); logger.log("Per-route bundle budgets (gzip):"); for (const e of sorted) { const kb = (e.gzipSize / 1024).toFixed(1); const limit = e.budget ? `${e.budget.limitKb}KB` : "—"; const tag = e.hasExplicitBudget ? e.kind : `${e.kind} (default)`; const line = `${e.status.padEnd(10)} ${tag.padEnd(18)} ${e.key.padEnd(40)} ${kb}KB / ${limit} (${e.file})`; if (e.status === "exceeds") logger.error(line); else logger.log(line); } } export async function runPerRouteBudgetReport({ assetsDir = DEFAULT_ASSETS_DIR, configPath = DEFAULT_BUDGETS_CONFIG_PATH, logger = console, files = null, } = {}) { let config; try { config = loadBudgetsConfig(configPath); } catch (error) { logger.error(`Per-route budget: failed to load config (${error.message})`); return { skipped: false, entries: [], totals: { ok: 0, warning: 0, exceeds: 0, unbudgeted: 0 }, error: error.message }; } const result = await evaluatePerAssetBudgets({ assetsDir, config, files }); if (result.skipped) { logger.log("Per-route budget: SKIP (no build artifacts)"); return { skipped: true, entries: [], totals: { ok: 0, warning: 0, exceeds: 0, unbudgeted: 0 } }; } formatPerAssetReport(result.entries, { logger }); const totals = summarizePerAssetEntries(result.entries); logger.log(`Per-route summary: ok=${totals.ok} warning=${totals.warning} exceeds=${totals.exceeds} unbudgeted=${totals.unbudgeted}`); return { skipped: false, entries: result.entries, totals }; } export async function readAssetFiles(dir) { let entries; try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch (error) { if (error && error.code === "ENOENT") { return null; } throw error; } const files = []; for (const entry of entries) { if (!entry.isFile()) { continue; } if (!isJavaScriptAsset(entry.name) && !entry.name.endsWith(".css")) { continue; } const full = path.join(dir, entry.name); const stat = await fs.stat(full); files.push({ name: entry.name, size: stat.size }); } return files; } export async function runBundleSizeGate({ assetsDir = DEFAULT_ASSETS_DIR, budgets = BUDGETS, logger = console } = {}) { const files = await readAssetFiles(assetsDir); if (files === null || files.length === 0) { logger.log('SKIP build artifacts not present; run "pnpm build" first'); return { skipped: true, results: [] }; } let initialPayload; try { initialPayload = await measureInitialPayload({ assetsDir, files }); } catch (error) { logger.error(`FAIL initial_payload_graph ${error.message}`); return { skipped: false, results: [], failures: [{ group: "initial_payload_graph", total: 1, count: 1, budget: 0, pass: false }], unexpectedEmptyJsChunks: [], largestJsChunks: getLargestJsChunks(files), files, }; } const results = evaluateBudgets(files, budgets, initialPayload); const unexpectedEmptyJsChunks = findUnexpectedEmptyJsChunks(files); const largestJsChunks = getLargestJsChunks(files); const failures = []; for (const result of results) { const drift = formatDriftPercent(result.total, result.budget); if (result.pass) { logger.log(`PASS group=${result.group} total=${result.total} budget=${result.budget} files=${result.count} drift=${drift}`); } else { failures.push(result); logger.error(`FAIL group=${result.group} total=${result.total} exceeds budget=${result.budget} files=${result.count} drift=${drift}`); } } if (unexpectedEmptyJsChunks.length > 0) { failures.push({ group: "empty_js_chunks", total: unexpectedEmptyJsChunks.length, count: unexpectedEmptyJsChunks.length, budget: 0, pass: false }); logger.error(`FAIL empty_js_chunks unexpected=${unexpectedEmptyJsChunks.length}`); for (const chunk of unexpectedEmptyJsChunks) { logger.error(`- ${chunk.name} baseline=${chunk.baselineName}`); } } else { logger.log("PASS empty_js_chunks unexpected=0"); } logger.log("Largest JS chunks:"); for (const chunk of largestJsChunks) { logger.log(`- ${chunk.name} size=${chunk.size}`); } const routeFileCount = countRouteFiles(); if (routeFileCount !== null) { logger.log(`Route files: count=${routeFileCount}`); } logger.log( `Initial static import graph: files=${initialPayload.count} raw=${initialPayload.rawSize} gzip=${initialPayload.gzipSize} brotli=${initialPayload.brotliSize}` ); const budgetFailures = results.filter((result) => !result.pass).length; const emptyChunkFailures = unexpectedEmptyJsChunks.length > 0 ? 1 : 0; logger.log(`Summary: ${results.length - budgetFailures + (emptyChunkFailures === 0 ? 1 : 0)} passed, ${failures.length} failed`); return { skipped: false, results, failures, unexpectedEmptyJsChunks, largestJsChunks, files }; } export function toPerRouteJsonReport(report) { return { skipped: report.skipped, totals: report.totals, entries: report.entries.map((e) => ({ file: e.file, key: e.key, kind: e.kind, rawSize: e.rawSize, gzipSize: e.gzipSize, status: e.status, hasExplicitBudget: e.hasExplicitBudget, limitKb: e.budget?.limitKb ?? null, warningKb: e.budget?.warningKb ?? null, })), }; } export async function runCli({ args = process.argv.slice(2), logger = console, stdout = process.stdout } = {}) { const jsonMode = args.includes("--json"); const perRouteOnly = args.includes("--per-route-only"); if (perRouteOnly || jsonMode) { const report = await runPerRouteBudgetReport({ logger: jsonMode ? { log: () => {}, error: () => {} } : logger, }); if (jsonMode) { stdout.write(`${JSON.stringify(toPerRouteJsonReport(report), null, 2)}\n`); return 0; } if (perRouteOnly) return 0; } const outcome = await runBundleSizeGate({ logger }); if (!jsonMode && !perRouteOnly) { await runPerRouteBudgetReport({ logger, files: outcome.skipped ? null : outcome.files }); } if (!outcome.skipped && outcome.failures.length > 0) { return 1; } return 0; } async function main() { const exitCode = await runCli(); if (exitCode !== 0) { process.exit(exitCode); } } const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === FILENAME; if (invokedDirectly) { main().catch((error) => { // eslint-disable-next-line no-console -- CLI gate must surface fatal errors on stderr. console.error(error); process.exit(1); }); }