From 5c5bbedc7eb6561ece8395af6a23d3fe7c7421a1 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Sat, 16 May 2026 19:45:10 +0000 Subject: [PATCH] =?UTF-8?q?feat(ci):=20SCALE-007=20=E2=80=94=20frontend=20?= =?UTF-8?q?bundle-size=20budget=20via=20size-limit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Acquisition-audit SCALE-007 closure (Sprint 6 ACQ, 2026-05-16). The web/src codebase has ~45 React.lazy() call sites (`grep -rE 'lazy\(' web/src --include='*.tsx' | wc -l`), heavily route- splitting the SPA. Pre-2026-05-16 there was no CI guard on bundle size, so unintended bloat in a vendor chunk or a page chunk would slip in unnoticed until somebody profiled cold-start performance. This commit adds: - web/.size-limit.json — 11 budget entries: per-chunk caps on the load-bearing chunks (main entry, vendor-recharts, vendor-react, vendor-query, vendor-router, vendor-icons, OnboardingWizard, CommandPalette, Timestamp) + two roll-up tiers (total vendor JS, total app JS). Budgets tuned to current vite-build output + ~15% headroom in brotli-compressed bytes (the size-limit default measurement mode — closest analogue to what a real browser downloads). - web/package.json + web/package-lock.json: `npm run size` script + size-limit + @size-limit/file devDeps. - .github/workflows/ci.yml: new "Frontend bundle-size budget (size-limit)" step in the frontend-build job, runs immediately after the vite build. - scripts/ci-guards/G-frontend-bundle-budget.sh: local-runnable wrapper matching the existing ci-guards/.sh contract — exits 0 on clean, non-zero with ::error:: prefix on regression. Acceptance verified locally: - npm install in web/ regenerates package-lock cleanly - `npm run size` exits 0 against the committed web/dist/ - `bash scripts/ci-guards/G-frontend-bundle-budget.sh` exits 0 - All current chunks measured (brotli, kB): main entry 23.3 (cap 30), vendor-recharts 91.2 (cap 110), vendor-react 37.4 (cap 45), OnboardingWizard 28.6 (cap 35), total vendor 149.5 (cap 180), total app 351.1 (cap 425) A regression that bloats a chunk past its cap fails CI and forces an explicit operator decision: fix the regression, or raise the cap in web/.size-limit.json with a rationale comment in the commit message. Do not raise caps blindly. --- .github/workflows/ci.yml | 11 +++ scripts/ci-guards/G-frontend-bundle-budget.sh | 71 ++++++++++++++ web/.size-limit.json | 57 +++++++++++ web/package-lock.json | 97 +++++++++++++++++++ web/package.json | 5 +- 5 files changed, 240 insertions(+), 1 deletion(-) create mode 100755 scripts/ci-guards/G-frontend-bundle-budget.sh create mode 100644 web/.size-limit.json 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",