Skip to content

test(scripts): add per-chunk gzipped ceilings to the eager-closure budget - #6210

Merged
yinlianghui-tw merged 2 commits into
mainfrom
claude/issue-5490-per-chunk-budgets
Aug 25, 2026
Merged

test(scripts): add per-chunk gzipped ceilings to the eager-closure budget#6210
yinlianghui-tw merged 2 commits into
mainfrom
claude/issue-5490-per-chunk-budgets

Conversation

@yinlianghui-tw

@yinlianghui-twyinlianghui-tw commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Fixes#5490

Per-chunk gzipped ceilings on top of the aggregate eager-closure ceiling, delivering option C of the #5468 ruling (option A stays as shipped in PR #5466; option B — a comparison against main — is rejected and is not reintroduced here: nothing in this change reads main, builds twice, or compares against another ref).

Sequencing caveat: which state I found

The ruling's caveat says to set the lines after the cleave work if it has landed. It has not landed. Both cards are closed, and neither changed chunking:

cardclosed bywhat actually landed
#5325PR #6007a checked ledger pinning the 43 ineffective dynamic imports — no imports moved
#5359PR #5925a docs correction to the eager-graph attribution above the /meta fold

advancedChunks in apps/console/vite.config.ts is untouched by both, so the measurement below is against a pre-cleave closure by fact, not by choice. It is measured on the current tip of main (2c8474c04), which is the only honest option available today; when a cleave does land, these lines must be re-measured with it.

Re-measured, and the card's figures were stale

Every number was re-measured with vite build on apps/console, read out of the gauge's own report. Three consecutive builds produced byte-identical totals (3,298,620 gzipped, 52/508 chunks), so these are measurements and not build noise.

chunkcard figure (2026-08-21)measured nowceiling setheadroom
vendor-objectstack1,493 KB926.2 KB (948,461 B)967,000 B (944.3 KB)18.1 KB (2.0%)
framework484 KB480.9 KB (492,399 B)502,000 B (490.2 KB)9.4 KB (2.0%)
ui-components380 KB381.9 KB (391,095 B)399,000 B (389.6 KB)7.7 KB (2.0%)

vendor-objectstack has more than halved since the card was written; the closure as a whole is 3,298,620 B against the 4,005,911 B baseline frozen in the file. Truthful current state plus ~2%, and no ceiling is set below what was measured — this is a ratchet, not an optimisation target.

Each headroom is far narrower than REGRESSION_THIS_GATE_MUST_CATCH_BYTES (89 KiB), which is the property that makes the lines worth having: a repeat of the #5266 incident cannot fit inside any of them. A unit test asserts exactly that, per budgeted chunk.

The part that decides whether the gate is worth anything

Chunk names come from the gauge's own measurement. emitEagerClosureReport now publishes each eager chunk's rolldown chunk.name as files[].name (report v2); the checker looks names up in that report. It does not re-derive a name by stripping the hash-and-extension suffix off a file name, and it carries no list of chunks it merely expects to exist.

A budgeted name that is absent from the report is an error (exit 2), never a skip — a ceiling with no subject weighs nothing and is green forever. The failure prints the missing name, says a vanished chunk must be re-pinned deliberately rather than inferred, and lists every chunk the report does carry, largest first, so a rename is diagnosable from the failure alone.

Three further vacuity holes are closed the same way: a report with zero named chunks is an error; an empty ceiling map is an error ("a disabled check, not a passing one"); and a member carrying no name fails report validation, so a build from before v2 is refused as version drift rather than read as "all budgeted chunks are missing".

The mapping is pinned twice — once at runtime against the report, and once in a unit test asserting each budgeted name is a real advancedChunks group in apps/console/vite.config.ts, so a rename reds without needing a build.

Controls, each predicted before running

Positive control 1 — a chunk grows. The #5266 incident replayed byte-for-byte: +89 KiB onto vendor-objectstack in a report kept internally consistent. Predicted exit 1, aggregate green, per-chunk red naming the chunk and both numbers.

