diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 133661c..5f863af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -466,6 +466,17 @@ jobs: working-directory: web run: npx vite build + - name: Frontend bundle-size budget (size-limit) + # Acquisition-audit SCALE-007 closure (Sprint 6 ACQ, 2026-05-16). + # Per-chunk + per-tier budgets in web/.size-limit.json; brotli- + # compressed sizes match real-world download cost. A regression + # that bloats a chunk past its cap fails this step and forces + # an explicit operator decision (fix vs raise cap with rationale). + # The script wrapper at scripts/ci-guards/G-frontend-bundle-budget.sh + # is the local-runnable counterpart; both invoke `npm run size`. + working-directory: web + run: npm run size + - name: Regression guards (extracted to scripts/ci-guards/) # All named regression guards live at scripts/ci-guards/.sh per # ci-pipeline-cleanup bundle Phase 1. Each guard is callable locally: diff --git a/scripts/ci-guards/G-frontend-bundle-budget.sh b/scripts/ci-guards/G-frontend-bundle-budget.sh new file mode 100755 index 0000000..a410740 --- /dev/null +++ b/scripts/ci-guards/G-frontend-bundle-budget.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Copyright 2026 certctl LLC. All rights reserved. +# SPDX-License-Identifier: BUSL-1.1 +# +# Acquisition-audit SCALE-007 closure (Sprint 6 ACQ, 2026-05-16). +# Per-chunk frontend bundle-size budget guard. +# +# Reads web/.size-limit.json and asserts every chunk-size pattern +# matches its committed cap (brotli-compressed, the default +# size-limit measurement mode — closest analogue to what a real +# browser downloads). A regression that bloats a chunk past its +# cap fails the build and forces an explicit operator decision — +# either fix the regression, or raise the cap in +# web/.size-limit.json with a rationale comment in the commit +# message. +# +# Contract (matches the other scripts/ci-guards/.sh files): +# - exit 0 on clean repo +# - non-zero with `::error::` prefix on regression +# - skips with exit 0 when the prerequisites aren't installed +# (npm missing, web/node_modules missing, web/dist not built) +# — CI's frontend-build job runs `npm ci` + `npx vite build` +# BEFORE invoking this guard, so the skip path only fires in +# local fast-loop runs where the operator hasn't built the +# frontend yet. +# +# Local run: +# cd web && npm ci && npm run build && cd .. +# bash scripts/ci-guards/G-frontend-bundle-budget.sh + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$REPO_ROOT" + +fail() { + echo "::error::G-frontend-bundle-budget: $*" >&2 + exit 1 +} + +if ! command -v npm >/dev/null 2>&1; then + echo "G-frontend-bundle-budget: skipped — npm not on PATH." + echo " Install: https://nodejs.org/ (CI uses Node 22 via actions/setup-node)." + exit 0 +fi + +if [ ! -f "web/.size-limit.json" ]; then + fail "web/.size-limit.json is missing — the size-limit config is the source of truth for per-chunk budgets." +fi + +if [ ! -d "web/node_modules/size-limit" ]; then + echo "G-frontend-bundle-budget: skipped — web/node_modules/size-limit/ missing." + echo " Run \`cd web && npm ci\` first." + exit 0 +fi + +if [ ! -d "web/dist/assets" ]; then + echo "G-frontend-bundle-budget: skipped — web/dist/assets/ not present." + echo " Run \`cd web && npm run build\` first." + exit 0 +fi + +echo "G-frontend-bundle-budget: checking $(ls -1 web/dist/assets/*.js 2>/dev/null | wc -l) JS chunks against web/.size-limit.json..." +cd web +if ! npm run --silent size; then + echo "::error::G-frontend-bundle-budget: at least one chunk exceeded its budget in web/.size-limit.json. See output above for the offender." + echo " To fix: either reduce the chunk size (audit the imports / move code to a route-level lazy boundary / drop an unneeded dependency), or — only if the growth is intentional — raise the cap in web/.size-limit.json with a rationale comment in the commit message." + exit 1 +fi + +echo "G-frontend-bundle-budget: PASS — all chunks within budget." diff --git a/web/.size-limit.json b/web/.size-limit.json new file mode 100644 index 0000000..d1024b6 --- /dev/null +++ b/web/.size-limit.json @@ -0,0 +1,57 @@ +[ + { + "name": "Main entry (index-*.js)", + "path": "dist/assets/index-*.js", + "limit": "30 KB" + }, + { + "name": "vendor-recharts (chart library)", + "path": "dist/assets/vendor-recharts-*.js", + "limit": "110 KB" + }, + { + "name": "vendor-react (react + react-dom)", + "path": "dist/assets/vendor-react-*.js", + "limit": "45 KB" + }, + { + "name": "vendor-query (TanStack Query)", + "path": "dist/assets/vendor-query-*.js", + "limit": "10 KB" + }, + { + "name": "vendor-router (React Router)", + "path": "dist/assets/vendor-router-*.js", + "limit": "10 KB" + }, + { + "name": "vendor-icons (lucide-react subset)", + "path": "dist/assets/vendor-icons-*.js", + "limit": "8 KB" + }, + { + "name": "OnboardingWizard (heaviest page chunk)", + "path": "dist/assets/OnboardingWizard-*.js", + "limit": "35 KB" + }, + { + "name": "CommandPalette", + "path": "dist/assets/CommandPalette-*.js", + "limit": "18 KB" + }, + { + "name": "Timestamp utility", + "path": "dist/assets/Timestamp-*.js", + "limit": "17 KB" + }, + { + "name": "Total vendor JS", + "path": "dist/assets/vendor-*.js", + "limit": "180 KB" + }, + { + "name": "Total app JS (all chunks combined)", + "path": "dist/assets/*.js", + "limit": "425 KB" + } +] diff --git a/web/package-lock.json b/web/package-lock.json index 7a1c37a..7f364ed 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -27,6 +27,7 @@ "devDependencies": { "@axe-core/react": "^4.11.3", "@playwright/test": "^1.49.0", + "@size-limit/file": "^11.2.0", "@storybook/addon-a11y": "^10.4.0", "@storybook/react-vite": "^10.4.0", "@testing-library/jest-dom": "^6.9.1", @@ -40,6 +41,7 @@ "jsdom": "^29.0.0", "orval": "^7.0.0", "postcss": "^8.5.8", + "size-limit": "^11.2.0", "storybook": "^10.4.0", "tailwindcss": "^3.4.19", "typescript": "^5.9.3", @@ -3724,6 +3726,19 @@ "dev": true, "license": "MIT" }, + "node_modules/@size-limit/file": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@size-limit/file/-/file-11.2.0.tgz", + "integrity": "sha512-OZHE3putEkQ/fgzz3Tp/0hSmfVo3wyTpOJSRNm6AmcwX4Nm9YtTfbQQ/hZRwbBFR23S7x2Sd9EbqYzngKwbRoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "size-limit": "11.2.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -5365,6 +5380,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bytes-iec": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bytes-iec/-/bytes-iec-3.1.1.tgz", + "integrity": "sha512-fey6+4jDK7TFtFg/klGSvNKJctyU7n2aQdnM+CO0ruLPbqqMOM8Tio0Pc+deqUeVKX1tL5DQep1zQ7+37aTAsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -8694,6 +8719,16 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanospinner": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/nanospinner/-/nanospinner-1.2.2.tgz", + "integrity": "sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.1.1" + } + }, "node_modules/nimma": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/nimma/-/nimma-0.2.3.tgz", @@ -10563,6 +10598,68 @@ "dev": true, "license": "ISC" }, + "node_modules/size-limit": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/size-limit/-/size-limit-11.2.0.tgz", + "integrity": "sha512-2kpQq2DD/pRpx3Tal/qRW1SYwcIeQ0iq8li5CJHQgOC+FtPn2BVmuDtzUCgNnpCrbgtfEHqh+iWzxK+Tq6C+RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes-iec": "^3.1.1", + "chokidar": "^4.0.3", + "jiti": "^2.4.2", + "lilconfig": "^3.1.3", + "nanospinner": "^1.2.2", + "picocolors": "^1.1.1", + "tinyglobby": "^0.2.11" + }, + "bin": { + "size-limit": "bin.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/size-limit/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/size-limit/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/size-limit/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", diff --git a/web/package.json b/web/package.json index 410acfd..456bbe8 100644 --- a/web/package.json +++ b/web/package.json @@ -13,7 +13,8 @@ "e2e:install": "playwright install --with-deps chromium", "generate": "orval --config ./orval.config.ts", "storybook": "storybook dev -p 6006", - "storybook:build": "storybook build --output-dir=.storybook-static" + "storybook:build": "storybook build --output-dir=.storybook-static", + "size": "size-limit" }, "dependencies": { "@floating-ui/react": "^0.27.19", @@ -48,6 +49,8 @@ "jsdom": "^29.0.0", "orval": "^7.0.0", "postcss": "^8.5.8", + "size-limit": "^11.2.0", + "@size-limit/file": "^11.2.0", "storybook": "^10.4.0", "tailwindcss": "^3.4.19", "typescript": "^5.9.3",