Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 63 additions & 30 deletions scripts/size/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,43 +20,66 @@ 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).
uses them to tell noise from growth.

Every cap records the **minified size measured when it was set**:
`capMinified` beside an inline `limit` in `scenarios.js`, and
`{ "cap", "minified" }` entries in `floor-caps.json`. Per scenario:

| brotli vs cap | head minified | verdict |
| ------------- | ---------------------------------------------------- | ----------------------- |
| at or under | anything | **pass** |
| over | ≤ recorded minified + `MINIFIED_ALLOWANCE` (20 B) | **pass with a warning** |
| over | more | **fail** |
| over | no recorded minified: grew ≤ 20 B over the PR's base | pass with a warning |
| over | no recorded minified: grew more, or no base | **fail** |

The allowance is measured against the recorded size, not per PR, so growth
cannot creep across PRs: on an over-cap scenario, two successive +15 B PRs
do not both pass — the second is +30 B over the recorded size. The warning
(job summary, PR size comment, annotation) states the headroom left:
_over brotli cap by N B; minified M B vs R B recorded with the cap (+D B) —
H B of the 20 B minified allowance left; +P B minified over this PR's base_.
A failure carries the same numbers, so a PR whose own change is small can
see that earlier growth used the allowance up.

The last two rows are the fail-safe for a cap without a recorded minified (a
new scenario that did not record one): the gate compares with the PR's base
(`pull_request.base.sha`, the first parent of the merge commit CI measures
as the head; on a push to `next`, the commit before the push), measured in
the same run with the head's harness, and the summary says so. 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.
the scenario's cap **and its recorded minified** in the same PR, measured
together by CI, with a dated reason in its ledger. A new scenario records
both. Raising a frozen floor's cap or its recorded minified (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.
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. A lowered cap records the minified measured with it (one
measurement; if that minified is higher, brotli still shrank, and the
ratchet lists it). A cap it does not lower only ever has its recorded
minified **lowered**, or recorded for the first time; it is never raised —
that loosens the gate like a cap raise, so it takes a PR (and, on a floor,
a `Size-Exception:`). The ratchet rewrites `scenarios.js` and
`floor-caps.json`, adds a dated ledger line above each cap it changes, and
prints the inline caps and the **frozen floors** as separate tables, so a
floor change is seen as one. Scenarios still over their cap, or above their
recorded minified, are listed and left alone: the ratchet does not raise,
so a scenario that landed over its cap on noise stays over it, held to its
recorded minified + 20 B, until code shrinks or a PR re-bases it.
`--minified-only` leaves every cap alone and only records or lowers
minified sizes.

Run it once per RC, on `next`, from CI's numbers — local and CI artifacts
differ by tens of brotli bytes:
Expand Down Expand Up @@ -124,8 +147,9 @@ 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`. The ratchet may lower a
frozen floor like any cap.
`documentation/plans/size-reduction-audit.md`. Each floor's recorded
minified is frozen the same way (raising it, or removing it, needs the
exception). The ratchet may lower a frozen floor like any cap.

## Attribution

Expand Down Expand Up @@ -176,3 +200,12 @@ breaks.
`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.
- **2026-10-06 — recorded minified (#3822).** Measured per PR, the
allowance let an over-cap scenario creep: each +15 B PR passed against its
own base. Every cap now records the minified size measured when it was
set, and the allowance is measured against that. The warning states the
headroom left instead of promising a re-base (the lower-only ratchet
cannot re-base an over-cap scenario). Seeded by the ratchet's
`--minified-only` mode from CI's measurement of `next` @ fff1615ee (Size
run 37443080193); every cap unchanged. The base comparison remains the fail-safe
for a cap without a recorded minified.
17 changes: 12 additions & 5 deletions scripts/size/check-floor-caps.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@
// Compares floor-caps.json at HEAD with the same file at <base-ref>. Exits
// non-zero if any cap increased, unless SIZE_EXCEPTION (the PR body, in CI)
// contains a line starting with "Size-Exception:" that names the reason.
// A cap absent at the base (a new floor scenario) is allowed.
// A cap absent at the base (a new floor scenario) is allowed. The minified
// size recorded with each cap (`{ cap, minified }` entries; the gate
// measures minified growth against it) is frozen the same way: raising it
// loosens the gate exactly as raising the cap does. A recorded minified
// absent at the base (an entry still in the old string form) is allowed.

import { readFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
Expand Down Expand Up @@ -54,12 +58,15 @@ try {
}

const exception = /^\s*Size-Exception:\s*\S/m.test(process.env.SIZE_EXCEPTION ?? "");
const entry = e => (typeof e === "string" ? { cap: e } : e);
let raised = [];
for (const [name, cap] of Object.entries(head)) {
for (const [name, value] of Object.entries(head)) {
if (!(name in baseCaps)) continue;
const before = toBytes(baseCaps[name]);
const after = toBytes(cap);
if (after > before) raised.push(` ${name}: ${baseCaps[name]} -> ${cap}`);
const was = entry(baseCaps[name]);
const now = entry(value);
if (toBytes(now.cap) > toBytes(was.cap)) raised.push(` ${name}: ${was.cap} -> ${now.cap}`);
if (typeof was.minified === "number" && !(now.minified <= was.minified))
raised.push(` ${name}: recorded minified ${was.minified} B -> ${now.minified ?? "removed"}`);
}

if (raised.length === 0) {
Expand Down
35 changes: 28 additions & 7 deletions scripts/size/floor-caps.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,30 @@
{
"signals: core floor (createSignal/Memo/Effect/Root/flush)": "7.35 KB",
"app: render + one signal (the simple-app floor)": "9.86 KB",
"app: hydrating (no stores) with Show/For/Loading/Errored/lazy": "17.71 KB",
"page: base server components (hydrating + dynamic + frames + sf reference)": "44.84 KB",
"page: live server components (base + live/GET + action + isPending/latest)": "48.51 KB",
"server: floor (getRequestEvent + isServer)": "1.34 KB",
"server: renderToString (the server-render floor)": "20.42 KB"
"signals: core floor (createSignal/Memo/Effect/Root/flush)": {
"cap": "7.35 KB",
"minified": 20133
},
"app: render + one signal (the simple-app floor)": {
"cap": "9.86 KB",
"minified": 27687
},
"app: hydrating (no stores) with Show/For/Loading/Errored/lazy": {
"cap": "17.71 KB",
"minified": 52567
},
"page: base server components (hydrating + dynamic + frames + sf reference)": {
"cap": "44.84 KB",
"minified": 145599
},
"page: live server components (base + live/GET + action + isPending/latest)": {
"cap": "48.51 KB",
"minified": 157562
},
"server: floor (getRequestEvent + isServer)": {
"cap": "1.34 KB",
"minified": 3324
},
"server: renderToString (the server-render floor)": {
"cap": "20.42 KB",
"minified": 71813
}
}
96 changes: 70 additions & 26 deletions scripts/size/gate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,19 @@
//
// 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.
// fail: brotli's layout moves ±50–90 B on minified changes of a few bytes.
// Each cap records the minified size measured when it was set
// (`capMinified`), and a scenario fails only when it is over its brotli cap
// AND its minified size exceeds that recorded size by more than
// MINIFIED_ALLOWANCE (or the scenario's `minifiedAllowance`). Over the cap
// within the allowance passes with a warning stating the headroom left.
// Measuring against the recorded size, not the PR's base, bounds growth
// across PRs: two +15 B PRs on an over-cap scenario cannot both pass.
//
// Fail-safe: a cap with no recorded minified falls back to the minified
// growth over the PR's base (the PR's base commit, or on a push to next the
// commit before it). Without that either (a new scenario, a base the head's
// harness could not measure, a local run) the cap is absolute.
//
// Usage: node gate.mjs <head.json> [base.json]
// Exits non-zero when any scenario fails. In GitHub Actions each warning
Expand All @@ -23,43 +28,76 @@ import { fileURLToPath } from "node:url";
// counts as real. Minified is the deterministic unit; brotli is not.
export const MINIFIED_ALLOWANCE = 20;

const signed = d => `${d >= 0 ? "+" : "−"}${Math.abs(d)} B`;
const bytes = n => `${n.toLocaleString("en-US")} B`;
const REAL_GROWTH =
"real growth: reduce it, or raise the cap and its recorded minified in this PR with a reason (frozen floors need `Size-Exception:`)";

/**
* One scenario's verdict.
* @returns {{ name: string, verdict: "pass" | "warn" | "fail", overBy?: number,
* minDelta?: number, allowance?: number, message: string }}
* One scenario's verdict. `against` says what the minified size was judged
* against: the minified recorded with the cap, or (fail-safe) the base.
* @returns {{ name: string, verdict: "pass" | "warn" | "fail",
* against?: "recorded" | "base" | "none", overBy?: number, minDelta?: number,
* baseDelta?: number, headroom?: 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")
const hasBase = base && !base.error && typeof base.minified === "number";
const baseDelta = hasBase ? head.minified - base.minified : undefined;
const thisPR = hasBase ? `; ${signed(baseDelta)} minified over this PR's base` : "";

if (typeof head.capMinified === "number") {
const minDelta = head.minified - head.capMinified;
const headroom = allowance - minDelta;
const vs = `minified ${bytes(head.minified)} vs ${bytes(head.capMinified)} recorded with the cap (${signed(minDelta)})`;
const common = { name, against: "recorded", overBy, minDelta, baseDelta, headroom, allowance };
if (headroom >= 0)
return {
...common,
verdict: "warn",
message: `over brotli cap by ${overBy} B; ${vs} — ${headroom} B of the ${allowance} B minified allowance left${thisPR}`
};
return {
...common,
verdict: "fail",
message: `over brotli cap by ${overBy} B; ${vs} — ${-headroom} B past the ${allowance} B minified allowance${thisPR}. ${REAL_GROWTH}`
};
}

if (!hasBase)
return {
name,
verdict: "fail",
against: "none",
overBy,
allowance,
message: `over brotli cap by ${overBy} B, no base measurement to compare minified growth against`
message: `over brotli cap by ${overBy} B; no minified recorded with the cap and no base measurement to compare against`
};
const minDelta = head.minified - base.minified;
const signed = `${minDelta >= 0 ? "+" : "−"}${Math.abs(minDelta)} B`;
if (minDelta <= allowance)
const headroom = allowance - baseDelta;
const common = {
name,
against: "base",
overBy,
minDelta: baseDelta,
baseDelta,
headroom,
allowance
};
const vs = `no minified recorded with the cap, so judged against this PR's base: minified ${signed(baseDelta)}`;
if (headroom >= 0)
return {
name,
...common,
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`
message: `over brotli cap by ${overBy} B; ${vs} — ${headroom} B of the ${allowance} B minified allowance left`
};
return {
name,
...common,
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:\`)`
message: `over brotli cap by ${overBy} B; ${vs} — ${-headroom} B past the ${allowance} B minified allowance. ${REAL_GROWTH}`
};
}

Expand Down Expand Up @@ -101,6 +139,12 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
`::${v.verdict === "warn" ? "warning" : "error"} title=${prop(`size: ${v.name}`)}::${data(v.message)}`
);
}
const fallback = verdicts.filter(v => v.against === "base" || v.against === "none");
if (fallback.length)
console.log(
`\ngate: ${fallback.length} over-cap scenario(s) have no minified recorded with the cap; ` +
"judged against the base instead (fail-safe)."
);
const failed = verdicts.some(v => v.verdict === "fail");
const warned = verdicts.filter(v => v.verdict === "warn").length;
console.log(
Expand Down
Loading
Loading