EXIT=1
✅ Console eager closure is 3310.3 KB gzipped across 52 of 508 chunks (budget: 3990.2 KB, headroom: 679.9 KB).
❌ 1 eager chunk is over its per-chunk budget:
❌ vendor-objectstack 1015.2 KB / 944.3 KB ceiling (OVER by 70.9 KB)
✅ framework 480.9 KB / 490.2 KB ceiling (headroom 9.4 KB)
✅ ui-components 381.9 KB / 389.6 KB ceiling (headroom 7.7 KB)

The aggregate half is green in that run with 679.9 KB to spare. That contrast is the whole reason this half exists.

Positive control 2 — a ceiling lowered below measured (framework: 502_000450_000, anchored counts 1→0 and 0→1 on disk). Predicted red naming framework: observed exit 1, 480.9 KB / 439.5 KB ceiling (OVER by 41.4 KB). Predicted at least the ceiling-vs-baseline unit test to red as well; observed 7 failures, all of them the same cause (the baseline now exceeds the lowered ceiling) — a wider blast radius than predicted, same direction. Restored with git checkout HEAD -- scripts/check-eager-closure-budget.mjs; anchored counts back to 1/0 and git diff HEAD empty (0 bytes).

Negative control — a budgeted chunk renamed in the report (the vendor-objectstack name value replaced with vendor-objectstack-core, anchored counts 1→0 and 0→1). Predicted exit 2 naming the absent chunk, not a pass:

EXIT=2
✅ Console eager closure is 3221.3 KB gzipped across 52 of 508 chunks (budget: 3990.2 KB, headroom: 768.9 KB).
❌ Budgeted chunk `vendor-objectstack` is ABSENT from the eager closure reported at …
That is a FAILURE, not a pass: a ceiling whose chunk does not exist weighs nothing and would be green forever.
… The 52 chunks the report DOES carry, largest first:
926.2 KB vendor-objectstack-core

What happens on an unbuilt tree

It fails loudly, and it always did — this change keeps that and extends it to the new half. With no apps/console/dist/eager-closure.json the checker exits 2, both halves saying so in their own words:

❌ No eager-closure report at … This is a broken gauge, not a passing budget.
❌ No eager-closure report at …, so no chunk was weighed. Per-chunk ceilings measure nothing
without a build — this is a broken gauge, not 3 budgets that all passed.

.github/workflows/performance-budget.yml maps exit 2 to budget_status=error and fails the step, and it builds the packages and the console before running the checker. The pnpm check:eager-closure alias is the only other caller, and run against an unbuilt tree it produces the output above rather than a green tick. No path through this gate exits 0 having measured nothing.

Verification — all at 32f162a77 (the final commit)

commandresult
npx vite build (apps/console)exit 0 — 52/508 chunks, 3,298,620 B gzipped, identical across three builds
node scripts/check-eager-closure-budget.mjsexit 0 — both verdicts green, sizes and headroom printed
npx vitest run scripts/__tests__ --maxWorkers=2 (root vitest)exit 0 — 73 files, 2019 tests passed
npx tsc --noEmit -p tsconfig.scripts.jsonexit 0 (both changed script files confirmed in the program via --listFiles)
npx tsc -b apps/console/tsconfig.node.json --forceexit 0 — the program that owns vite.config.ts
pnpm lint:rootexit 0 — full root scan, 28 pre-existing warnings, 0 errors
npx eslint on the three changed filesexit 0, no problems
pnpm check:control-bytes / check:phantom-deps / check:esm-specifiersexit 0

Exit codes captured by redirect before any pipe. The whole scripts/__tests__ directory was run rather than one file because eight tests in it read apps/console/vite.config.ts as their subject.

Scope

Chunking is unchanged, the aggregate ceiling and its baseline are unchanged, and no cleave was attempted. Two of the existing main tests had to move: their fixtures did not carry the budgeted chunk names, and a report missing those is now an error — which is the new half working, not a fixture detail.

