From b651365b7bccf31b9f10bfd1de9a58a02243d15c Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 5 Oct 2026 23:13:59 -0700 Subject: [PATCH] chore(size): gate on brotli cap + minified growth over base; add lower-only ratchet A scenario now fails only when it is over its brotli cap AND grew more than MINIFIED_ALLOWANCE (20 B) minified over the PR's base; over the cap within the allowance passes with a layout-noise warning. The base measurement moves from the informational compare job (which ran after check) into a parallel base job that check waits on; pushes to next compare against the previous commit. ratchet.mjs re-bases caps to measured + 10 B, lower only, floors listed apart. Co-authored-by: Claude via Cursor --- .github/workflows/size.yml | 118 +++++++++++++++++++------------ scripts/size/README.md | 97 ++++++++++++++++++++++++-- scripts/size/gate.mjs | 112 ++++++++++++++++++++++++++++++ scripts/size/gate.test.mjs | 65 +++++++++++++++++ scripts/size/package.json | 3 + scripts/size/ratchet.mjs | 139 +++++++++++++++++++++++++++++++++++++ scripts/size/report.mjs | 49 ++++++++++--- scripts/size/scenarios.js | 7 ++ scripts/size/size.mjs | 15 +++- 9 files changed, 541 insertions(+), 64 deletions(-) create mode 100644 scripts/size/gate.mjs create mode 100644 scripts/size/gate.test.mjs create mode 100644 scripts/size/ratchet.mjs diff --git a/.github/workflows/size.yml b/.github/workflows/size.yml index 96b9fc194..4b8c43cda 100644 --- a/.github/workflows/size.yml +++ b/.github/workflows/size.yml @@ -9,12 +9,17 @@ name: Size # ships); the retained-module-graph assertions in # packages/signals/tests/treeshake.test.ts stay the which-module diagnostic # underneath. -# The check job also runs on direct pushes to next: it is self-contained -# (absolute limits, no base ref), and PR-only triggering let the -# +createStore scenario sit failing at a release commit unnoticed — direct -# pushes never measured. The compare job stays PR-only; it needs a PR -# context (base branch, comment target). The floor-cap freeze step in check -# is PR-only for the same reason (it diffs against the base branch). +# +# Three jobs. `head` measures the commit under test; `base` (PRs only) +# measures the PR's base with the head's harness; `check` — the required +# status — decides with both (scripts/size/gate.mjs): a scenario fails only +# when it is over its brotli cap AND grew past the minified allowance over +# the base. The decision needs the base's minified numbers, so the base +# measurement moved from the old informational `compare` job (which ran after +# `check`) into a job `check` waits on; `head` and `base` run in parallel. +# Pushes to next are gated too, against the commit before the push — +# PR-only triggering let the +createStore scenario sit failing at a release +# commit unnoticed. on: push: branches: [next] @@ -26,9 +31,7 @@ permissions: pull-requests: write jobs: - # The hard gate: self-contained on the PR head, fails when any scenario - # exceeds its limit. Never depends on the base branch. - check: + head: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -48,46 +51,33 @@ jobs: - run: pnpm build - run: npm ci --no-audit --no-fund working-directory: scripts/size - # size.mjs gates every scenario against its cap and prints the lazy - # chunks and per-package minified bytes for each, so a bump is - # attributed in the same log that reports it. The JSON feeds the - # compare job's PR comment. - - run: npm run size -- --json size-head.json + # size.mjs prints the lazy chunks and per-package minified bytes for + # each scenario, so a bump is attributed in the same log that reports + # it. --no-gate: the decision is check's. Uploaded on pushes too — a + # push to next's size-head is what `npm run ratchet -- --from` reads. + - run: npm run size -- --json size-head.json --no-gate working-directory: scripts/size - - if: always() && github.event_name == 'pull_request' - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v4 with: name: size-head path: scripts/size/size-head.json - # The floor and page caps (scripts/size/floor-caps.json) are frozen: a PR - # may lower them, never raise them, unless its body carries a - # `Size-Exception:` line. Compared against the PR's base branch, so - # PR events only. - - if: github.event_name == 'pull_request' - run: git fetch --no-tags --depth=1 origin "${{ github.base_ref }}" - - if: github.event_name == 'pull_request' - run: npm run check-floor-caps -- "origin/${{ github.base_ref }}" - working-directory: scripts/size - env: - SIZE_EXCEPTION: ${{ github.event.pull_request.body }} - # Best-effort base-vs-head delta comment. Builds the base branch and - # measures its artifacts with the HEAD's harness (SIZE_PACKAGES_ROOT), so - # both columns come from the same scenarios and the same Rolldown; the head - # numbers are the check job's. Informational and never blocks: - # continue-on-error keeps bootstrap PRs (and any hiccup) green. PR events - # only — it needs a base ref and a comment target. - compare: - # always(): the comment is most useful on the PR whose check failed. - if: always() && github.event_name == 'pull_request' - needs: check + # Builds the base and measures its artifacts with the HEAD's harness + # (SIZE_PACKAGES_ROOT), so both sides come from the same scenarios and the + # same Rolldown. On a PR the head is the merge commit with base.sha, so the + # difference is the PR's own change; on a push it is the pushed range + # (`before`), so a merge that passed with a noise warning does not turn + # next red on the absolute cap. continue-on-error: a base the head's + # harness cannot build or measure leaves check on absolute caps instead of + # blocking. + base: runs-on: ubuntu-latest continue-on-error: true steps: - uses: actions/checkout@v4 - uses: actions/checkout@v4 with: - ref: ${{ github.event.pull_request.base.sha }} + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} path: base - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 @@ -106,23 +96,50 @@ jobs: working-directory: base - run: npm ci --no-audit --no-fund working-directory: scripts/size - - run: node size.mjs --json size-base.json || true + - run: node size.mjs --json size-base.json --no-gate working-directory: scripts/size env: SIZE_PACKAGES_ROOT: ${{ github.workspace }}/base + - uses: actions/upload-artifact@v4 + with: + name: size-base + path: scripts/size/size-base.json + + # The hard gate (the required status). always(): it runs when base failed; + # a failed head leaves no size-head artifact and fails here. + check: + if: always() + needs: [head, base] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: ".nvmrc" + - run: npm ci --no-audit --no-fund + working-directory: scripts/size + # gate.mjs's own tests; scripts/size is outside the workspace `pnpm test`. + - run: npm test + working-directory: scripts/size - uses: actions/download-artifact@v4 with: name: size-head path: scripts/size + - continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: size-base + path: scripts/size + # The job summary carries the report on every run. The comment only + # goes on PRs from this repository: a fork PR's token is read-only, so + # createComment answers 403. Rendered before the gate step so a failing + # PR still gets its table. - run: node report.mjs size-head.json size-base.json > size-comment.md working-directory: scripts/size - # The job summary carries the report on every PR. The comment only goes - # on PRs from this repository: a fork PR's token is read-only, so - # createComment answered 403 and the job showed as failed on every - # external PR. - run: cat size-comment.md >> "$GITHUB_STEP_SUMMARY" working-directory: scripts/size - - if: github.event.pull_request.head.repo.full_name == github.repository + - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + continue-on-error: true uses: actions/github-script@v7 with: script: | @@ -135,3 +152,16 @@ jobs: const mine = comments.find(c => c.user.type === "Bot" && c.body.startsWith(marker)); if (mine) await github.rest.issues.updateComment({ owner, repo, comment_id: mine.id, body }); else await github.rest.issues.createComment({ owner, repo, issue_number, body }); + - run: node gate.mjs size-head.json size-base.json + working-directory: scripts/size + # The floor and page caps (scripts/size/floor-caps.json) are frozen: a PR + # may lower them, never raise them, unless its body carries a + # `Size-Exception:` line. Compared against the PR's base branch, so + # PR events only. always(): reported even when the gate step failed. + - if: always() && github.event_name == 'pull_request' + run: git fetch --no-tags --depth=1 origin "${{ github.base_ref }}" + - if: always() && github.event_name == 'pull_request' + run: npm run check-floor-caps -- "origin/${{ github.base_ref }}" + working-directory: scripts/size + env: + SIZE_EXCEPTION: ${{ github.event.pull_request.body }} diff --git a/scripts/size/README.md b/scripts/size/README.md index 5b541651b..a20b2746a 100644 --- a/scripts/size/README.md +++ b/scripts/size/README.md @@ -8,9 +8,67 @@ compiled-template scenarios — a compiled floor and a JSX todo app in CSR and hydrating form — the frames client as a package, two server-component PAGES: base and live, and two server-entry floors: `getRequestEvent`/`isServer` and `renderToString`) with hard brotli limits on the eager entry chunk. CI fails -when a scenario exceeds its limit — that means tree-shaking regressed, or a -deliberate feature landed and the limit should be bumped in the same PR with -a reason. +when a scenario exceeds its limit and grew more than a small minified +allowance over its base (see [The gate](#the-gate)) — that means tree-shaking regressed, or a deliberate +feature landed and the limit should be bumped in the same PR with a reason. + +## The gate + +Caps are **brotli** bytes on the eager entry chunk — brotli is what ships — +set at measured + 10 B rounded up to 0.01 KB. But brotli's layout is not +monotonic in the input: a change of a few minified bytes moves a scenario's +brotli by ±50–90 B, so a brotli-only gate failed PRs for noise and every +"fix" either raised a cap (permanently) or golfed the code until the layout +came out lucky. Minified bytes are deterministic, so the gate (`gate.mjs`) +uses them to tell noise from growth. Per scenario: + +| brotli vs cap | minified vs base | verdict | +| ------------- | ------------------------------------- | ------------------------------ | +| at or under | anything | **pass** | +| over | grew by ≤ `MINIFIED_ALLOWANCE` (20 B) | **pass with a warning** | +| over | grew by more | **fail** | +| over | no base measurement | **fail** (the cap is absolute) | + +"Base" is the PR's base commit (`pull_request.base.sha`, the first parent +of the merge commit CI measures as the head), measured in the same run +with the head's harness; on a push to `next` it is the commit before the +push. The warning — in the job summary, the PR size comment and as an +annotation — reads _over brotli cap by N B, minified +M B — layout noise; +cap will be re-based at the next ratchet_. The allowance is one constant, +`MINIFIED_ALLOWANCE` in `gate.mjs`; a scenario may set its own with +`minifiedAllowance` in `scenarios.js` (none does). + +Real growth is still a decision made in the PR: lower the bytes, or raise +the scenario's cap in the same PR with a dated reason in its ledger. Raising +a frozen floor cap (below) additionally needs a `Size-Exception:` line in +the PR body — the override for growth the maintainer has accepted. + +Locally, `npm run size` is the absolute gate (any scenario over its cap +fails); `node gate.mjs head.json base.json` applies the PR rule to two +`size.mjs --json` files, and `npm test` runs the decision's tests. + +## The ratchet + +Noise that passed with a warning leaves a scenario over its cap; savings +leave caps loose. `npm run ratchet` re-bases every cap on what the tree +measures now — measured + 10 B rounded up to 0.01 KB — and **only ever +lowers** a cap. It rewrites the inline caps in `scenarios.js` and the frozen +floors in `floor-caps.json`, adds a dated ledger line above each lowered +cap, and prints the lowered inline caps and the lowered **frozen floors** as +separate tables, so a floor change is seen as one. A scenario still over its +cap is listed and left alone: the ratchet does not raise. + +Run it once per RC, on `next`, from CI's numbers — local and CI artifacts +differ by tens of brotli bytes: + +```sh +gh run download -n size-head # the Size run of the push to next +node ratchet.mjs --from size-head.json --note "RC.7, next @ abc1234" --dry-run +node ratchet.mjs --from size-head.json --note "RC.7, next @ abc1234" +``` + +Without `--from` it measures this checkout (build it first). Land the +result as its own PR. ## Bundler @@ -62,10 +120,12 @@ the two server-entry floors have their caps in `floor-caps.json`, not in **frozen**: a PR may lower them, never raise them. `check-floor-caps.mjs` diffs the file against the PR's base branch in CI and fails on a raise unless the PR body contains a line starting with `Size-Exception:` naming why the -maintainer accepted the cost. Ten weeks of individually justified 10–300 B +maintainer accepted the cost (read from the PR body when the run starts — +edit the body, then re-run). Ten weeks of individually justified 10–300 B bumps took the signals floor from 7.1 to 9.9 KB; the freeze makes the next one a decision, not a paragraph. See -`documentation/plans/size-reduction-audit.md`. +`documentation/plans/size-reduction-audit.md`. The ratchet may lower a +frozen floor like any cap. ## Attribution @@ -75,7 +135,18 @@ the lazy chunks and the minified bytes each package contributed (`signals`, attributed in the log that reports it. Minified bytes are the attributable unit; brotli compresses across module boundaries. `node attribute.mjs [name] [--min bytes]` lists the individual dist modules of matching scenarios. -`node size.mjs --json out.json` writes the results for the PR comment. +`node size.mjs --json out.json` writes the results for the gate and the PR +comment. + +## CI + +`.github/workflows/size.yml` runs three jobs. `head` builds the commit under +test and measures it; `base` builds the base (above) and measures it with +the head's harness (`SIZE_PACKAGES_ROOT`); the two run in parallel. `check` +— the required status — waits for both, renders the summary and the PR +comment (`report.mjs`), decides (`gate.mjs`) and checks the floor freeze +(`check-floor-caps.mjs`). A base that cannot be built or measured leaves the +caps absolute rather than blocking. ## Layout @@ -91,3 +162,17 @@ first). `SIZE_PACKAGES_ROOT=` measures another checkout's built The retained-module-graph test in `packages/signals/tests/treeshake.test.ts` is the companion diagnostic that names the re-coupled module when shaking breaks. + +## Ledger + +- **2026-10-05 — minified allowance and ratchet.** The gate stopped failing + on brotli alone: over the cap with ≤ 20 B minified growth over the base + passes with a warning; caps stay brotli and are re-based downward per RC + by `npm run ratchet`. Prompted by one day of noise: #3807 +63 B minified + went +36 then +97 B brotli after `next` moved (a +61 B variant measured + +3 / −54); #3814 +10 B minified went +50 / +56 B; #3811 +20 B minified + went +55 / +89 B; #3817 reordered a condition for −20 B minified and + moved one scenario −90 B brotli. The base measurement moved out of the informational + `compare` job (which ran after `check`) into a parallel `base` job that + `check` waits on, and pushes to `next` now compare against the previous + commit instead of the absolute cap. No cap changed. diff --git a/scripts/size/gate.mjs b/scripts/size/gate.mjs new file mode 100644 index 000000000..61bf5cc7e --- /dev/null +++ b/scripts/size/gate.mjs @@ -0,0 +1,112 @@ +// The size gate's decision: head (and, on a PR, base) results from size.mjs +// --json in, one verdict per scenario out. +// +// Caps are brotli bytes, and brotli is what ships, so a scenario under its +// cap passes whatever its minified size did. Over the cap is not enough to +// fail: brotli's layout moves ±50–90 B on minified changes of a few bytes, so +// a PR fails a scenario only when it is over its brotli cap AND its minified +// growth over the PR's base exceeds MINIFIED_ALLOWANCE (or the scenario's +// `minifiedAllowance`). Over the cap within the allowance passes with a +// warning — layout noise, left for the next ratchet (ratchet.mjs) to re-base. +// The base is the PR's base commit, or on a push to next the commit before +// it. Without a base measurement (a new scenario, a base the head's harness +// could not measure, a local run) the cap is absolute, as it always was. +// +// Usage: node gate.mjs [base.json] +// Exits non-zero when any scenario fails. In GitHub Actions each warning +// and failure is also emitted as an annotation. + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +// Minified bytes a PR may add to an over-cap scenario before brotli growth +// counts as real. Minified is the deterministic unit; brotli is not. +export const MINIFIED_ALLOWANCE = 20; + +/** + * One scenario's verdict. + * @returns {{ name: string, verdict: "pass" | "warn" | "fail", overBy?: number, + * minDelta?: number, allowance?: number, message: string }} + */ +export function decide(head, base) { + const { name } = head; + if (head.error) return { name, verdict: "fail", message: `failed to bundle: ${head.error}` }; + if (head.size <= head.limit) return { name, verdict: "pass", message: "within its cap" }; + const overBy = head.size - head.limit; + const allowance = head.minifiedAllowance ?? MINIFIED_ALLOWANCE; + if (!base || base.error || typeof base.minified !== "number") + return { + name, + verdict: "fail", + overBy, + allowance, + message: `over brotli cap by ${overBy} B, no base measurement to compare minified growth against` + }; + const minDelta = head.minified - base.minified; + const signed = `${minDelta >= 0 ? "+" : "−"}${Math.abs(minDelta)} B`; + if (minDelta <= allowance) + return { + name, + verdict: "warn", + overBy, + minDelta, + allowance, + message: `over brotli cap by ${overBy} B, minified ${signed} — layout noise; cap will be re-based at the next ratchet` + }; + return { + name, + verdict: "fail", + overBy, + minDelta, + allowance, + message: `over brotli cap by ${overBy} B, minified ${signed} (allowance ${allowance} B) — real growth: reduce it, or raise the cap in this PR with a reason (frozen floors need \`Size-Exception:\`)` + }; +} + +/** Verdicts for every head scenario, each matched to its base by name. */ +export function decideAll(head, base = []) { + return head.map(h => + decide( + h, + base.find(b => b.name === h.name) + ) + ); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const [headFile, baseFile] = process.argv.slice(2); + if (!headFile) { + console.error("usage: node gate.mjs [base.json]"); + process.exit(2); + } + const head = JSON.parse(readFileSync(headFile, "utf8")); + let base = []; + if (baseFile) { + try { + base = JSON.parse(readFileSync(baseFile, "utf8")); + } catch { + console.log(`gate: no base measurement at ${baseFile}; every cap is absolute.`); + } + } + const verdicts = decideAll(head, base); + const annotate = !!process.env.GITHUB_ACTIONS; + // Workflow-command escaping: scenario names carry `,` and `:`. + const data = s => s.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A"); + const prop = s => data(s).replace(/:/g, "%3A").replace(/,/g, "%2C"); + for (const v of verdicts) { + const tag = v.verdict === "pass" ? "ok " : v.verdict === "warn" ? "WARN" : "FAIL"; + console.log(`${tag} ${v.name}${v.verdict === "pass" ? "" : `\n ${v.message}`}`); + if (annotate && v.verdict !== "pass") + console.log( + `::${v.verdict === "warn" ? "warning" : "error"} title=${prop(`size: ${v.name}`)}::${data(v.message)}` + ); + } + const failed = verdicts.some(v => v.verdict === "fail"); + const warned = verdicts.filter(v => v.verdict === "warn").length; + console.log( + failed + ? "\nsize: a scenario grew past its cap (or failed to bundle)." + : `\nsize: every scenario passes${warned ? ` (${warned} over its brotli cap within the minified allowance)` : ""}.` + ); + process.exit(failed ? 1 : 0); +} diff --git a/scripts/size/gate.test.mjs b/scripts/size/gate.test.mjs new file mode 100644 index 000000000..301bd88b0 --- /dev/null +++ b/scripts/size/gate.test.mjs @@ -0,0 +1,65 @@ +// node --test gate.test.mjs — the gate's decision, without bundling anything. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { decide, decideAll, MINIFIED_ALLOWANCE } from "./gate.mjs"; + +const head = (size, minified, extra = {}) => ({ name: "s", size, minified, limit: 1000, ...extra }); +const base = minified => ({ name: "s", size: 990, minified }); + +test("under the cap passes whatever minified did", () => { + assert.equal(decide(head(1000, 5000), base(4000)).verdict, "pass"); + assert.equal(decide(head(990, 5000)).verdict, "pass"); +}); + +test("over the cap with minified growth within the allowance warns", () => { + const v = decide(head(1050, 4000 + MINIFIED_ALLOWANCE), base(4000)); + assert.equal(v.verdict, "warn"); + assert.equal(v.overBy, 50); + assert.equal(v.minDelta, MINIFIED_ALLOWANCE); + assert.equal( + v.message, + `over brotli cap by 50 B, minified +${MINIFIED_ALLOWANCE} B — layout noise; cap will be re-based at the next ratchet` + ); +}); + +test("over the cap after shrinking minified warns", () => { + const v = decide(head(1003, 3946), base(4000)); + assert.equal(v.verdict, "warn"); + assert.match(v.message, /minified −54 B/); +}); + +test("over the cap with minified growth past the allowance fails", () => { + const v = decide(head(1001, 4000 + MINIFIED_ALLOWANCE + 1), base(4000)); + assert.equal(v.verdict, "fail"); + assert.equal(v.minDelta, MINIFIED_ALLOWANCE + 1); +}); + +test("a scenario's minifiedAllowance overrides the default", () => { + assert.equal(decide(head(1001, 4030, { minifiedAllowance: 40 }), base(4000)).verdict, "warn"); + assert.equal(decide(head(1001, 4010, { minifiedAllowance: 0 }), base(4000)).verdict, "fail"); +}); + +test("without a usable base the cap is absolute", () => { + assert.equal(decide(head(1001, 4000)).verdict, "fail"); + assert.equal(decide(head(1001, 4000), { name: "s", error: "boom" }).verdict, "fail"); + assert.equal(decide(head(1000, 4000)).verdict, "pass"); +}); + +test("a scenario that failed to bundle fails", () => { + assert.equal(decide({ name: "s", limit: 1000, error: "boom" }, base(4000)).verdict, "fail"); +}); + +test("decideAll matches base by name and tolerates a missing base", () => { + const verdicts = decideAll( + [ + { name: "a", size: 1050, minified: 4010, limit: 1000 }, + { name: "b", size: 1050, minified: 4010, limit: 1000 } + ], + [{ name: "a", size: 1000, minified: 4000 }] + ); + assert.deepEqual( + verdicts.map(v => v.verdict), + ["warn", "fail"] + ); + assert.equal(decideAll([{ name: "a", size: 900, minified: 1, limit: 1000 }]).length, 1); +}); diff --git a/scripts/size/package.json b/scripts/size/package.json index eff9a0c15..6997aba6a 100644 --- a/scripts/size/package.json +++ b/scripts/size/package.json @@ -6,6 +6,9 @@ "size": "node size.mjs", "attribute": "node attribute.mjs", "check-floor-caps": "node check-floor-caps.mjs", + "gate": "node gate.mjs", + "ratchet": "node ratchet.mjs", + "test": "node --test gate.test.mjs", "build": "cd ../.. && pnpm install --frozen-lockfile && pnpm build" }, "devDependencies": { diff --git a/scripts/size/ratchet.mjs b/scripts/size/ratchet.mjs new file mode 100644 index 000000000..9f0c20494 --- /dev/null +++ b/scripts/size/ratchet.mjs @@ -0,0 +1,139 @@ +// The ratchet: re-bases every cap on what the tree measures now, LOWER ONLY. +// A scenario's new cap is its brotli size + 10 B rounded up to 0.01 KB (the +// convention every cap in scenarios.js was set by); a cap is rewritten only +// when that is below the current cap. Raising stays a decision made in a PR. +// Run per RC, on the release candidate's next, from CI's measurement +// (`--from`): local and CI artifacts differ by tens of brotli bytes. +// +// Inline caps are rewritten in scenarios.js with a dated ledger line above +// each; the frozen floors in floor-caps.json are rewritten too, with their +// ledger line on the scenario in scenarios.js, and are listed in their own +// table so a floor change is seen as one. Scenarios still over their cap +// after re-measuring are listed and left alone: the ratchet does not raise. +// +// Usage: node ratchet.mjs [--from ] [--note ] [--dry-run] +// --from ratchet from a size.mjs --json measurement (CI's +// size-head artifact of a push to next) instead of +// measuring this checkout +// --note recorded in each ledger line (e.g. "RC.7, next @ abc1234") +// --dry-run print the tables, write nothing + +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { bundle, here, scenarios, toBytes } from "./bundle.mjs"; + +const HEADROOM = 10; + +/** Measured brotli + headroom, rounded up to the next 10 B (0.01 KB). */ +const capFor = br => Math.ceil((br + HEADROOM) / 10) * 10; +const formatCap = bytes => `${(bytes / 1000).toFixed(2)} KB`; + +const args = process.argv.slice(2); +const flag = name => { + const i = args.indexOf(name); + return i >= 0 ? args[i + 1] : undefined; +}; +const fromFile = flag("--from"); +const note = flag("--note"); +const dryRun = args.includes("--dry-run"); + +let measured; +if (fromFile) { + measured = JSON.parse(readFileSync(fromFile, "utf8")); +} else { + measured = []; + for (const scenario of scenarios) { + try { + const r = await bundle(scenario); + measured.push({ name: r.name, size: r.br, minified: r.min }); + } catch (err) { + measured.push({ name: scenario.name, error: String(err?.message ?? err) }); + } + } +} + +const floorFile = join(here, "floor-caps.json"); +const scenariosFile = join(here, "scenarios.js"); +const floorCaps = JSON.parse(readFileSync(floorFile, "utf8")); +let source = readFileSync(scenariosFile, "utf8"); + +const errors = measured.filter(m => m.error); +if (errors.length) { + console.error( + `ratchet: ${errors.map(m => m.name).join(", ")} failed to bundle; nothing written.` + ); + process.exit(1); +} + +const date = new Date().toLocaleDateString("en-CA"); +const origin = fromFile ? "CI-measured" : `measured locally (${process.platform})`; +const lowered = { inline: [], floor: [] }; +const stillOver = []; +for (const scenario of scenarios) { + const m = measured.find(x => x.name === scenario.name); + if (!m) { + console.error(`ratchet: no measurement for "${scenario.name}"; nothing written.`); + process.exit(1); + } + const cap = toBytes(scenario.limit); + const next = capFor(m.size); + if (m.size > cap) + stillOver.push({ name: scenario.name, cap, size: m.size, minified: m.minified }); + if (next >= cap) continue; + const floor = scenario.name in floorCaps; + const row = { + name: scenario.name, + from: formatCap(cap), + to: formatCap(next), + size: m.size, + minified: m.minified + }; + (floor ? lowered.floor : lowered.inline).push(row); + + // The scenario's block runs from its `name:` to the next scenario's. + const start = source.indexOf(`name: ${JSON.stringify(scenario.name)},`); + if (start < 0) throw new Error(`ratchet: "${scenario.name}" not found in scenarios.js`); + const end = (i => (i < 0 ? source.length : i))(source.indexOf("\n {\n name:", start)); + const block = source.slice(start, end); + const limitText = floor ? "limit: floorCaps[" : `limit: ${JSON.stringify(scenario.limit)},`; + const at = block.indexOf(limitText); + if (at < 0) throw new Error(`ratchet: no \`${limitText}\` line for "${scenario.name}"`); + const lineStart = block.lastIndexOf("\n", at) + 1; + const indent = block.slice(lineStart, at); + const ledger = + `${indent}// Ratchet (${date}${note ? `, ${note}` : ""}): ${row.from} -> ${row.to}, ${origin} at\n` + + `${indent}// ${m.size.toLocaleString("en-US")} B (${m.minified.toLocaleString("en-US")} B minified). ` + + `Lower only: measured + ${HEADROOM} B rounded up to 0.01 KB.\n`; + const rest = floor + ? block.slice(lineStart) + : block.slice(lineStart).replace(limitText, `limit: ${JSON.stringify(row.to)},`); + if (floor) floorCaps[scenario.name] = row.to; + source = source.slice(0, start) + block.slice(0, lineStart) + ledger + rest + source.slice(end); +} + +const table = rows => + ["| scenario | cap | measured | new cap |", "| --- | ---: | ---: | ---: |"] + .concat(rows.map(r => `| ${r.name} | ${r.from} | ${r.size} B | ${r.to} |`)) + .join("\n"); + +console.log(`ratchet: ${origin}${note ? `, ${note}` : ""}${dryRun ? " (dry run)" : ""}\n`); +console.log(`Inline caps lowered (scenarios.js): ${lowered.inline.length}`); +if (lowered.inline.length) console.log(table(lowered.inline)); +console.log(`\nFROZEN FLOOR caps lowered (floor-caps.json): ${lowered.floor.length}`); +if (lowered.floor.length) console.log(table(lowered.floor)); +if (stillOver.length) { + console.log( + `\nStill over their cap (not raised — the ratchet only lowers): ${stillOver.length}\n` + + stillOver + .map(s => ` ${s.name}: ${s.size} B > ${formatCap(s.cap)} (+${s.size - s.cap} B)`) + .join("\n") + ); +} + +if (!dryRun && (lowered.inline.length || lowered.floor.length)) { + writeFileSync(scenariosFile, source); + writeFileSync(floorFile, JSON.stringify(floorCaps, null, 2) + "\n"); + console.log("\nratchet: scenarios.js and floor-caps.json rewritten."); +} else if (!dryRun) { + console.log("\nratchet: no cap to lower."); +} diff --git a/scripts/size/report.mjs b/scripts/size/report.mjs index 078d75f7e..fdc879942 100644 --- a/scripts/size/report.mjs +++ b/scripts/size/report.mjs @@ -1,37 +1,64 @@ // Renders the PR size comment: head vs base per scenario, from two size.mjs -// --json files. Base entries may be missing (a new scenario, or a base -// checkout the head's scenarios cannot bundle); those rows show "—". +// --json files, with gate.mjs's verdict for each. Base entries may be missing +// (a new scenario, or a base checkout the head's scenarios cannot bundle); +// those rows show "—" and their cap is absolute. // // Usage: node report.mjs [base.json] > comment.md import { readFileSync } from "node:fs"; import { toKB } from "./bundle.mjs"; +import { decideAll, MINIFIED_ALLOWANCE } from "./gate.mjs"; const [headFile, baseFile] = process.argv.slice(2); const head = JSON.parse(readFileSync(headFile, "utf8")); -const base = baseFile ? JSON.parse(readFileSync(baseFile, "utf8")) : []; +let base = []; +try { + if (baseFile) base = JSON.parse(readFileSync(baseFile, "utf8")); +} catch {} +const verdicts = decideAll(head, base); +const signed = d => `${d > 0 ? "+" : d < 0 ? "−" : ""}${Math.abs(d)} B`; const delta = (h, b) => { const d = h - b; if (d === 0) return "0 B"; - return `${d > 0 ? "+" : "−"}${Math.abs(d)} B (${d > 0 ? "+" : "−"}${((Math.abs(d) / b) * 100).toFixed(1)}%)`; + return `${signed(d)} (${d > 0 ? "+" : "−"}${((Math.abs(d) / b) * 100).toFixed(1)}%)`; }; -const rows = head.map(h => { +const rows = head.map((h, i) => { const b = base.find(x => x.name === h.name); + const v = verdicts[i]; const size = h.error ? "error" : toKB(h.size); - const change = h.error || !b || b.error ? "—" : delta(h.size, b.size); + const comparable = !h.error && b && !b.error; + const change = comparable ? delta(h.size, b.size) : "—"; + const minChange = comparable ? signed(h.minified - b.minified) : "—"; const cap = toKB(h.limit); - const status = h.error ? "❌ error" : h.passed ? "✅" : `❌ over by ${h.size - h.limit} B`; + const status = h.error + ? "❌ error" + : v.verdict === "pass" + ? "✅" + : v.verdict === "warn" + ? `⚠️ over by ${v.overBy} B, noise` + : `❌ over by ${v.overBy} B`; const lazy = h.lazy?.length ? h.lazy.map(c => `${c.name} ${toKB(c.br)}`).join(", ") : ""; - return `| ${h.name} | ${size} | ${change} | ${cap} | ${status} | ${lazy} |`; + return `| ${h.name} | ${size} | ${change} | ${minChange} | ${cap} | ${status} | ${lazy} |`; }); console.log(""); console.log("## Size (brotli, eager entry chunk)\n"); -console.log("| scenario | head | vs base | cap | | lazy chunks (not counted) |"); -console.log("| --- | ---: | ---: | ---: | --- | --- |"); +console.log("| scenario | head | vs base | minified vs base | cap | | lazy chunks (not counted) |"); +console.log("| --- | ---: | ---: | ---: | ---: | --- | --- |"); console.log(rows.join("\n")); + +const warned = verdicts.filter(v => v.verdict === "warn"); +const failed = verdicts.filter(v => v.verdict === "fail"); +if (warned.length) { + console.log("\n### ⚠️ Over the brotli cap within the minified allowance (passes)\n"); + for (const v of warned) console.log(`- **${v.name}**: ${v.message}`); +} +if (failed.length) { + console.log("\n### ❌ Fails the gate\n"); + for (const v of failed) console.log(`- **${v.name}**: ${v.message}`); +} console.log( - "\nBundled with Rolldown (what Vite ships), brotli q11, decimal KB. Caps in `scripts/size/scenarios.js`; the floor and page caps in `floor-caps.json` are frozen (lower only, or `Size-Exception:` in the PR body)." + `\nBundled with Rolldown (what Vite ships), brotli q11, decimal KB. A scenario fails only when it is over its brotli cap **and** grew more than ${MINIFIED_ALLOWANCE} B minified over the base; over the cap within that allowance is brotli layout noise and passes with a warning. Caps in \`scripts/size/scenarios.js\`; the floor and page caps in \`floor-caps.json\` are frozen (lower only, or \`Size-Exception:\` in the PR body). Caps are re-based downward by \`npm run ratchet\` (scripts/size/README.md).` ); diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index b715917a2..45fab4d25 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -11,6 +11,13 @@ // tree-shaking regressed (or a deliberate feature landed — bump the limit in // the same PR and say why). // +// Minified allowance (2026-10-05): a PR fails a scenario only when it is over +// its brotli limit AND grew more than gate.mjs's MINIFIED_ALLOWANCE (20 B) +// minified over its base; over the limit within the allowance is brotli +// layout noise and passes with a warning. An optional `minifiedAllowance` +// overrides it per scenario. Limits are lowered (never raised) per RC by +// ratchet.mjs, which writes a dated `Ratchet` line above each one it moves. +// // Bundler switch (2026-09-26): the harness measured with esbuild through // size-limit until this date; it now measures with Rolldown, the bundler Vite // ships, pinned exactly in package.json. Every cap was re-based on that day diff --git a/scripts/size/size.mjs b/scripts/size/size.mjs index abb93ef45..eb21957e8 100644 --- a/scripts/size/size.mjs +++ b/scripts/size/size.mjs @@ -4,13 +4,19 @@ // page would fetch later (reported, never counted), and the per-package // minified split so a bump is attributed in the log that reports it. // -// Usage: node size.mjs [--json ] [scenario-substring ...] -// --json also write the results (for the PR compare comment) +// Usage: node size.mjs [--json ] [--no-gate] [scenario-substring ...] +// --json also write the results (for gate.mjs and the PR comment) +// --no-gate measure and report, exit 0; CI's check job makes the +// decision with gate.mjs, which also has the base numbers +// +// Run on its own this is the absolute gate: any scenario over its brotli cap +// fails. The PR gate (gate.mjs) also weighs minified growth over the base. import { writeFileSync } from "node:fs"; import { bundle, packageOf, scenarios, toBytes, toKB } from "./bundle.mjs"; const args = process.argv.slice(2); +const gate = !args.includes("--no-gate"); const jsonAt = args.indexOf("--json"); const jsonFile = jsonAt >= 0 ? args[jsonAt + 1] : null; // Without `--json`, jsonAt is -1 and `i !== jsonAt + 1` would drop the first @@ -59,6 +65,9 @@ for (const scenario of scenarios) { minified: r.min, limit: cap, passed: !over, + ...(scenario.minifiedAllowance !== undefined && { + minifiedAllowance: scenario.minifiedAllowance + }), lazy: r.lazy, packages: Object.fromEntries(packages) }); @@ -80,4 +89,4 @@ console.log( ? "\nsize: a scenario exceeds its cap or failed to bundle." : "\nsize: every scenario within its cap." ); -process.exit(failed ? 1 : 0); +process.exit(failed && gate ? 1 : 0);