diff --git a/.changeset/eager-closure-budget-5324.md b/.changeset/eager-closure-budget-5324.md new file mode 100644 index 000000000..230d59670 --- /dev/null +++ b/.changeset/eager-closure-budget-5324.md @@ -0,0 +1,45 @@ +--- +--- + +CI and build tooling only — this publishes nothing, declared explicitly with an empty +frontmatter rather than left undeclared. No package `src/` is touched. + +The console "performance budget" now weighs the **eager closure** instead of one chunk. + +`.github/workflows/performance-budget.yml` gzipped `apps/console/dist/assets/index-*.js` +and compared it against a 350 KB line. Measured on `77f846a8b`, that chunk is 25,910 bytes +gzipped, while the closure it statically pulls in — every chunk the browser must fetch and +parse before the app renders — is 3,881,609 bytes across 58 of 507 chunks. The gate passed +on 0.67% of the payload it claimed to govern, and `advancedChunks` routes vendor and +workspace code into named chunks on purpose, so most regressions land outside the entry +chunk. objectui#5266 is the worked example: 89 KiB gzipped added to every page load, landing +in `vendor-objectstack-*.js`, structurally invisible here (objectui#5324). + +`emitEagerClosureReport` in `apps/console/vite.config.ts` walks rolldown's own +`chunk.imports` from the entry chunks — static edges only, because the dynamic edge is the +lazy boundary — gzips the bytes actually written to disk, and writes +`dist/eager-closure.json`. `scripts/check-eager-closure-budget.mjs` applies the ceiling. +The split is deliberate: a size ceiling enforced inside `vite build` would fail every +Vercel preview and every local build, which is how a budget gets switched off rather than +fixed. Exit codes are distinct — `1` over budget (a verdict about the bundle), `2` no +trustworthy measurement (a verdict about the gauge) — so a broken gauge is never reported +as a clean bundle, and vice versa. + +Every check in that path is a counter-probe, because this gate's failure mode is silent: a +walk that finds too little, a stale report, an absent field read as zero all produce a +SMALL number, and a budget reads a small number as good news. So the build refuses to +publish a figure unless `react-dom` is inside the closure and at least one chunk is outside +it, and the checker refuses a report whose totals disagree with its own chunk list, whose +version it does not recognise, or that has collapsed to its entry chunk — that last one +being precisely the gauge this replaces. + +The ceiling is 3,960,000 gzipped bytes: today's measurement plus 78,391 bytes of headroom. +It passes on current `main`, and the headroom is deliberately narrower than the 89 KiB +regression the gate exists to catch, so a repeat of objectui#5266 fails it (verified: the +baseline plus 89 KiB comes out 12.4 KB over). Both constraints are asserted in +`scripts/__tests__/check-eager-closure-budget.test.ts`, not merely argued in a comment. + +This is a truthful current-state ceiling, not a target. 3.79 MB gzipped before first render +is a bad payload and the honest long-term line is far below it; lowering it is a separate +decision with its own work behind it. The entry-chunk budget and its 350 KB line are +unchanged — replacing a blind gauge is not licence to drop the check that was already there. diff --git a/.github/workflows/performance-budget.yml b/.github/workflows/performance-budget.yml index 1a234de09..ee94b4d1d 100644 --- a/.github/workflows/performance-budget.yml +++ b/.github/workflows/performance-budget.yml @@ -65,9 +65,36 @@ jobs: - name: Check console performance budget id: budget run: | - # Performance budget: main entry must be < 350 KB gzip - # This is a realistic threshold for a full-featured enterprise app - # with React, routing, UI components, and core business logic. + # TWO measurements, one verdict. + # + # 1. The `index-*.js` entry chunk against a 350 KB line. This is the + # original budget and it stays: a fat entry chunk is still worth + # its own signal, and replacing a gauge is not licence to weaken + # the one already here. + # 2. The EAGER CLOSURE — every chunk the entry reaches through STATIC + # imports, i.e. everything the browser must fetch and parse before + # the app renders. + # + # Measurement 1 alone was the whole budget until objectui#5324. On + # `77f846a8b` the entry chunk gzips to 25.9 KB against the 350 KB line + # while the closure it pulls in is 3,881,609 bytes across 58 of 507 + # chunks — so the gate passed on 0.67% of the payload it claimed to + # govern, and the 89 KiB regression of objectui#5266 landed in + # `vendor-objectstack-*.js` where nothing here could see it. + # `advancedChunks` routes vendor and workspace code into named chunks + # on purpose, so MOST regressions land outside `index-*.js`. + # + # The closure ceiling is NOT set here. It lives in + # `scripts/check-eager-closure-budget.mjs` next to the reasoning that + # produced it, so a re-baseline is one edit against a documented + # argument rather than a number in YAML: today's measured payload plus + # ~2% of headroom, chosen to pass on today's `main` while staying + # NARROWER than the 89 KiB regression the gate exists to catch. It is a + # truthful current-state ceiling, not a statement that 3.79 MB is fine. + # + # Both measurements run before either may fail the step: a run that + # reports one number and hides the other teaches readers to distrust + # the comment, which is how objectui#3152 nearly took this gate down. MAX_ENTRY_GZIP_KB=350 DIST_DIR="apps/console/dist/assets" @@ -110,19 +137,43 @@ jobs: echo "budget_kb=$MAX_ENTRY_GZIP_KB" >> "$GITHUB_OUTPUT" echo "entry_file=$(basename $ENTRY_FILE)" >> "$GITHUB_OUTPUT" - # Check budget - OVER=$(awk "BEGIN {print ($GZIP_KB > $MAX_ENTRY_GZIP_KB) ? 1 : 0}") - if [ "$OVER" -eq 1 ]; then + ENTRY_OVER=$(awk "BEGIN {print ($GZIP_KB > $MAX_ENTRY_GZIP_KB) ? 1 : 0}") + if [ "$ENTRY_OVER" -eq 1 ]; then + echo "❌ ENTRY BUDGET EXCEEDED: Main entry is ${GZIP_KB} KB gzip (limit: ${MAX_ENTRY_GZIP_KB} KB)" + else + echo "✅ Entry budget OK: Main entry is ${GZIP_KB} KB gzip (limit: ${MAX_ENTRY_GZIP_KB} KB)" + fi + + echo "" + echo "📦 Eager closure (what a page load actually pays for):" + # Writes closure_status / closure_gzip_kb / closure_budget_kb / + # closure_chunks to $GITHUB_OUTPUT itself. Exit codes are distinct on + # purpose: 1 = over budget (a verdict about the bundle), 2 = no + # trustworthy measurement (a verdict about the gauge). Reporting one as + # the other is how a broken gauge gets read as a size regression, and a + # size regression as a broken gauge. + set +e + node scripts/check-eager-closure-budget.mjs + CLOSURE_CODE=$? + set -e + + if [ "$CLOSURE_CODE" -eq 2 ]; then + echo "budget_status=error" >> "$GITHUB_OUTPUT" + echo "budget_message=The entry chunk measured ${GZIP_KB} KB, but the eager-closure gauge produced no trustworthy measurement — see the step log. This is a broken gauge, not a passing budget." >> "$GITHUB_OUTPUT" + exit 1 + fi + + if [ "$ENTRY_OVER" -eq 1 ] || [ "$CLOSURE_CODE" -ne 0 ]; then echo "" - echo "❌ BUDGET EXCEEDED: Main entry is ${GZIP_KB} KB gzip (limit: ${MAX_ENTRY_GZIP_KB} KB)" + echo "❌ BUDGET EXCEEDED" echo "budget_status=fail" >> "$GITHUB_OUTPUT" exit 1 - else - echo "" - echo "✅ Budget OK: Main entry is ${GZIP_KB} KB gzip (limit: ${MAX_ENTRY_GZIP_KB} KB)" - echo "budget_status=pass" >> "$GITHUB_OUTPUT" fi + echo "" + echo "✅ Budget OK: entry chunk and eager closure are both within budget" + echo "budget_status=pass" >> "$GITHUB_OUTPUT" + - name: Generate package size report id: size-report # NOT `always()`. `always()` also fires on a cancelled run, where @@ -183,6 +234,10 @@ jobs: BUDGET_GZIP_KB: ${{ steps.budget.outputs.gzip_kb }} BUDGET_LIMIT_KB: ${{ steps.budget.outputs.budget_kb }} BUDGET_ENTRY_FILE: ${{ steps.budget.outputs.entry_file }} + BUDGET_CLOSURE_STATUS: ${{ steps.budget.outputs.closure_status }} + BUDGET_CLOSURE_GZIP_KB: ${{ steps.budget.outputs.closure_gzip_kb }} + BUDGET_CLOSURE_BUDGET_KB: ${{ steps.budget.outputs.closure_budget_kb }} + BUDGET_CLOSURE_CHUNKS: ${{ steps.budget.outputs.closure_chunks }} BUDGET_STEP_OUTCOME: ${{ steps.budget.outcome }} BUILD_PACKAGES_OUTCOME: ${{ steps.build_packages.outcome }} run: node scripts/render-budget-comment.mjs > budget-comment.md diff --git a/apps/console/vite.config.ts b/apps/console/vite.config.ts index f7cc2d19a..e9a84bdd1 100644 --- a/apps/console/vite.config.ts +++ b/apps/console/vite.config.ts @@ -7,6 +7,7 @@ import type { Plugin, Rollup } from 'vite'; import react from '@vitejs/plugin-react'; import path from 'path'; import fs from 'fs'; +import zlib from 'node:zlib'; // Relative specifiers carry their real file extension, and `__dirname` is // spelled `import.meta.dirname` throughout this file, so the config stays // loadable by Vite's `configLoader: 'native'` — which imports this file with @@ -187,6 +188,138 @@ function assertLazyLinterStaysLazy(specTest: RegExp): Plugin { }; } +/** + * Emits the eager-closure size report the console performance budget weighs. + * + * The budget in `.github/workflows/performance-budget.yml` used to gzip one + * file — the `index-*.js` entry chunk — and call the result "the console + * performance budget". Measured on `77f846a8b` that chunk is 25.3 KB gzipped + * against a 350 KB line, while the closure the browser must fetch and parse + * before the app renders is 3,791 KB across 58 chunks. The gate therefore + * passed on 0.67% of the payload it claimed to govern, and the 89 KiB + * regression of objectui#5266 landed in `vendor-objectstack-*.js` where it was + * structurally invisible (objectui#5324). + * + * What the page pays for is the EAGER CLOSURE: every chunk reachable from an + * entry chunk through STATIC imports only. `dynamicImports` is deliberately not + * followed — that edge is the lazy boundary, and following it would turn this + * number into "the whole bundle", which no page load pays. + * + * This plugin only MEASURES. The ceiling and the verdict live in + * `scripts/check-eager-closure-budget.mjs`, which the workflow runs against the + * report written here. The split is deliberate: a size ceiling that fails + * `vite build` would also fail every Vercel preview deploy and every local + * build, which is how a budget gets switched off. The graph knowledge stays + * here, where rolldown's own `chunk.imports` is authoritative; the policy stays + * in a file with unit tests. + * + * The report is written INTO `dist` on purpose. A report that outlives its + * build is worse than no report — `rm -rf dist` followed by a stale read is a + * verdict about a bundle that no longer exists. Living in the output directory + * makes the report and the bundle the same artifact. It carries only chunk file + * names and byte counts, all of which are already observable in the deployed + * bundle itself. + * + * Both counter-probes below refuse a verdict rather than report a number that + * happens to be small, in the spirit of `assertLazyLinterStaysLazy` above: + * an under-counting walk is the dangerous direction, because the budget's + * failure mode is silent — a closure walk that finds nothing reads exactly like + * a bundle that got smaller. + */ +function emitEagerClosureReport(reportFileName = 'eager-closure.json'): Plugin { + // React is reached synchronously from the entry — nothing in the console + // renders without it — and `advancedChunks` routes it to its own + // `vendor-react` chunk, so it is a KNOWN member of the eager closure that is + // NOT the entry chunk. If the walk cannot see it, the walk is wrong. + const EAGER_COUNTER_PROBE = /[\\/]node_modules[\\/]react-dom[\\/]/; + + return { + name: 'emit-eager-closure-report', + writeBundle(options, bundle) { + const outDir = options.dir ?? path.resolve(import.meta.dirname, 'dist'); + + const chunks = new Map(); + for (const [fileName, output] of Object.entries(bundle)) { + if (output.type === 'chunk') chunks.set(fileName, output); + } + + const entries = [...chunks.values()].filter((c) => c.isEntry).map((c) => c.fileName); + const eager = new Set(); + const queue = [...entries]; + while (queue.length > 0) { + const fileName = queue.pop() as string; + if (eager.has(fileName)) continue; + eager.add(fileName); + for (const imported of chunks.get(fileName)?.imports ?? []) { + if (!eager.has(imported)) queue.push(imported); + } + } + + // Counter-probe 1 — the walk must SEE something known to be eager. + // Asserting only "the total is under the ceiling" would also pass on a + // walk that returned the entry chunk alone, which is the very gauge this + // report replaces. + const probeChunks = [...chunks.values()] + .filter((chunk) => Object.keys(chunk.modules).some((id) => EAGER_COUNTER_PROBE.test(id))) + .map((chunk) => chunk.fileName); + const eagerProbe = probeChunks.filter((fileName) => eager.has(fileName)); + if (eagerProbe.length === 0) { + this.error( + `[emit-eager-closure-report] counter-probe failed: no eagerly loaded chunk contains ` + + `a \`react-dom\` module. React is reached synchronously from the app entry, so it ` + + `must be in the eager closure — its absence means this walk is reading the graph ` + + `wrongly, not that the bundle improved. Fix the walk before trusting the size ` + + `below; a walk that finds too little produces a SMALL number, and a budget check ` + + `reads a small number as good news. ` + + `(probe: ${EAGER_COUNTER_PROBE}; chunks holding it: ${probeChunks.join(', ') || 'NONE'}; ` + + `entry chunks: ${entries.join(', ') || 'NONE'}; eager: ${eager.size}/${chunks.size})`, + ); + } + + // Counter-probe 2 — the other direction. If EVERY chunk is eager then + // either this bundle has no lazy boundary at all or the walk is following + // dynamic edges; in both cases the number is not "what a page load costs" + // and must not be published under that name. + if (eager.size === chunks.size) { + this.error( + `[emit-eager-closure-report] counter-probe failed: every one of the ${chunks.size} ` + + `chunks is in the eager closure, so this walk is not distinguishing static from ` + + `dynamic imports. Either \`imports\` has stopped excluding \`dynamicImports\`, or ` + + `the console genuinely has no lazy boundary left — check which before reading the ` + + `size as a page-load cost. (entry chunks: ${entries.join(', ') || 'NONE'})`, + ); + } + + const files = [...eager].sort().map((fileName) => { + const raw = fs.readFileSync(path.join(outDir, fileName)); + // Level 6 — zlib's default, and the level `gzip -c` uses in the + // workflow's entry-chunk check, so the two numbers in one PR comment + // are measured the same way. + return { fileName, bytes: raw.length, gzipBytes: zlib.gzipSync(raw).length }; + }); + + const report = { + // Bumped when the shape below changes; the checker refuses a report it + // does not understand rather than reading absent fields as zero. + reportVersion: 1, + entryChunks: entries.sort(), + eagerChunkCount: files.length, + totalChunkCount: chunks.size, + eagerGzipBytes: files.reduce((n, f) => n + f.gzipBytes, 0), + eagerRawBytes: files.reduce((n, f) => n + f.bytes, 0), + files, + }; + + fs.writeFileSync(path.join(outDir, reportFileName), `${JSON.stringify(report, null, 2)}\n`); + const kb = (report.eagerGzipBytes / 1024).toFixed(1); + this.info( + `eager closure: ${report.eagerChunkCount}/${report.totalChunkCount} chunks, ` + + `${report.eagerGzipBytes} bytes gzipped (${kb} KB) → ${reportFileName}`, + ); + }, + }; +} + /** * Dev-only Vite plugin: serves runtime branding assets at /runtime/assets/*. * @@ -433,6 +566,12 @@ export default defineConfig({ // eagerly-loaded chunk. Runs on CI/Vercel too — it costs microseconds and // the regression it catches is invisible in every other signal. assertLazyLinterStaysLazy(specModuleTest), + // Writes `dist/eager-closure.json` — the size of everything a page load + // pays for before the app renders. `.github/workflows/performance-budget.yml` + // weighs THAT against the budget; weighing the entry chunk alone measured + // 0.67% of it (objectui#5324). Measurement only: the verdict is the + // workflow's, so a size regression never blocks a preview deploy. + emitEagerClosureReport(), // maplibre-gl loads its worker as a sibling of its own chunk URL — an // edge no bundler can see — so the worker (and the shared module it // imports) must be copied into assets/ or every map page 404s diff --git a/package.json b/package.json index 806a835c9..1dd73656d 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "check:skills-paths": "node scripts/check-skills-paths.mjs", "check:doc-types": "node scripts/check-doc-component-types.mjs", "check:doc-snippets": "node scripts/check-doc-snippet-types.mjs", + "check:eager-closure": "node scripts/check-eager-closure-budget.mjs", "cli": "node packages/cli/dist/cli.js", "objectui": "node packages/cli/dist/cli.js", "create-plugin": "node packages/create-plugin/dist/index.js", diff --git a/scripts/__tests__/check-eager-closure-budget.test.ts b/scripts/__tests__/check-eager-closure-budget.test.ts new file mode 100644 index 000000000..e4721ab88 --- /dev/null +++ b/scripts/__tests__/check-eager-closure-budget.test.ts @@ -0,0 +1,308 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Plain-JS CI helper. Its types are INFERRED from the .mjs source by +// `tsconfig.scripts.json` (`allowJs`), so no `@ts-expect-error` here — +// re-adding one is now itself an error (TS2578). See objectui#3494. +import { + BASELINE, + MAX_EAGER_CLOSURE_GZIP_BYTES, + REGRESSION_THIS_GATE_MUST_CATCH_BYTES, + SUPPORTED_REPORT_VERSION, + evaluateClosureBudget, + main, + renderTopChunks, + validateReport, +} from '../check-eager-closure-budget.mjs'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const workflowPath = path.join(repoRoot, '.github/workflows/performance-budget.yml'); +const viteConfigPath = path.join(repoRoot, 'apps/console/vite.config.ts'); + +/** + * A report shaped exactly like `emitEagerClosureReport`'s output, with the + * chunk list summing to the declared total — the checker refuses reports where + * it does not. + */ +function report(overrides: Record = {}) { + const files = [ + { fileName: 'assets/index-A.js', bytes: 90_000, gzipBytes: 25_910 }, + { fileName: 'assets/vendor-objectstack-B.js', bytes: 5_000_000, gzipBytes: 1_529_129 }, + { fileName: 'assets/framework-C.js', bytes: 1_800_000, gzipBytes: 495_690 }, + ]; + return { + reportVersion: SUPPORTED_REPORT_VERSION, + entryChunks: ['assets/index-A.js'], + eagerChunkCount: files.length, + totalChunkCount: 507, + eagerGzipBytes: files.reduce((n, f) => n + f.gzipBytes, 0), + eagerRawBytes: files.reduce((n, f) => n + f.bytes, 0), + files, + ...overrides, + }; +} + +/** + * objectui#5324: the console "performance budget" gzipped one file — the + * `index-*.js` entry chunk — against a 350 KB line. On `77f846a8b` that chunk + * is 25.9 KB while the closure it statically pulls in is 3,881,609 bytes across + * 58 of 507 chunks, so the gate passed on 0.67% of the payload it claimed to + * govern; the 89 KiB regression of objectui#5266 landed in a vendor chunk and + * was structurally invisible to it. + */ +describe('the ceiling itself', () => { + /** + * Both constraints on the chosen number, as assertions rather than prose. + * A ceiling below today's payload lands red and gets disabled; a ceiling more + * than one known regression above it is decorative. + */ + /** A closure report totalling exactly `gzipBytes`, spread over `chunks` files. */ + function closureOf(gzipBytes: number, chunks = BASELINE.chunks) { + const files = Array.from({ length: chunks }, (_, i) => ({ + fileName: `assets/chunk-${i}.js`, + bytes: 0, + gzipBytes: i === 0 ? gzipBytes - (chunks - 1) : 1, + })); + return report({ + files, + eagerChunkCount: chunks, + totalChunkCount: BASELINE.totalChunks, + eagerGzipBytes: gzipBytes, + }); + } + + it('passes on the measured baseline, with headroom', () => { + expect(MAX_EAGER_CLOSURE_GZIP_BYTES).toBeGreaterThan(BASELINE.gzipBytes); + expect(evaluateClosureBudget({ report: closureOf(BASELINE.gzipBytes) }).status).toBe('pass'); + }); + + it('would have failed on the regression it exists to catch', () => { + const headroom = MAX_EAGER_CLOSURE_GZIP_BYTES - BASELINE.gzipBytes; + expect(headroom).toBeLessThan(REGRESSION_THIS_GATE_MUST_CATCH_BYTES); + + const afterRegression = BASELINE.gzipBytes + REGRESSION_THIS_GATE_MUST_CATCH_BYTES; + expect(evaluateClosureBudget({ report: closureOf(afterRegression) }).status).toBe('fail'); + }); +}); + +describe('evaluateClosureBudget', () => { + it('passes a closure inside the budget and names the headroom', () => { + const result = evaluateClosureBudget({ report: report(), budgetBytes: 3_000_000 }); + expect(result.status).toBe('pass'); + expect(result.gzipBytes).toBe(2_050_729); + expect(result.chunkCount).toBe(3); + expect(result.message).toContain('headroom'); + }); + + it('fails a closure over the budget and says how far over', () => { + const result = evaluateClosureBudget({ report: report(), budgetBytes: 2_000_000 }); + expect(result.status).toBe('fail'); + expect(result.message).toContain('over the'); + // A failure must not read as an invitation to widen the number. + expect(result.message).toContain('do not widen it just to get a green check'); + }); + + /** + * The whole family of "the gauge broke" cases, which all share one shape: the + * number comes out SMALL, and a budget check reads small as good news. Every + * one of them must be an error, never a pass. + */ + describe('refuses a verdict rather than reporting a number it cannot trust', () => { + it('when the report is absent', () => { + const result = evaluateClosureBudget({ report: null }); + expect(result.status).toBe('error'); + expect(result.message).toContain('not a passing budget'); + expect(result.gzipBytes).toBeNull(); + }); + + it('when the emitter and the checker have drifted apart', () => { + const result = evaluateClosureBudget({ report: report({ reportVersion: 99 }) }); + expect(result.status).toBe('error'); + expect(result.message).toContain('reportVersion'); + }); + + it('when the closure collapsed to its entry chunk — the gauge this replaces', () => { + const files = [{ fileName: 'assets/index-A.js', bytes: 90_000, gzipBytes: 25_910 }]; + const result = evaluateClosureBudget({ + report: report({ files, eagerChunkCount: 1, eagerGzipBytes: 25_910 }), + }); + expect(result.status).toBe('error'); + expect(result.message).toContain('collapsed to its entry chunk'); + }); + + it('when every chunk is eager, so nothing separates static from dynamic', () => { + const result = evaluateClosureBudget({ report: report({ totalChunkCount: 3 }) }); + expect(result.status).toBe('error'); + expect(result.message).toContain('not separating static from dynamic'); + }); + + it('when the totals disagree with the chunk list', () => { + const result = evaluateClosureBudget({ report: report({ eagerGzipBytes: 1 }) }); + expect(result.status).toBe('error'); + expect(result.message).toContain('internally inconsistent'); + }); + + it('when the walk had no roots', () => { + const result = evaluateClosureBudget({ report: report({ entryChunks: [] }) }); + expect(result.status).toBe('error'); + expect(result.message).toContain('no roots'); + }); + + it.each([['eagerGzipBytes'], ['eagerChunkCount'], ['totalChunkCount']])( + 'when %s is missing (an absent field must never read as zero)', + (key) => { + expect(validateReport(report({ [key]: undefined })).join(' ')).toContain(key); + }, + ); + + it('when the report is not an object at all', () => { + expect(validateReport('3881609')).toEqual(['report is not an object']); + }); + }); +}); + +describe('renderTopChunks', () => { + it('names the biggest eager chunks so a failure has suspects', () => { + const lines = renderTopChunks(report(), 2).split('\n'); + expect(lines).toHaveLength(2); + expect(lines[0]).toContain('vendor-objectstack-B.js'); + expect(lines[1]).toContain('framework-C.js'); + }); + + it('does not throw on a report with no chunk list', () => { + expect(renderTopChunks({})).toBe(''); + }); +}); + +describe('main', () => { + function run(reportBody: unknown) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'closure-budget-')); + const reportPath = path.join(dir, 'eager-closure.json'); + const outputPath = path.join(dir, 'github-output'); + if (reportBody !== undefined) fs.writeFileSync(reportPath, JSON.stringify(reportBody)); + const previous = process.env.GITHUB_OUTPUT; + process.env.GITHUB_OUTPUT = outputPath; + try { + const code = main(['--report', reportPath]); + const outputs = Object.fromEntries( + fs + .readFileSync(outputPath, 'utf8') + .split('\n') + .filter(Boolean) + .map((line) => line.split('=') as [string, string]), + ); + return { code, outputs }; + } finally { + if (previous === undefined) delete process.env.GITHUB_OUTPUT; + else process.env.GITHUB_OUTPUT = previous; + fs.rmSync(dir, { recursive: true, force: true }); + } + } + + it('exits 0 and publishes the measurement when within budget', () => { + const { code, outputs } = run(report()); + expect(code).toBe(0); + expect(outputs.closure_status).toBe('pass'); + expect(outputs.closure_chunks).toBe('3'); + expect(outputs.closure_gzip_kb).toBe('2002.7'); + }); + + it('exits 1 — a verdict about the BUNDLE — when over budget', () => { + const { code, outputs } = run(report({ eagerGzipBytes: 9_000_000, files: [ + { fileName: 'assets/huge.js', bytes: 30_000_000, gzipBytes: 9_000_000 }, + ], eagerChunkCount: 1, totalChunkCount: 507 })); + // eagerChunkCount 1 is itself refused, so this run proves the ORDER: a + // report that cannot be trusted is an error even when it is also over. + expect(code).toBe(2); + expect(outputs.closure_status).toBe('error'); + }); + + it('exits 1 with a real over-budget report', () => { + const huge = MAX_EAGER_CLOSURE_GZIP_BYTES + 1; + const files = [ + { fileName: 'assets/index-A.js', bytes: 90_000, gzipBytes: 1 }, + { fileName: 'assets/huge.js', bytes: 30_000_000, gzipBytes: huge - 1 }, + ]; + const { code, outputs } = run(report({ files, eagerChunkCount: 2, eagerGzipBytes: huge })); + expect(code).toBe(1); + expect(outputs.closure_status).toBe('fail'); + expect(outputs.closure_gzip_kb).not.toBe(''); + }); + + it('exits 2 — a verdict about the GAUGE — when there is no report', () => { + const { code, outputs } = run(undefined); + expect(code).toBe(2); + expect(outputs.closure_status).toBe('error'); + // The keys are published EMPTY, never as a number: the renderer's + // "not measured" branch keys off exactly that emptiness, and a stale + // number here would render as a verdict about a bundle nobody weighed. + expect(outputs.closure_gzip_kb).toBe(''); + expect(outputs.closure_chunks).toBe(''); + }); +}); + +/** + * The checker can only be correct if the workflow keeps feeding it and the + * build keeps emitting the report. Neither half is reachable from a unit test. + */ +describe('performance-budget.yml + vite.config.ts contract', () => { + const workflow = fs.readFileSync(workflowPath, 'utf8'); + const viteConfig = fs.readFileSync(viteConfigPath, 'utf8'); + + it('runs the closure checker in the budget step', () => { + expect(workflow).toContain('node scripts/check-eager-closure-budget.mjs'); + }); + + it('keeps the entry-chunk budget alongside it', () => { + // Replacing a blind gauge is not licence to drop the check already there. + expect(workflow).toContain('MAX_ENTRY_GZIP_KB=350'); + }); + + it('measures the closure even when the entry chunk is over budget', () => { + // The entry check used to `exit 1` on breach. If it still did, a fat entry + // chunk would hide the number that actually governs a page load. + const step = workflow.slice(workflow.indexOf('MAX_ENTRY_GZIP_KB=350')); + const entryVerdict = step.indexOf('ENTRY BUDGET EXCEEDED'); + const closureRun = step.indexOf('node scripts/check-eager-closure-budget.mjs'); + expect(entryVerdict).toBeGreaterThan(-1); + expect(closureRun).toBeGreaterThan(entryVerdict); + }); + + it('maps the gauge-failure exit code to `error`, not to a size verdict', () => { + expect(workflow).toContain('if [ "$CLOSURE_CODE" -eq 2 ]; then'); + const branch = workflow.slice(workflow.indexOf('if [ "$CLOSURE_CODE" -eq 2 ]; then')); + expect(branch.slice(0, 400)).toContain('budget_status=error'); + }); + + it('emits the report from a plugin that is not skipped on CI', () => { + // `compression` and `visualizer` sit behind `...(!isCI ? [` — the budget + // runs ON CI, so the report emitter must not join them there. + expect(viteConfig).toContain('emitEagerClosureReport()'); + const registration = viteConfig.indexOf('emitEagerClosureReport(),'); + const ciOnlyBlock = viteConfig.indexOf('...(!isCI ? ['); + expect(registration).toBeGreaterThan(-1); + expect(ciOnlyBlock).toBeGreaterThan(registration); + }); + + it('writes the report where the checker looks for it', () => { + expect(viteConfig).toContain("reportFileName = 'eager-closure.json'"); + const checker = fs.readFileSync( + path.join(repoRoot, 'scripts/check-eager-closure-budget.mjs'), + 'utf8', + ); + expect(checker).toContain("'apps/console/dist/eager-closure.json'"); + }); + + it('follows static imports only — dynamic edges are the lazy boundary', () => { + const plugin = viteConfig.slice(viteConfig.indexOf('function emitEagerClosureReport')); + const body = plugin.slice(0, plugin.indexOf('\n}\n')); + expect(body).toContain('chunks.get(fileName)?.imports ?? []'); + // The queue may only ever be fed from the STATIC import list. (Plain + // `not.toContain('dynamicImports')` would trip on the counter-probe's own + // message, which names the field it is guarding against.) + expect(body).not.toMatch(/for \(const \w+ of [^)]*dynamicImports/); + }); +}); diff --git a/scripts/__tests__/render-budget-comment.test.ts b/scripts/__tests__/render-budget-comment.test.ts index 25f51c086..1e713c575 100644 --- a/scripts/__tests__/render-budget-comment.test.ts +++ b/scripts/__tests__/render-budget-comment.test.ts @@ -29,6 +29,12 @@ describe('renderBudgetComment', () => { gzipKb: '28.1', budgetKb: '350', entryFile: 'index-BRKCVm_4.js', + // The eager closure — the metric that actually governs a page load + // (objectui#5324). A measured run now always carries both. + closureStatus: 'pass', + closureGzipKb: '3790.6', + closureBudgetKb: '3867.2', + closureChunks: '58', }; it('renders PASS with the measurement when the bundle is within budget', () => { @@ -36,7 +42,8 @@ describe('renderBudgetComment', () => { expect(kind).toBe('pass'); expect(body).toContain('## ✅ Console Performance Budget'); - expect(body).toContain('| Main entry (gzip) | **28.1 KB** | 350 KB |'); + expect(body).toContain('| **Eager closure** (gzip, 58 chunks) | **3790.6 KB** | 3867.2 KB |'); + expect(body).toContain('| Main entry chunk (gzip) | 28.1 KB | 350 KB |'); expect(body).toContain('| Entry file | `index-BRKCVm_4.js` | — |'); expect(body).toContain('| Status | **PASS** | — |'); expect(body).not.toContain('FAIL'); @@ -44,15 +51,14 @@ describe('renderBudgetComment', () => { it('still renders a full FAIL verdict when the bundle IS over budget', () => { const { kind, body } = renderBudgetComment({ + ...measured, status: 'fail', gzipKb: '412.7', - budgetKb: '350', - entryFile: 'index-BRKCVm_4.js', }); expect(kind).toBe('fail'); expect(body).toContain('## ❌ Console Performance Budget'); - expect(body).toContain('| Main entry (gzip) | **412.7 KB** | 350 KB |'); + expect(body).toContain('| Main entry chunk (gzip) | 412.7 KB | 350 KB |'); expect(body).toContain('| Status | **FAIL** | — |'); // The real signal must stay unambiguous — no hedging language on a // genuine violation. @@ -77,7 +83,8 @@ describe('renderBudgetComment', () => { expect(body).toContain('## ℹ️ Console Performance Budget — not measured'); expect(body).toContain('**This is not a budget violation.**'); // The empty-metric table that made the fake alarm look like a report. - expect(body).not.toContain('| Main entry (gzip) | ** KB** |'); + expect(body).not.toContain('| Main entry chunk (gzip) | KB |'); + expect(body).not.toContain('| **Eager closure** (gzip) | ** KB** |'); }); it.each([ @@ -112,6 +119,59 @@ describe('renderBudgetComment', () => { } }); + /** + * objectui#5324: this comment reported the `index-*.js` entry chunk and + * called it "the performance budget". On `77f846a8b` that chunk is 25.9 KB + * gzipped while the closure it statically pulls in is 3,790.6 KB — 0.67% of + * the payload — and the 89 KiB regression of objectui#5266 landed outside it. + * + * The invariant these pin: the closure is the number the comment leads with, + * and an ABSENT closure figure is stated, never silently dropped. A one-row + * table showing only the entry chunk IS the old gauge, and it reads as a + * complete report. + */ + it('leads with the eager closure and de-emphasises the entry chunk', () => { + const { body } = renderBudgetComment({ status: 'pass', ...measured }); + + const closureRow = body.indexOf('| **Eager closure**'); + const entryRow = body.indexOf('| Main entry chunk (gzip)'); + expect(closureRow).toBeGreaterThan(-1); + expect(entryRow).toBeGreaterThan(closureRow); + // The entry chunk keeps its row and its 350 KB line — replacing the gauge + // is not licence to drop the check that was already there. + expect(body).toContain('| Main entry chunk (gzip) | 28.1 KB | 350 KB |'); + expect(body).toContain('what the browser fetches and parses before the app renders'); + }); + + it('says so loudly when the closure was not measured, instead of showing the entry chunk alone', () => { + const { kind, body } = renderBudgetComment({ + status: 'pass', + gzipKb: '28.1', + budgetKb: '350', + entryFile: 'index-BRKCVm_4.js', + }); + + expect(kind).toBe('pass'); + expect(body).toContain('| **Eager closure** (gzip) | _not measured_ | — |'); + expect(body).toContain('was **not measured** in this run'); + expect(body).toContain('emitEagerClosureReport'); + }); + + it('carries no hedging language when both metrics are present on a real violation', () => { + const { body } = renderBudgetComment({ ...measured, status: 'fail', gzipKb: '412.7' }); + expect(body).not.toContain('not measured'); + }); + + it('omits the chunk count from the closure row rather than printing an empty one', () => { + const { body } = renderBudgetComment({ + status: 'pass', + ...measured, + closureChunks: '', + }); + expect(body).toContain('| **Eager closure** (gzip) | **3790.6 KB** | 3867.2 KB |'); + expect(body).not.toContain(', chunks)'); + }); + it('appends the package size report when one was generated', () => { const sizeReport = '## 📦 Bundle Size Report\n\n| Package | Size | Gzipped |'; const { body } = renderBudgetComment({ status: 'pass', ...measured, sizeReport }); @@ -138,6 +198,10 @@ describe('renderFromEnv', () => { BUDGET_GZIP_KB: '28.1', BUDGET_LIMIT_KB: '350', BUDGET_ENTRY_FILE: 'index-BRKCVm_4.js', + BUDGET_CLOSURE_STATUS: 'pass', + BUDGET_CLOSURE_GZIP_KB: '3790.6', + BUDGET_CLOSURE_BUDGET_KB: '3867.2', + BUDGET_CLOSURE_CHUNKS: '58', BUDGET_STEP_OUTCOME: 'success', BUILD_PACKAGES_OUTCOME: 'success', GITHUB_SERVER_URL: 'https://github.com', @@ -148,7 +212,8 @@ describe('renderFromEnv', () => { ); expect(kind).toBe('pass'); - expect(body).toContain('| Main entry (gzip) | **28.1 KB** | 350 KB |'); + expect(body).toContain('| Main entry chunk (gzip) | 28.1 KB | 350 KB |'); + expect(body).toContain('| **Eager closure** (gzip, 58 chunks) | **3790.6 KB** | 3867.2 KB |'); }); it('links the run so a "not measured" note can be checked against the log', () => { diff --git a/scripts/check-eager-closure-budget.mjs b/scripts/check-eager-closure-budget.mjs new file mode 100644 index 000000000..c84bb2c41 --- /dev/null +++ b/scripts/check-eager-closure-budget.mjs @@ -0,0 +1,318 @@ +#!/usr/bin/env node +/** + * The console performance budget, weighed over the EAGER CLOSURE. + * + * ## What this replaces + * + * `.github/workflows/performance-budget.yml` is named "Bundle Analysis" and its + * step is "Check console performance budget", but until objectui#5324 it gzipped + * exactly one file: + * + * ENTRY_FILE=$(find "$DIST_DIR" -name 'index-*.js' ... | head -1) + * GZIP_BYTES=$(gzip -c "$ENTRY_FILE" | wc -c) + * + * The entry chunk is not what a page load costs. It statically imports a closure + * of other chunks, and the browser fetches and parses all of them before the app + * renders. Measured on `77f846a8b`: + * + * | index-*.js alone (what the budget weighed) | 25,910 bytes gzipped | + * | the eager closure — 58 of 507 chunks | 3,881,609 bytes gzipped | + * + * So the gate passed on 0.67% of the payload it claimed to govern. That is not + * theoretical: objectui#5266 put 89 KiB gzipped on every console page load, it + * landed in `vendor-objectstack-*.js`, and this gate could not see it. + * `advancedChunks` deliberately routes vendor and workspace code into named + * chunks, so MOST regressions land outside `index-*.js`. + * + * ## Where the number comes from + * + * `apps/console/vite.config.ts` (`emitEagerClosureReport`) writes + * `apps/console/dist/eager-closure.json` from rolldown's own `chunk.imports` — + * a BFS from the entry chunks over STATIC imports only, gzipping the bytes that + * were actually written to disk. This file only applies a ceiling to it. The + * split keeps graph knowledge where the graph is and policy where it can be unit + * tested (`scripts/__tests__/check-eager-closure-budget.test.ts`), and it keeps a + * size regression from failing every Vercel preview and local build — which is + * how a budget gets switched off rather than fixed. + * + * ## The ceiling, and why it is this number + * + * `MAX_EAGER_CLOSURE_GZIP_BYTES` is today's measurement plus ~2% of headroom. + * Two constraints pin it from both sides: + * + * - It must PASS on today's `main`. A gate that lands red is a gate someone + * disables, and this one is landing as a replacement for a gate nobody could + * fail. Headroom above the current 3,881,609 bytes: 78,391 (2.02%). + * - The headroom must stay SMALLER than the regression the gate exists to + * catch. objectui#5266 was 89 KiB = 91,136 bytes; 78,391 < 91,136, so this + * ceiling would have failed on that change. Widening the headroom past ~89 + * KiB would leave the gate green through a repeat of its own motivating + * incident. + * + * This is a truthful CURRENT-STATE ceiling, not a target. 3.7 MB gzipped before + * first render is a bad payload, and the honest long-term line is far below it — + * but lowering the line is a separate decision with its own work behind it + * (objectui#5324 names the candidates: per-chunk budgets, a ratchet against + * `main`, or actually cleaving the closure). Nothing here should be read as a + * finding that 3.79 MB is acceptable. + * + * ## Raising it + * + * Re-baselining is legitimate — it is how a ratchet advances — but it is a + * DECISION, so make it visible: update the constant, update the measured figure + * in this comment, and say in the PR what the added bytes buy. Silently bumping + * the number to make CI green reproduces the gate this file replaced. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +/** + * Ceiling for the console eager closure, in gzipped bytes. See the header for + * how this number was chosen; measured 3,881,609 on `77f846a8b`. + */ +export const MAX_EAGER_CLOSURE_GZIP_BYTES = 3_960_000; + +/** + * The measurement the ceiling above was derived from. Exported so the two + * constraints in the header are CHECKED rather than merely argued + * (`scripts/__tests__/check-eager-closure-budget.test.ts`): a future edit that + * raises the ceiling past the regression size it is meant to catch fails a + * test instead of quietly becoming decorative. + */ +export const BASELINE = Object.freeze({ + /** `emitEagerClosureReport`'s `eagerGzipBytes` on this commit. */ + gzipBytes: 3_881_609, + chunks: 58, + totalChunks: 507, + commit: '77f846a8b', +}); + +/** + * 89 KiB gzipped — the per-page-load cost objectui#5266 added, landing in + * `vendor-objectstack-*.js` where the entry-chunk budget could not see it. The + * headroom above {@link BASELINE} must stay under this, or the gate is green + * through a repeat of the incident that motivated it. + */ +export const REGRESSION_THIS_GATE_MUST_CATCH_BYTES = 89 * 1024; + +/** The report shape this checker understands. */ +export const SUPPORTED_REPORT_VERSION = 1; + +const DEFAULT_REPORT_PATH = 'apps/console/dist/eager-closure.json'; + +/** + * Validate a parsed report before any verdict is read from it. + * + * Every check here is a counter-probe, and they all guard the same asymmetry: + * this gate's failure mode is SILENT. A missing field read as zero, a report + * left over from an older build, a walk that returned the entry chunk alone — + * each produces a SMALL number, and a budget check reads a small number as good + * news. So a report that cannot be trusted must be an ERROR, never a pass. + * + * @param {unknown} report + * @returns {string[]} problems; empty means the report may be read + */ +export function validateReport(report) { + const problems = []; + if (report === null || typeof report !== 'object') { + return ['report is not an object']; + } + const r = /** @type {Record} */ (report); + + if (r.reportVersion !== SUPPORTED_REPORT_VERSION) { + problems.push( + `reportVersion is ${JSON.stringify(r.reportVersion)}, expected ${SUPPORTED_REPORT_VERSION} — ` + + `the emitter in apps/console/vite.config.ts and this checker have drifted apart`, + ); + // Every field check below assumes v1's names, so they would report noise. + return problems; + } + + for (const key of ['eagerChunkCount', 'totalChunkCount', 'eagerGzipBytes', 'eagerRawBytes']) { + if (typeof r[key] !== 'number' || !Number.isFinite(r[key]) || r[key] < 0) { + problems.push(`${key} is ${JSON.stringify(r[key])}, expected a non-negative number`); + } + } + if (!Array.isArray(r.files)) problems.push('files is not an array'); + if (!Array.isArray(r.entryChunks) || r.entryChunks.length === 0) { + problems.push('entryChunks is empty — the walk had no roots, so it measured nothing'); + } + if (problems.length > 0) return problems; + + const files = /** @type {{ fileName: string, gzipBytes: number }[]} */ (r.files); + const eagerChunkCount = /** @type {number} */ (r.eagerChunkCount); + const totalChunkCount = /** @type {number} */ (r.totalChunkCount); + const eagerGzipBytes = /** @type {number} */ (r.eagerGzipBytes); + + // The entry chunk alone IS the gauge this replaces. One chunk is not a + // closure, and a walk that collapsed to its roots must not be read as a + // shrinking bundle. + if (eagerChunkCount < 2) { + problems.push( + `eagerChunkCount is ${eagerChunkCount} — the closure collapsed to its entry chunk(s), ` + + `which is exactly the blind gauge this check replaces (objectui#5324)`, + ); + } + // The other direction: if everything is eager there is no lazy boundary and + // the number is "the whole bundle", which no page load pays. + if (eagerChunkCount >= totalChunkCount) { + problems.push( + `eagerChunkCount (${eagerChunkCount}) is not less than totalChunkCount (${totalChunkCount}) — ` + + `the walk is not separating static from dynamic imports`, + ); + } + if (files.length !== eagerChunkCount) { + problems.push(`files has ${files.length} entries but eagerChunkCount is ${eagerChunkCount}`); + } + const summed = files.reduce((n, f) => n + (typeof f?.gzipBytes === 'number' ? f.gzipBytes : NaN), 0); + if (!Number.isFinite(summed) || summed !== eagerGzipBytes) { + problems.push( + `eagerGzipBytes (${eagerGzipBytes}) does not equal the sum of files[].gzipBytes (${summed}) — ` + + `the report is internally inconsistent, so neither number can be trusted`, + ); + } + return problems; +} + +/** + * @param {object} input + * @param {unknown} input.report parsed `eager-closure.json`, or null when absent + * @param {number} [input.budgetBytes] ceiling to compare against + * @param {string} [input.reportPath] path the report was read from, for messages + * @returns {{ status: 'pass' | 'fail' | 'error', message: string, gzipBytes: number | null, + * budgetBytes: number, chunkCount: number | null, totalChunkCount: number | null }} + */ +export function evaluateClosureBudget({ + report, + budgetBytes = MAX_EAGER_CLOSURE_GZIP_BYTES, + reportPath = DEFAULT_REPORT_PATH, +} = {}) { + const base = { budgetBytes, gzipBytes: null, chunkCount: null, totalChunkCount: null }; + + if (report === null || report === undefined) { + return { + ...base, + status: 'error', + message: + `No eager-closure report at ${reportPath}. It is written by ` + + `\`emitEagerClosureReport\` in apps/console/vite.config.ts during \`vite build\`, so an ` + + `absent report means the console was not built — or was built by a config that no ` + + `longer emits it. This is a broken gauge, not a passing budget.`, + }; + } + + const problems = validateReport(report); + if (problems.length > 0) { + return { + ...base, + status: 'error', + message: `Eager-closure report at ${reportPath} cannot be trusted:\n - ${problems.join('\n - ')}`, + }; + } + + const r = /** @type {{ eagerGzipBytes: number, eagerChunkCount: number, totalChunkCount: number }} */ (report); + const gzipBytes = r.eagerGzipBytes; + const shared = { + budgetBytes, + gzipBytes, + chunkCount: r.eagerChunkCount, + totalChunkCount: r.totalChunkCount, + }; + + if (gzipBytes > budgetBytes) { + const over = gzipBytes - budgetBytes; + return { + ...shared, + status: 'fail', + message: + `Console eager closure is ${kb(gzipBytes)} KB gzipped across ${r.eagerChunkCount} chunks — ` + + `${kb(over)} KB over the ${kb(budgetBytes)} KB budget.\n` + + `These are the bytes every console page load fetches and parses before the app renders; ` + + `they are not deferred by any lazy import.\n` + + `If the growth is intended, raise MAX_EAGER_CLOSURE_GZIP_BYTES in ` + + `scripts/check-eager-closure-budget.mjs deliberately and say in the PR what the bytes ` + + `buy — do not widen it just to get a green check.`, + }; + } + + return { + ...shared, + status: 'pass', + message: + `Console eager closure is ${kb(gzipBytes)} KB gzipped across ${r.eagerChunkCount} of ` + + `${r.totalChunkCount} chunks (budget: ${kb(budgetBytes)} KB, ` + + `headroom: ${kb(budgetBytes - gzipBytes)} KB).`, + }; +} + +const kb = (bytes) => (bytes / 1024).toFixed(1); + +/** + * The biggest eager chunks, so a failure names suspects instead of a total. + * @param {{ files?: { fileName: string, gzipBytes: number }[] }} report + * @param {number} [limit] + */ +export function renderTopChunks(report, limit = 12) { + const files = [...(report?.files ?? [])].sort((a, b) => b.gzipBytes - a.gzipBytes).slice(0, limit); + return files.map((f) => ` ${kb(f.gzipBytes).padStart(9)} KB ${f.fileName}`).join('\n'); +} + +/** @param {string} reportPath */ +export function readReport(reportPath) { + try { + return JSON.parse(fs.readFileSync(reportPath, 'utf8')); + } catch { + return null; + } +} + +/** Appends `name=value` lines to $GITHUB_OUTPUT when running in Actions. */ +function writeGithubOutput(entries, outputPath = process.env.GITHUB_OUTPUT) { + if (!outputPath) return; + const lines = Object.entries(entries).map(([k, v]) => `${k}=${v}\n`); + fs.appendFileSync(outputPath, lines.join('')); +} + +/** + * Exit codes: `0` within budget, `1` over budget, `2` no trustworthy + * measurement (report missing, stale-shaped, or internally inconsistent). + */ +export function main(argv = process.argv.slice(2)) { + const flagIndex = argv.indexOf('--report'); + const reportPath = flagIndex === -1 ? DEFAULT_REPORT_PATH : argv[flagIndex + 1]; + const resolved = path.resolve(reportPath); + const report = readReport(resolved); + const result = evaluateClosureBudget({ report, reportPath }); + + if (result.status === 'pass') { + console.log(`✅ ${result.message}`); + } else { + console.error(`❌ ${result.message}`); + } + if (report?.files?.length) { + console.log(''); + console.log('Largest eagerly loaded chunks (gzipped):'); + console.log(renderTopChunks(report)); + } + + writeGithubOutput({ + closure_status: result.status, + closure_gzip_kb: result.gzipBytes === null ? '' : kb(result.gzipBytes), + closure_budget_kb: kb(result.budgetBytes), + closure_chunks: result.chunkCount === null ? '' : String(result.chunkCount), + }); + + // Distinct codes so the workflow can tell "over budget" (a real verdict about + // the bundle) from "the gauge produced nothing" (a verdict about the gauge). + // Collapsing them to 1 would let a broken report be reported as a size + // regression, and a size regression reported as a broken report — each of + // which teaches readers to ignore the other. + if (result.status === 'pass') return 0; + return result.status === 'fail' ? 1 : 2; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exit(main()); +} diff --git a/scripts/render-budget-comment.mjs b/scripts/render-budget-comment.mjs index 092b006ea..9cacaedde 100644 --- a/scripts/render-budget-comment.mjs +++ b/scripts/render-budget-comment.mjs @@ -44,6 +44,10 @@ const text = (value) => (typeof value === 'string' ? value.trim() : ''); * @param {string} [input.gzipKb] measured gzip size of the main entry, in KB * @param {string} [input.budgetKb] the budget the measurement was compared against * @param {string} [input.entryFile] basename of the measured entry chunk + * @param {string} [input.closureStatus] `closure_status` written by the closure checker + * @param {string} [input.closureGzipKb] measured gzip size of the eager closure, in KB + * @param {string} [input.closureBudgetKb] the closure ceiling it was compared against + * @param {string} [input.closureChunks] how many chunks the eager closure spans * @param {string} [input.message] human-readable reason when `status` is `error` * @param {string} [input.budgetOutcome] `steps.budget.outcome` * @param {string} [input.buildOutcome] `steps.build_packages.outcome` @@ -57,6 +61,12 @@ export function renderBudgetComment(input = {}) { const budgetKb = text(input.budgetKb); const entryFile = text(input.entryFile); const sizeReport = text(input.sizeReport); + const closure = { + status: text(input.closureStatus), + gzipKb: text(input.closureGzipKb), + budgetKb: text(input.closureBudgetKb), + chunks: text(input.closureChunks), + }; // A verdict needs an affirmative status AND the numbers that status was // derived from. A real over-budget failure always arrives with a @@ -65,7 +75,7 @@ export function renderBudgetComment(input = {}) { MEASURED_STATUSES.has(status) && gzipKb !== '' && budgetKb !== '' && entryFile !== ''; const body = measured - ? verdictBody({ status, gzipKb, budgetKb, entryFile, sizeReport }) + ? verdictBody({ status, gzipKb, budgetKb, entryFile, sizeReport, closure }) : notMeasuredBody({ message: text(input.message), budgetOutcome: text(input.budgetOutcome), @@ -77,18 +87,36 @@ export function renderBudgetComment(input = {}) { return { kind: measured ? status : 'not-measured', body }; } -function verdictBody({ status, gzipKb, budgetKb, entryFile, sizeReport }) { +function verdictBody({ status, gzipKb, budgetKb, entryFile, sizeReport, closure }) { const pass = status === 'pass'; + const closureMeasured = closure.gzipKb !== '' && closure.budgetKb !== ''; const lines = [ `## ${pass ? '✅' : '❌'} Console Performance Budget`, '', '| Metric | Value | Budget |', '|--------|-------|--------|', - `| Main entry (gzip) | **${gzipKb} KB** | ${budgetKb} KB |`, + closureMeasured + ? `| **Eager closure** (gzip${closure.chunks ? `, ${closure.chunks} chunks` : ''}) | **${closure.gzipKb} KB** | ${closure.budgetKb} KB |` + : '| **Eager closure** (gzip) | _not measured_ | — |', + `| Main entry chunk (gzip) | ${gzipKb} KB | ${budgetKb} KB |`, `| Entry file | \`${entryFile}\` | — |`, `| Status | **${pass ? 'PASS' : 'FAIL'}** | — |`, '', + // The closure is the number that governs a page load; the entry chunk is + // ~1% of it. Saying so in the comment is what stops the entry figure from + // being read as "the bundle" the way it was for the whole life of this + // gate (objectui#5324). + 'The **eager closure** is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.', + '', ]; + if (!closureMeasured) { + // Never let an absent closure figure render as a quiet table with one row. + // A comment that shows only the entry number, silently, IS the old gauge. + lines.push( + '> ⚠️ The eager closure was **not measured** in this run, so this verdict covers the entry chunk only — roughly 1% of what a page load costs. Check the `Check console performance budget` step log: the report is written by `emitEagerClosureReport` in `apps/console/vite.config.ts` during the console build.', + '', + ); + } return withSizeReport(lines.join('\n'), sizeReport); } @@ -151,6 +179,10 @@ export function renderFromEnv(env = process.env, sizeReportPath = 'size-report.m gzipKb: env.BUDGET_GZIP_KB, budgetKb: env.BUDGET_LIMIT_KB, entryFile: env.BUDGET_ENTRY_FILE, + closureStatus: env.BUDGET_CLOSURE_STATUS, + closureGzipKb: env.BUDGET_CLOSURE_GZIP_KB, + closureBudgetKb: env.BUDGET_CLOSURE_BUDGET_KB, + closureChunks: env.BUDGET_CLOSURE_CHUNKS, budgetOutcome: env.BUDGET_STEP_OUTCOME, buildOutcome: env.BUILD_PACKAGES_OUTCOME, sizeReport: readSizeReport(sizeReportPath),