One thing worth flagging rather than fixing here: the per-chunk headroom assertion compares two frozen constants, the same shape #5924 records for the aggregate one. The per-chunk band is ~2% rather than ~24%, so the exposure is much smaller, but the systemic fix (deriving the headroom check from the report the gate just read) belongs to that card, which is also where the aggregate ceiling's now-very-wide live headroom is already recorded. Nothing here lowers or raises that ceiling.


Generated by Claude Code

…dget
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
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3221.3 KB3990.2 KB
Main entry chunk (gzip)153.7 KB350 KB
Entry fileindex-C7xifsvv.js
StatusPASS

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.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.38KB3.90KB
app-shell (runtime-config.js)18.10KB6.51KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)505.15KB114.53KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)168.48KB46.47KB
fields (index.js)238.40KB59.89KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.66KB18.32KB
plugin-chatbot (index.js)188.21KB44.67KB
plugin-dashboard (index.js)133.35KB34.45KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)244.14KB61.94KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)126.07KB30.78KB
plugin-gantt (index.js)164.15KB39.88KB
plugin-grid (index.js)201.05KB54.38KB
plugin-kanban (index.js)52.89KB14.59KB
plugin-list (index.js)111.86KB27.22KB
plugin-map (index.js)20.11KB6.64KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.49KB7.59KB
plugin-tree (index.js)9.26KB3.13KB
plugin-view (index.js)84.57KB20.74KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)54.84KB18.43KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.35KB0.70KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)7.54KB2.63KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.49KB2.14KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

@yinlianghui-twClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM review — verified against the repository, not against the report

Reviewed at head 32f162a77 (4 files, +690/−25). Every load-bearing claim was re-derived here rather than read out of the report body.

Checked independently

