From eb93e07570747d5759d466e2fd4979e386ddacdf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 01:34:25 +0000 Subject: [PATCH 1/2] test(scripts): add per-chunk gzipped ceilings to the eager-closure budget Per-chunk ceilings for vendor-objectstack, framework and ui-components on top of the aggregate closure ceiling, keyed on the chunk names the report carries so a renamed or vanished chunk fails loudly instead of passing by measuring nothing. Report v2 publishes each eager chunk's own rolldown name. Part of #5490 --- .changeset/5490-per-chunk-eager-budgets.md | 21 ++ apps/console/vite.config.ts | 29 +- .../check-eager-closure-budget.test.ts | 330 ++++++++++++++++- scripts/check-eager-closure-budget.mjs | 335 +++++++++++++++++- 4 files changed, 690 insertions(+), 25 deletions(-) create mode 100644 .changeset/5490-per-chunk-eager-budgets.md diff --git a/.changeset/5490-per-chunk-eager-budgets.md b/.changeset/5490-per-chunk-eager-budgets.md new file mode 100644 index 0000000000..f058a3687b --- /dev/null +++ b/.changeset/5490-per-chunk-eager-budgets.md @@ -0,0 +1,21 @@ +--- +--- + +Build tooling and CI only — `apps/console/vite.config.ts` (not published source: +`@object-ui/console`'s `files` list carries `dist`, `plugin.*` and `README.md`), +`scripts/check-eager-closure-budget.mjs` and its unit test. Nothing ships from +this change. + +The console eager-closure budget weighed one total across 52 chunks. Inside its +headroom a single chunk can absorb the whole allowance while the others shrink, +and the total never moves — the shape of objectui#5266, whose 89 KiB landed +entirely in `vendor-objectstack`. Per-chunk gzipped ceilings now sit on top of +the aggregate for the three largest eager chunks, set at the measured current +state plus ~2%, with each headroom narrower than the regression the gate exists +to catch. + +The ceilings key on the chunk names the report itself carries (`files[].name`, +new in report v2, taken from rolldown's own `chunk.name`) rather than on names +this checker expects to exist. A budgeted chunk that is absent — renamed group, +chunk gone — is therefore an error, not a skip: a ceiling with no subject weighs +nothing and would be green forever. diff --git a/apps/console/vite.config.ts b/apps/console/vite.config.ts index 5c17ac54b5..17bc8bcf7c 100644 --- a/apps/console/vite.config.ts +++ b/apps/console/vite.config.ts @@ -326,17 +326,40 @@ function emitEagerClosureReport(reportFileName = 'eager-closure.json'): Plugin { ); } + // The chunk's OWN name, as rolldown recorded it — the `advancedChunks` + // group name for a grouped chunk, the entry's name for an entry. The + // per-chunk ceilings in `scripts/check-eager-closure-budget.mjs` key on + // this field (objectui#5490) instead of stripping the hash out of + // `fileName` themselves: the group names are decided by `advancedChunks` + // a few hundred lines below, and a budget that re-derives them from a + // file name is a second opinion about the same fact — one that goes on + // reading plausibly while it matches nothing. + const name = chunks.get(fileName)?.name; + if (typeof name !== 'string' || name === '') { + this.error( + `[emit-eager-closure-report] eager-closure member \`${fileName}\` carries no chunk ` + + `\`name\` (${JSON.stringify(name)}), so a per-chunk ceiling has no way to find ` + + `its subject. Refused rather than published: a budgeted chunk MISSING from this ` + + `report is a budget with nothing to weigh, and a budget that weighs nothing ` + + `passes — the silent direction every counter-probe in this plugin exists to ` + + `refuse (objectui#5490).`, + ); + } + const raw = fs.readFileSync(filePath); // 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 }; + return { fileName, name, 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, + // does not understand rather than reading absent fields as zero. v2 + // added `files[].name` for the per-chunk ceilings (objectui#5490) — a + // v1 report reaching the v2 checker is therefore a REFUSAL, not a run + // in which every budgeted chunk happens to be missing. + reportVersion: 2, entryChunks: entries.sort(), eagerChunkCount: files.length, totalChunkCount: chunks.size, diff --git a/scripts/__tests__/check-eager-closure-budget.test.ts b/scripts/__tests__/check-eager-closure-budget.test.ts index e4721ab88f..e972e94029 100644 --- a/scripts/__tests__/check-eager-closure-budget.test.ts +++ b/scripts/__tests__/check-eager-closure-budget.test.ts @@ -10,10 +10,14 @@ import { fileURLToPath } from 'node:url'; import { BASELINE, MAX_EAGER_CLOSURE_GZIP_BYTES, + PER_CHUNK_BASELINE, + PER_CHUNK_GZIP_CEILINGS, REGRESSION_THIS_GATE_MUST_CATCH_BYTES, SUPPORTED_REPORT_VERSION, evaluateClosureBudget, + evaluatePerChunkBudgets, main, + measureChunksByName, renderTopChunks, validateReport, } from '../check-eager-closure-budget.mjs'; @@ -29,9 +33,14 @@ const viteConfigPath = path.join(repoRoot, 'apps/console/vite.config.ts'); */ 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 }, + { fileName: 'assets/index-A.js', name: 'index', bytes: 90_000, gzipBytes: 25_910 }, + { + fileName: 'assets/vendor-objectstack-B.js', + name: 'vendor-objectstack', + bytes: 5_000_000, + gzipBytes: 1_529_129, + }, + { fileName: 'assets/framework-C.js', name: 'framework', bytes: 1_800_000, gzipBytes: 495_690 }, ]; return { reportVersion: SUPPORTED_REPORT_VERSION, @@ -63,6 +72,7 @@ describe('the ceiling itself', () => { function closureOf(gzipBytes: number, chunks = BASELINE.chunks) { const files = Array.from({ length: chunks }, (_, i) => ({ fileName: `assets/chunk-${i}.js`, + name: `chunk-${i}`, bytes: 0, gzipBytes: i === 0 ? gzipBytes - (chunks - 1) : 1, })); @@ -125,7 +135,9 @@ describe('evaluateClosureBudget', () => { }); 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 files = [ + { fileName: 'assets/index-A.js', name: 'index', bytes: 90_000, gzipBytes: 25_910 }, + ]; const result = evaluateClosureBudget({ report: report({ files, eagerChunkCount: 1, eagerGzipBytes: 25_910 }), }); @@ -164,6 +176,186 @@ describe('evaluateClosureBudget', () => { }); }); +/** + * objectui#5490 — per-chunk ceilings on top of the aggregate. + * + * The aggregate is one number over 52 chunks: inside its headroom a single + * chunk can absorb the whole allowance while the others shrink, and the total + * never moves. objectui#5266 is that shape exactly — 89 KiB, all of it in + * `vendor-objectstack`. These tests hold the two properties that decide whether + * the per-chunk half is worth anything: it must be red when a budgeted chunk + * grows, and it must be red — not silent — when a budgeted chunk is not there + * to weigh. + */ +describe('per-chunk ceilings', () => { + /** A v2 report carrying the real budgeted names at the real measured sizes. */ + function budgetedReport(sizes: Record = {}) { + const measured: Record = { ...PER_CHUNK_BASELINE, ...sizes }; + const files = [ + { fileName: 'assets/index-A.js', name: 'index', bytes: 0, gzipBytes: 25_910 }, + ...Object.entries(measured).map(([name, gzipBytes]) => ({ + fileName: `assets/${name}-hash.js`, + name, + bytes: 0, + gzipBytes, + })), + ]; + return report({ + files, + eagerChunkCount: files.length, + eagerGzipBytes: files.reduce((n, f) => n + f.gzipBytes, 0), + eagerRawBytes: 0, + }); + } + + describe('the ceilings themselves', () => { + it('budgets exactly the chunks it has measured', () => { + // A ceiling with no measurement behind it is a guess; a measurement with + // no ceiling weighs nothing. Neither may exist alone. + expect(Object.keys(PER_CHUNK_GZIP_CEILINGS).sort()).toEqual( + Object.keys(PER_CHUNK_BASELINE).sort(), + ); + expect(Object.keys(PER_CHUNK_GZIP_CEILINGS).length).toBeGreaterThan(0); + }); + + it.each(Object.keys(PER_CHUNK_GZIP_CEILINGS))( + '%s passes on its measured size, with headroom narrower than the regression it must catch', + (name) => { + const measured = PER_CHUNK_BASELINE[name as keyof typeof PER_CHUNK_BASELINE]; + const ceiling = PER_CHUNK_GZIP_CEILINGS[name as keyof typeof PER_CHUNK_GZIP_CEILINGS]; + // Truthful current state: a ceiling under today's payload lands red on + // `main`, which is how a budget gets switched off rather than met. + expect(ceiling).toBeGreaterThan(measured); + // ...and headroom wider than one known regression makes the line + // decorative — objectui#5266's 89 KiB landed in one of these chunks. + expect(ceiling - measured).toBeLessThan(REGRESSION_THIS_GATE_MUST_CATCH_BYTES); + }, + ); + + it('passes on the measured baseline and names every size and headroom', () => { + const result = evaluatePerChunkBudgets({ report: budgetedReport() }); + expect(result.status).toBe('pass'); + // The verdict carries the MEASUREMENT, not a tick: a reader watching a + // chunk creep upward should see it coming. + for (const [name, measured] of Object.entries(PER_CHUNK_BASELINE)) { + expect(result.message).toContain(name); + expect(result.message).toContain((measured / 1024).toFixed(1)); + } + expect(result.message).toContain('headroom'); + }); + + it('would have caught objectui#5266 — 89 KiB into a single budgeted chunk', () => { + const result = evaluatePerChunkBudgets({ + report: budgetedReport({ + 'vendor-objectstack': + PER_CHUNK_BASELINE['vendor-objectstack'] + REGRESSION_THIS_GATE_MUST_CATCH_BYTES, + }), + }); + expect(result.status).toBe('fail'); + expect(result.over).toEqual(['vendor-objectstack']); + }); + }); + + it('fails a chunk over its ceiling, naming the chunk and BOTH numbers', () => { + const over = PER_CHUNK_GZIP_CEILINGS.framework + 1; + const result = evaluatePerChunkBudgets({ report: budgetedReport({ framework: over }) }); + expect(result.status).toBe('fail'); + expect(result.over).toEqual(['framework']); + expect(result.message).toContain('framework'); + expect(result.message).toContain((over / 1024).toFixed(1)); + expect(result.message).toContain((PER_CHUNK_GZIP_CEILINGS.framework / 1024).toFixed(1)); + expect(result.message).toContain('do not widen it just to get a green check'); + }); + + it('sums chunks sharing a name, so a group cannot split its way under a ceiling', () => { + const half = Math.ceil((PER_CHUNK_GZIP_CEILINGS['ui-components'] + 2) / 2); + const base = budgetedReport(); + const files = [ + ...base.files.filter((f) => f.name !== 'ui-components'), + { fileName: 'assets/ui-components-1.js', name: 'ui-components', bytes: 0, gzipBytes: half }, + { fileName: 'assets/ui-components-2.js', name: 'ui-components', bytes: 0, gzipBytes: half }, + ]; + const result = evaluatePerChunkBudgets({ + report: report({ + files, + eagerChunkCount: files.length, + eagerGzipBytes: files.reduce((n, f) => n + f.gzipBytes, 0), + eagerRawBytes: 0, + }), + }); + expect(measureChunksByName({ files }).get('ui-components')?.gzipBytes).toBe(half * 2); + expect(result.status).toBe('fail'); + expect(result.over).toEqual(['ui-components']); + }); + + /** + * The clause the whole card turns on: a budget keyed on a chunk that no + * longer exists is VACUOUSLY GREEN — it passes because it is measuring + * nothing. Every case here must be an ERROR, never a skip and never a pass. + */ + describe('refuses to weigh a chunk that is not there', () => { + it('errors when a budgeted chunk has been renamed, naming it and listing what IS present', () => { + const base = budgetedReport(); + const files = base.files.map((f) => + f.name === 'vendor-objectstack' ? { ...f, name: 'vendor-objectstack-core' } : f, + ); + const result = evaluatePerChunkBudgets({ report: report({ ...base, files }) }); + expect(result.status).toBe('error'); + expect(result.missing).toEqual(['vendor-objectstack']); + expect(result.message).toContain('vendor-objectstack'); + expect(result.message).toContain('ABSENT'); + // The new spelling is in the message, so a rename is diagnosable from the + // failure alone rather than from a second build. + expect(result.message).toContain('vendor-objectstack-core'); + }); + + it('errors when a budgeted chunk has left the closure entirely', () => { + const base = budgetedReport(); + const files = base.files.filter((f) => f.name !== 'framework'); + const result = evaluatePerChunkBudgets({ + report: report({ + files, + eagerChunkCount: files.length, + eagerGzipBytes: files.reduce((n, f) => n + f.gzipBytes, 0), + eagerRawBytes: 0, + }), + }); + expect(result.status).toBe('error'); + expect(result.missing).toEqual(['framework']); + // Good news is still RE-PINNED deliberately, not inferred by a gate. + expect(result.message).toContain('RE-PINNED'); + }); + + it('errors on a report with no chunks at all — a collapse is not an under-budget bundle', () => { + const result = evaluatePerChunkBudgets({ + report: report({ files: [], eagerChunkCount: 0, eagerGzipBytes: 0 }), + }); + expect(result.status).toBe('error'); + expect(measureChunksByName({ files: [] }).size).toBe(0); + }); + + it('errors when no ceilings are configured — an empty budget is a disabled one', () => { + const result = evaluatePerChunkBudgets({ report: budgetedReport(), ceilings: {} }); + expect(result.status).toBe('error'); + expect(result.message).toContain('weighs nothing'); + }); + + it('errors when the report carries no chunk names (a build from before v2)', () => { + const base = budgetedReport(); + const files = base.files.map(({ name, ...rest }) => rest); + expect(validateReport(report({ ...base, files })).join(' ')).toContain('no chunk `name`'); + const result = evaluatePerChunkBudgets({ report: report({ ...base, files }) }); + expect(result.status).toBe('error'); + }); + + it('errors when there is no report at all — an unbuilt tree measures nothing', () => { + const result = evaluatePerChunkBudgets({ report: null }); + expect(result.status).toBe('error'); + expect(result.message).toContain('broken gauge'); + }); + }); +}); + describe('renderTopChunks', () => { it('names the biggest eager chunks so a failure has suspects', () => { const lines = renderTopChunks(report(), 2).split('\n'); @@ -202,17 +394,20 @@ describe('main', () => { } } + // `budgeted()` rather than the bare `report()` fixture: since objectui#5490 + // the checker weighs BOTH halves, and a report missing the budgeted chunks is + // an error — which is the per-chunk half working, not a fixture detail. it('exits 0 and publishes the measurement when within budget', () => { - const { code, outputs } = run(report()); + const { code, outputs } = run(budgeted()); expect(code).toBe(0); expect(outputs.closure_status).toBe('pass'); - expect(outputs.closure_chunks).toBe('3'); - expect(outputs.closure_gzip_kb).toBe('2002.7'); + expect(outputs.closure_chunks).toBe('4'); + expect(outputs.closure_gzip_kb).toBe('1814.3'); }); 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 }, + { fileName: 'assets/huge.js', name: 'huge', 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. @@ -221,17 +416,95 @@ describe('main', () => { }); it('exits 1 with a real over-budget report', () => { - const huge = MAX_EAGER_CLOSURE_GZIP_BYTES + 1; + // Every per-chunk ceiling holds and the TOTAL is still over: the aggregate + // half is not made redundant by the per-chunk one — bytes can also arrive + // spread across chunks nobody budgets. + const base = budgeted(); const files = [ - { fileName: 'assets/index-A.js', bytes: 90_000, gzipBytes: 1 }, - { fileName: 'assets/huge.js', bytes: 30_000_000, gzipBytes: huge - 1 }, + ...base.files, + { fileName: 'assets/huge.js', name: 'huge', bytes: 30_000_000, gzipBytes: MAX_EAGER_CLOSURE_GZIP_BYTES }, ]; - const { code, outputs } = run(report({ files, eagerChunkCount: 2, eagerGzipBytes: huge })); + const { code, outputs } = run( + report({ + files, + eagerChunkCount: files.length, + eagerGzipBytes: files.reduce((n, f) => n + f.gzipBytes, 0), + eagerRawBytes: 0, + }), + ); expect(code).toBe(1); expect(outputs.closure_status).toBe('fail'); + expect(outputs.closure_chunk_status).toBe('pass'); expect(outputs.closure_gzip_kb).not.toBe(''); }); + /** A v2 report at the measured per-chunk sizes, well inside the aggregate. */ + function budgeted(sizes: Record = {}) { + const measured: Record = { ...PER_CHUNK_BASELINE, ...sizes }; + const files = [ + { fileName: 'assets/index-A.js', name: 'index', bytes: 0, gzipBytes: 25_910 }, + ...Object.entries(measured).map(([name, gzipBytes]) => ({ + fileName: `assets/${name}-hash.js`, + name, + bytes: 0, + gzipBytes, + })), + ]; + return report({ + files, + eagerChunkCount: files.length, + eagerGzipBytes: files.reduce((n, f) => n + f.gzipBytes, 0), + eagerRawBytes: 0, + }); + } + + it('exits 0 and publishes both verdicts when every budget holds', () => { + const { code, outputs } = run(budgeted()); + expect(code).toBe(0); + expect(outputs.closure_status).toBe('pass'); + expect(outputs.closure_chunk_status).toBe('pass'); + }); + + /** + * The reason this half exists, as one run: the TOTAL is inside the aggregate + * ceiling — the aggregate half is green — and a single chunk has still grown + * past its own line. Before objectui#5490 that run exited 0. + */ + it('exits 1 when one chunk is over its ceiling while the aggregate is green', () => { + const { code, outputs } = run( + budgeted({ 'vendor-objectstack': PER_CHUNK_GZIP_CEILINGS['vendor-objectstack'] + 1 }), + ); + expect(code).toBe(1); + expect(outputs.closure_status).toBe('pass'); + expect(outputs.closure_chunk_status).toBe('fail'); + }); + + it('exits 2 when a budgeted chunk is absent — measuring nothing is not passing', () => { + const base = budgeted(); + const files = base.files.filter((f) => f.name !== 'ui-components'); + const { code, outputs } = run( + report({ + files, + eagerChunkCount: files.length, + eagerGzipBytes: files.reduce((n, f) => n + f.gzipBytes, 0), + eagerRawBytes: 0, + }), + ); + expect(code).toBe(2); + // The aggregate half is perfectly happy — which is precisely why the + // per-chunk half may not be silent about it. + expect(outputs.closure_status).toBe('pass'); + expect(outputs.closure_chunk_status).toBe('error'); + }); + + it('exits 2 on a report from a build that predates per-chunk names', () => { + const base = budgeted(); + const { code, outputs } = run({ ...base, reportVersion: 1 }); + expect(code).toBe(2); + expect(outputs.closure_status).toBe('error'); + expect(outputs.closure_chunk_status).toBe('error'); + }); + it('exits 2 — a verdict about the GAUGE — when there is no report', () => { const { code, outputs } = run(undefined); expect(code).toBe(2); @@ -287,6 +560,39 @@ describe('performance-budget.yml + vite.config.ts contract', () => { expect(ciOnlyBlock).toBeGreaterThan(registration); }); + it('agrees with the emitter about the report version', () => { + // The two halves of one contract, in two files. A silent disagreement here + // is the worst shape available: the checker would refuse every report, or + // (the version it was bumped to guard) read a report missing the very field + // the per-chunk ceilings key on. + const emitted = viteConfig.match(/reportVersion: (\d+)/); + expect(emitted?.[1]).toBe(String(SUPPORTED_REPORT_VERSION)); + }); + + it('publishes each chunk\'s own name, and refuses a member without one', () => { + const plugin = viteConfig.slice(viteConfig.indexOf('function emitEagerClosureReport')); + const body = plugin.slice(0, plugin.indexOf('\n}\n')); + expect(body).toContain('chunks.get(fileName)?.name'); + expect(body).toContain('return { fileName, name,'); + // An unnamed member must stop the build rather than be published: it would + // reach the checker as bytes no per-chunk ceiling can find. + expect(body).toContain('carries no chunk'); + }); + + /** + * The static half of the mapping pin. The runtime half (a budgeted chunk + * absent from the REPORT is an error) needs a build; this one reds in a unit + * run the moment an `advancedChunks` group is renamed out from under a + * ceiling — the rename and the stale ceiling are then one failing test apart + * rather than one green CI apart. + */ + it.each(Object.keys(PER_CHUNK_GZIP_CEILINGS))( + 'budgets `%s`, which is a real advancedChunks group in the console config', + (name) => { + expect(viteConfig).toContain(`{ name: '${name}',`); + }, + ); + it('writes the report where the checker looks for it', () => { expect(viteConfig).toContain("reportFileName = 'eager-closure.json'"); const checker = fs.readFileSync( diff --git a/scripts/check-eager-closure-budget.mjs b/scripts/check-eager-closure-budget.mjs index 26e1378888..484c0ea05a 100644 --- a/scripts/check-eager-closure-budget.mjs +++ b/scripts/check-eager-closure-budget.mjs @@ -78,10 +78,18 @@ * This is a truthful CURRENT-STATE ceiling, not a target. 3.8 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; objectui#5490 is the ruled follow-up - * adding per-chunk budgets so `vendor-objectstack` cannot grow unnoticed inside - * aggregate headroom again). Nothing here should be read as a finding that - * 3.82 MB is acceptable. + * (objectui#5324 names the candidates). Nothing here should be read as a + * finding that 3.82 MB is acceptable. + * + * ## Per-chunk ceilings (objectui#5490) + * + * One total over 52 chunks cannot say WHERE the payload moved, and inside its + * headroom one chunk can grow by the whole allowance while the others shrink. + * {@link PER_CHUNK_GZIP_CEILINGS} adds a line per big chunk on top of the + * aggregate — same truthful-current-state discipline, same checked constraints, + * keyed on the chunk names the REPORT carries so a renamed or vanished chunk + * fails loudly instead of passing by weighing nothing. See that constant's + * comment for the reasoning and for how to move one. * * ## Raising it * @@ -124,8 +132,93 @@ export const BASELINE = Object.freeze({ */ export const REGRESSION_THIS_GATE_MUST_CATCH_BYTES = 89 * 1024; -/** The report shape this checker understands. */ -export const SUPPORTED_REPORT_VERSION = 1; +/** + * Per-chunk ceilings, in gzipped bytes, keyed by the chunk NAME the report + * carries (objectui#5490, the ruled follow-up of objectui#5468: option A now, + * option C next, option B — a comparison against `main` — rejected). + * + * ## Why the aggregate ceiling is not enough + * + * The aggregate is one number over 52 chunks. Inside its headroom, a single + * chunk can grow by the whole allowance while every other chunk shrinks by the + * same amount, and the gate reports a green tick either way. That is not a + * hypothetical shape: objectui#5266 put 89 KiB on every page load and ALL of it + * landed in `vendor-objectstack`, the chunk that is 29% of the closure today. + * The aggregate says whether the payload grew; these say WHERE. + * + * ## Why the keys are names from the report and not names written here + * + * The names are decided by `advancedChunks.groups` in + * `apps/console/vite.config.ts`, emitted by rolldown, and published in + * `files[].name`. This file looks them up; it does not re-derive them by + * stripping `-.js` off a file name, and it does not carry a list of + * chunks it EXPECTS to exist independent of the measurement. + * + * The distinction is the whole design, because the failure mode is silent. A + * budget keyed on a chunk name that no longer exists is VACUOUSLY GREEN: it + * passes because there is nothing to weigh. So a budgeted name that is absent + * from the report is an ERROR here (exit 2, "the gauge cannot be trusted"), + * never a skip — the same asymmetry {@link validateReport} exists for. If a + * group is renamed or removed in `vite.config.ts`, this gate stops the build + * and says so, and the mapping is re-pinned deliberately. + * + * ## Raising one + * + * Same discipline as {@link MAX_EAGER_CLOSURE_GZIP_BYTES}, and the same two + * constraints, both CHECKED in `scripts/__tests__/check-eager-closure-budget.test.ts`: + * every ceiling passes on the measurement in {@link PER_CHUNK_BASELINE}, and its + * headroom stays under {@link REGRESSION_THIS_GATE_MUST_CATCH_BYTES} so a repeat + * of the incident that motivated the gate cannot fit inside it. ⛔ Do not LOWER + * one below the measured figure to express an aspiration: this is a ratchet, and + * a ceiling under today's reality is a gate that lands red on `main`, which is + * how a budget gets switched off rather than met. Shrinking the payload is real + * work with its own cards (objectui#5324 names the candidates); when it lands, + * re-measure and lower both numbers together. + */ +export const PER_CHUNK_GZIP_CEILINGS = Object.freeze({ + 'vendor-objectstack': 967_000, + framework: 502_000, + 'ui-components': 399_000, +}); + +/** + * The measurement {@link PER_CHUNK_GZIP_CEILINGS} was derived from: one + * `vite build` of `apps/console` on `2c8474c04`, read out of the report that + * build wrote. Exported so the ceilings are CHECKED against it instead of + * merely asserted in this comment. + * + * ⚠️ This is a DIFFERENT and LATER reading than {@link BASELINE} above, which + * still carries `4c1623c0c`. On `2c8474c04` the same build measures the closure + * at 3,298,620 bytes — 707,291 BELOW that recorded aggregate baseline, almost + * all of it in `vendor-objectstack` (1,493 KB in the objectui#5490 card, 926 KB + * here). The aggregate ceiling and its baseline were deliberately NOT touched + * by objectui#5490: objectui#5468 ruled that the aggregate line "stays as + * shipped", and moving it — in either direction — is the maintainer's call, not + * this card's. The consequence is recorded rather than quietly fixed: the + * aggregate ceiling now sits ~787 KB above today's payload, which is far more + * than the {@link REGRESSION_THIS_GATE_MUST_CATCH_BYTES} it was sized to catch, + * and until that is re-decided these per-chunk ceilings are what actually holds + * the three biggest chunks in place. + * + * Keys must match {@link PER_CHUNK_GZIP_CEILINGS} exactly (a test enforces it): + * a ceiling with no measurement behind it is a number someone guessed, and a + * measurement with no ceiling weighs nothing. + */ +export const PER_CHUNK_BASELINE = Object.freeze({ + 'vendor-objectstack': 948_461, + framework: 492_399, + 'ui-components': 391_095, +}); + +/** + * The report shape this checker understands. v2 added `files[].name` — the + * chunk name rolldown itself recorded — which is what the per-chunk ceilings + * below are keyed on. Refusing a v1 report is the point: without those names + * every budgeted chunk would be "absent", and the difference between "this + * build predates per-chunk budgets" and "vendor-objectstack has vanished from + * the closure" must not be a guess. + */ +export const SUPPORTED_REPORT_VERSION = 2; const DEFAULT_REPORT_PATH = 'apps/console/dist/eager-closure.json'; @@ -193,6 +286,20 @@ export function validateReport(report) { if (files.length !== eagerChunkCount) { problems.push(`files has ${files.length} entries but eagerChunkCount is ${eagerChunkCount}`); } + // v2: every member carries the chunk name rolldown recorded, and that name is + // the key the per-chunk ceilings look up. A member without one is not a + // cosmetic gap — its bytes would be weighed by the aggregate and by nothing + // else, and a budgeted chunk hiding in it would read as ABSENT. + const unnamed = files.filter((f) => typeof f?.name !== 'string' || f.name === ''); + if (unnamed.length > 0) { + const shown = unnamed.slice(0, 5).map((f) => f?.fileName ?? ''); + problems.push( + `${unnamed.length} of ${files.length} files carry no chunk \`name\` (${shown.join(', ')}` + + `${unnamed.length > shown.length ? ', …' : ''}) — per-chunk ceilings key on that field, ` + + `so a report without it cannot be weighed per chunk`, + ); + } + const summed = files.reduce((n, f) => n + (typeof f?.gzipBytes === 'number' ? f.gzipBytes : NaN), 0); if (!Number.isFinite(summed) || summed !== eagerGzipBytes) { problems.push( @@ -276,6 +383,192 @@ export function evaluateClosureBudget({ const kb = (bytes) => (bytes / 1024).toFixed(1); +/** + * Fold the report's eager members into `name -> { gzipBytes, fileNames }`. + * + * Chunks are SUMMED per name rather than matched one-to-one, so a group that + * one day emits two chunks under the same name cannot let bytes out from under + * its ceiling by splitting. Members with no name are skipped here and refused + * upstream by {@link validateReport}, which is the only place that refusal + * belongs — dropping them silently here would be the under-count this whole + * file exists to prevent. + * + * @param {{ files?: { fileName: string, name?: string, gzipBytes: number }[] }} report + * @returns {Map} + */ +export function measureChunksByName(report) { + const byName = new Map(); + for (const file of report?.files ?? []) { + const name = file?.name; + if (typeof name !== 'string' || name === '') continue; + const entry = byName.get(name) ?? { name, gzipBytes: 0, fileNames: [] }; + entry.gzipBytes += file.gzipBytes; + entry.fileNames.push(file.fileName); + byName.set(name, entry); + } + return byName; +} + +/** + * Weigh each budgeted chunk against its own ceiling. + * + * Three verdicts, and the ORDER between them is the design: + * + * - `error` — the report cannot be read, yielded no named chunks at all, or + * is missing a chunk this file budgets. All three are verdicts about the + * GAUGE. The missing-chunk case is the one worth stating out loud: a budget + * whose subject is absent passes trivially, so "absent" must be louder than + * "over", not quieter. A collapsed measurement is never under budget. + * - `fail` — a budgeted chunk is over its ceiling. Names the chunk and BOTH + * numbers, because "over budget" without the measurement is a tick in the + * other direction. + * - `pass` — every budgeted chunk with its measured size and remaining + * headroom, so a reader watching a chunk creep upward sees it coming + * instead of learning about it the day the gate turns red. + * + * @param {object} input + * @param {unknown} input.report + * @param {Record} [input.ceilings] + * @param {string} [input.reportPath] + * @returns {{ status: 'pass' | 'fail' | 'error', message: string, + * chunks: { name: string, gzipBytes: number, ceilingBytes: number, + * headroomBytes: number, fileNames: string[] }[], + * missing: string[], over: string[] }} + */ +export function evaluatePerChunkBudgets({ + report, + ceilings = PER_CHUNK_GZIP_CEILINGS, + reportPath = DEFAULT_REPORT_PATH, +} = {}) { + const base = { chunks: [], missing: [], over: [] }; + const budgeted = Object.keys(ceilings); + + if (report === null || report === undefined) { + return { + ...base, + status: 'error', + message: + `No eager-closure report at ${reportPath}, so no chunk was weighed. Per-chunk ` + + `ceilings measure nothing without a build — this is a broken gauge, not ` + + `${budgeted.length} budgets that all passed.`, + }; + } + + const problems = validateReport(report); + if (problems.length > 0) { + return { + ...base, + status: 'error', + message: + `Per-chunk budgets cannot be read from ${reportPath}:\n - ${problems.join('\n - ')}`, + }; + } + + // A ceiling map with no entries is the same vacuity as a ceiling whose chunk + // is missing, one level up: nothing is weighed, so nothing can fail. + if (budgeted.length === 0) { + return { + ...base, + status: 'error', + message: + `No per-chunk ceilings are configured, so this half of the gate weighs nothing. ` + + `An empty PER_CHUNK_GZIP_CEILINGS is a disabled check, not a passing one — ` + + `objectui#5490 exists because a budget with no subject is green forever.`, + }; + } + + const measured = measureChunksByName(report); + // Defence in depth: {@link validateReport} above already refuses a report with + // no files or with unnamed ones, so this branch should be unreachable through + // this function. It stays because the direction it guards is the silent one — + // if a future report shape ever slips an empty measurement past validation, + // "zero chunks" must read as a broken gauge, never as an under-budget bundle. + if (measured.size === 0) { + return { + ...base, + status: 'error', + message: + `The eager-closure report at ${reportPath} yielded ZERO named chunks, so every ` + + `per-chunk budget would pass by measuring nothing. A collapsed measurement is not an ` + + `under-budget bundle.`, + }; + } + + const present = [...measured.values()].sort((a, b) => b.gzipBytes - a.gzipBytes); + const missing = budgeted.filter((name) => !measured.has(name)); + if (missing.length > 0) { + return { + ...base, + missing, + status: 'error', + message: + `Budgeted chunk${missing.length === 1 ? '' : 's'} ${missing.map((n) => `\`${n}\``).join(', ')} ` + + `${missing.length === 1 ? 'is' : 'are'} ABSENT from the eager closure reported at ` + + `${reportPath}. That is a FAILURE, not a pass: a ceiling whose chunk does not exist ` + + `weighs nothing and would be green forever.\n` + + `Either the chunk left the eager closure — good news that must be RE-PINNED here, not ` + + `inferred — or an \`advancedChunks\` group in apps/console/vite.config.ts was renamed ` + + `or removed and PER_CHUNK_GZIP_CEILINGS still names the old spelling.\n` + + `The ${present.length} chunks the report DOES carry, largest first:\n` + + present.map((c) => ` ${kb(c.gzipBytes).padStart(9)} KB ${c.name}`).join('\n'), + }; + } + + const chunks = budgeted + .map((name) => { + const entry = /** @type {{ name: string, gzipBytes: number, fileNames: string[] }} */ ( + measured.get(name) + ); + const ceilingBytes = ceilings[name]; + return { + name, + gzipBytes: entry.gzipBytes, + ceilingBytes, + headroomBytes: ceilingBytes - entry.gzipBytes, + fileNames: entry.fileNames, + }; + }) + .sort((a, b) => b.gzipBytes - a.gzipBytes); + + const over = chunks.filter((c) => c.gzipBytes > c.ceilingBytes); + // Measured sizes belong in the verdict either way — a reader should see + // headroom shrinking, not just the tick that precedes a red gate. + const table = chunks + .map( + (c) => + ` ${c.gzipBytes > c.ceilingBytes ? '❌' : '✅'} ${c.name.padEnd(20)} ` + + `${kb(c.gzipBytes).padStart(9)} KB / ${kb(c.ceilingBytes)} KB ceiling ` + + `(${c.headroomBytes >= 0 ? `headroom ${kb(c.headroomBytes)}` : `OVER by ${kb(-c.headroomBytes)}`} KB)` + + `${c.fileNames.length > 1 ? ` [${c.fileNames.length} chunks]` : ''}`, + ) + .join('\n'); + + if (over.length > 0) { + return { + ...base, + chunks, + over: over.map((c) => c.name), + status: 'fail', + message: + `${over.length} eager chunk${over.length === 1 ? ' is' : 's are'} over ` + + `${over.length === 1 ? 'its' : 'their'} per-chunk budget:\n${table}\n` + + `These bytes are inside the aggregate ceiling's headroom, which is exactly why this ` + + `check exists (objectui#5490): one chunk growing while others shrink is invisible to a ` + + `single total.\n` + + `If the growth is intended, raise that chunk's entry in PER_CHUNK_GZIP_CEILINGS in ` + + `scripts/check-eager-closure-budget.mjs deliberately, move PER_CHUNK_BASELINE with it, ` + + `and say in the PR what the bytes buy — do not widen it just to get a green check.`, + }; + } + + return { + ...base, + chunks, + status: 'pass', + message: `Per-chunk eager budgets (${chunks.length} chunks weighed):\n${table}`, + }; +} + /** * The biggest eager chunks, so a failure names suspects instead of a total. * @param {{ files?: { fileName: string, gzipBytes: number }[] }} report @@ -303,8 +596,18 @@ function writeGithubOutput(entries, outputPath = process.env.GITHUB_OUTPUT) { } /** - * Exit codes: `0` within budget, `1` over budget, `2` no trustworthy - * measurement (report missing, stale-shaped, or internally inconsistent). + * Exit codes: `0` within budget, `1` over budget — the aggregate ceiling or any + * per-chunk ceiling — and `2` no trustworthy measurement (report missing, + * stale-shaped, internally inconsistent, or missing a budgeted chunk). + * + * `2` covers the unbuilt tree, and deliberately so: with no + * `apps/console/dist/eager-closure.json` this check reports a BROKEN GAUGE and + * the workflow fails the step. It never prints a verdict about a bundle nobody + * weighed, and it never exits 0 having measured nothing. + * + * Both halves are evaluated and printed before either decides the code: a run + * that reports the total and hides which chunk moved (or the reverse) teaches + * readers to ignore the half they cannot see. */ export function main(argv = process.argv.slice(2)) { const flagIndex = argv.indexOf('--report'); @@ -312,12 +615,18 @@ export function main(argv = process.argv.slice(2)) { const resolved = path.resolve(reportPath); const report = readReport(resolved); const result = evaluateClosureBudget({ report, reportPath }); + const perChunk = evaluatePerChunkBudgets({ report, reportPath }); if (result.status === 'pass') { console.log(`✅ ${result.message}`); } else { console.error(`❌ ${result.message}`); } + if (perChunk.status === 'pass') { + console.log(`✅ ${perChunk.message}`); + } else { + console.error(`❌ ${perChunk.message}`); + } if (report?.files?.length) { console.log(''); console.log('Largest eagerly loaded chunks (gzipped):'); @@ -329,6 +638,7 @@ export function main(argv = process.argv.slice(2)) { closure_gzip_kb: result.gzipBytes === null ? '' : kb(result.gzipBytes), closure_budget_kb: kb(result.budgetBytes), closure_chunks: result.chunkCount === null ? '' : String(result.chunkCount), + closure_chunk_status: perChunk.status, }); // Distinct codes so the workflow can tell "over budget" (a real verdict about @@ -336,8 +646,13 @@ export function main(argv = process.argv.slice(2)) { // 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; + // + // `error` outranks `fail` across BOTH halves for the same reason it does + // within one: a report that cannot be trusted makes its own size verdict + // meaningless, whichever half noticed first. + const statuses = [result.status, perChunk.status]; + if (statuses.includes('error')) return 2; + return statuses.includes('fail') ? 1 : 0; } if (isEntrypoint(import.meta.url)) { From 32f162a77ab3ac9e98d1a6c3815c47760222fa4d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 01:39:33 +0000 Subject: [PATCH 2/2] style(scripts): silence an unused-binding warning in the budget test Part of #5490 --- scripts/__tests__/check-eager-closure-budget.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/__tests__/check-eager-closure-budget.test.ts b/scripts/__tests__/check-eager-closure-budget.test.ts index e972e94029..da6ccf8126 100644 --- a/scripts/__tests__/check-eager-closure-budget.test.ts +++ b/scripts/__tests__/check-eager-closure-budget.test.ts @@ -342,7 +342,7 @@ describe('per-chunk ceilings', () => { it('errors when the report carries no chunk names (a build from before v2)', () => { const base = budgetedReport(); - const files = base.files.map(({ name, ...rest }) => rest); + const files = base.files.map(({ name: _name, ...rest }) => rest); expect(validateReport(report({ ...base, files })).join(' ')).toContain('no chunk `name`'); const result = evaluatePerChunkBudgets({ report: report({ ...base, files }) }); expect(result.status).toBe('error');