The three budgeted names are real advancedChunks groups. Read off origin/main:apps/console/vite.config.ts directly — vendor-objectstack (line 700), framework (709), ui-components (710), all three at the spellings PER_CHUNK_GZIP_CEILINGS keys on. So the static half of the mapping pin has a real subject today, and the it.each that asserts { name: '<n>', appears in that file reds the moment a group is renamed.

Both constraints hold arithmetically, per chunk. Ceiling above measured (a ratchet, not an aspiration) and headroom under the 89 KiB the gate exists to catch:

chunkmeasuredceilingheadroom< 91,136 B
vendor-objectstack948,461967,00018,539
framework492,399502,0009,601
ui-components391,095399,0007,905

These are checked in the test as well as satisfied in the constant, which is the right split — the assertion is what survives the next edit.

Exit 2 reaches CI as a failure..github/workflows/performance-budget.yml:160-164 on main maps CLOSURE_CODE -eq 2 to budget_status=error and exit 1, and :166 maps any other non-zero to fail. Not in this diff, and it did not need to be — the new error verdicts inherit a path that already existed. The main() change that makes error outrank fail across both halves is what connects them.

The v1→v2 bump has no other consumer. Grepped every file on main mentioning eager-closure or reportVersion: registerStudioComponents.tsx, DraftChangesPanel.tsx, vite-ineffective-dynamic-imports.ts and its test all mention it in prose only. The one machine caller besides the workflow is check:eager-closure in package.json, which is this script. So bumping the emitter and the checker in one commit is complete — nothing else reads the shape.

Bundle Analysis is green on this head. That is the end-to-end reading that the unit tests cannot give: the v2 emitter wrote a report and the v2 checker weighed it, in CI, on a real build.

What makes this worth landing

Control [A] is the argument, and it is a single run rather than a claim: +89 KiB onto vendor-objectstack, aggregate green with 679.9 KB of headroom, per-chunk red naming the chunk and both numbers. That is #5266 replayed, and before this change that run exits 0. Everything else in the PR is scaffolding around keeping that reading honest.

The vacuity discipline is the other half and it is unusually complete — four separate ways the per-chunk half could measure nothing (chunk absent, zero named chunks, empty ceiling map, unnamed member) and all four are error, none a skip. The absent-chunk failure listing every name the report does carry, largest first, is what makes a rename diagnosable without a second build.

Two judgements I'd have made the same way:

Control [B] coming back with seven failures against a predicted one is reported as wider-than-predicted rather than smoothed over, and the seven share one cause. Restore proven by anchored counts and an empty git diff HEAD, not by an editor's exit code.

The stale-card correction is material and was surfaced rather than buried: vendor-objectstack measures 926.2 KB, not the card's 1,493 KB. Ceilings set against the measurement, with the sequencing caveat resolved by establishing that neither #5325 nor #5359 moved an import — checked, not assumed.

⛔ Not arming yet

Type Check and all four Test shards are still in_progress. in_progress is not green. I will arm — draft:false plus auto-merge (SQUASH) — when every check carries a conclusion, and not before.


Generated by Claude Code

@yinlianghui-tw
yinlianghui-tw marked this pull request as ready for review August 25, 2026 01:59
@yinlianghui-tw
yinlianghui-tw added this pull request to the merge queueAug 25, 2026
@yinlianghui-twClaude

Copy link
Copy Markdown
CollaboratorAuthor

Armed — all 25 checks concluded, ready + auto-merge (SQUASH)

Re-read the full check set on 32f162a77: 25 of 25 carry a conclusion, all success or skipped, nothing in_progress or queued. Type Check, Lint and all four Test shards — the six that were still running at review time — came back green (Test (shard 2/4) last, at 01:58:25Z).

draft: false and auto-merge SQUASH set in the same turn.

⭐ Worth recording alongside the arm: Bundle Analysis is green on this head, which is the only reading that exercises this change end to end — the v2 emitter wrote a report with files[].name, and the v2 checker weighed both halves against it, on a real apps/console build in CI. The unit tests pin the logic; that job is what proves the emitter and the checker actually agree in the environment they will run in. A version bump split across two files is exactly the change that can pass every unit test and still refuse every real report.


Generated by Claude Code

Merged via the queue into main with commit 48e5381Aug 25, 2026
26 checks passed
@yinlianghui-tw
yinlianghui-tw deleted the claude/issue-5490-per-chunk-budgets branch August 25, 2026 02:11
yinlianghui-tw pushed a commit that referenced this pull request Aug 25, 2026
…t, and re-baseline the ceiling
The gate stated a binding constraint on its own sensitivity — the headroom
above the measured payload must stay SMALLER than the 89 KiB regression the
gate exists to catch — and then checked it between two constants frozen in the
same module. That assertion is an arithmetic fact about the file, true whatever
the console weighs. The closure shrank 706,013 gzipped bytes below the pinned
baseline without the ceiling following it down; the live headroom reached 8.6x
the regression size, and the check meant to notice stayed green.
`evaluateHeadroomSensitivity` derives the headroom from the report the gate
just read — for the aggregate ceiling and each of the three per-chunk ceilings
added by #6210 — and calls a ceiling more than one regression above its own
measurement an ERROR (exit 2, a verdict about the gauge), never a size failure.
`error` still outranks `fail`, now across three halves.
The aggregate ceiling is re-baselined downward as the decision this records:
MAX_EAGER_CLOSURE_GZIP_BYTES 4,086,000 -> 3,345,000 over a BASELINE moving
4,005,911 (4c1623c) -> 3,299,898 (48e5381). Headroom 8.63x -> 0.49x the
regression size. Lowering a ceiling toward reality is a tightening; the floor
is unchanged — never below a measured figure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019b5UBNMtTzKbVtZZGvFuxe
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add per-chunk budgets on top of the aggregate eager-closure ceiling — vendor-objectstack (38%) first — ruled follow-up of #5468

2 participants

@yinlianghui-tw@os-trump