From e58fb9dd02af1607f42ecd1337d3944ed1f3d03d Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:08:56 +0900 Subject: [PATCH 01/52] chore: budget each bundle entry by what it adds to the measured core --- scripts/bundle-size.ts | 46 +++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/scripts/bundle-size.ts b/scripts/bundle-size.ts index 58b0c4b..2687f44 100644 --- a/scripts/bundle-size.ts +++ b/scripts/bundle-size.ts @@ -33,20 +33,15 @@ const modes = ["production", "development"] as const; * The declarations are not measured here. They cost a download and a parse, never a byte in the * user's bundle, and `type-perf` already measures what reads them. * - * `traitGzip` and `enumGzip` are the first budget plus what each adds, so the pair says the line - * rather than a second round number: what an experimental import costs is the number to read. + * `val` caps the core. The others cap what each entry adds to the measured core, so growth in the + * core spends only the core's room: what an import costs is the number to read. */ -const BUDGET_VAL = 1024; -const BUDGET_VAL_PLUS_EQUALS = BUDGET_VAL + 128; -const BUDGET_VAL_PLUS_TRAIT = BUDGET_VAL + 256; -const BUDGET_VAL_PLUS_ENUM = BUDGET_VAL + 384; -const BUDGET_ALL = BUDGET_VAL + 640; const budget = { - val: BUDGET_VAL, - equals: BUDGET_VAL_PLUS_EQUALS, - traitGzip: BUDGET_VAL_PLUS_TRAIT, - enumGzip: BUDGET_VAL_PLUS_ENUM, - all: BUDGET_ALL, + val: 1024, + equals: 256, + trait: 256, + enum: 640, + all: 1024, }; type Sizes = { minified: number; gzip: number; brotli: number }; @@ -159,25 +154,30 @@ const measured = { all: bundles.all, }; +const core = measured.val.production.gzip; +const added = (name: Exclude) => + measured[name].production.gzip - core; + +// The sign marks a budget on what an entry adds to the core. const checks = [ - ["production gzip", measured.val.production.gzip, budget.val], - ["production gzip, with Equals", measured.equals.production.gzip, budget.equals], - ["production gzip, with Trait", measured.trait.production.gzip, budget.traitGzip], - ["production gzip, with Enum", measured.enum.production.gzip, budget.enumGzip], - ["production gzip, all", measured.all.production.gzip, budget.all], + ["production gzip", core, budget.val, ""], + ["added by Equals", added("equals"), budget.equals, "+"], + ["added by Trait", added("trait"), budget.trait, "+"], + ["added by Enum", added("enum"), budget.enum, "+"], + ["added by all", added("all"), budget.all, "+"], ] as const; function budgets(): string { const label = Math.max(...checks.map(([name]) => name.length)); - const used = Math.max(...checks.map(([, size]) => format(size).length)); - const limit = Math.max(...checks.map(([, , max]) => format(max).length)); + const used = Math.max(...checks.map(([, size, , sign]) => `${sign}${format(size)}`.length)); + const limit = Math.max(...checks.map(([, , max, sign]) => `${sign}${format(max)}`.length)); return checks - .map(([name, size, max]) => { + .map(([name, size, max, sign]) => { const left = size > max ? `${format(size - max)} over` : `${format(max - size)} left (${Math.round((1 - size / max) * 100)}%)`; - return ` ${name.padEnd(label)} ${format(size).padStart(used)} / ${format(max).padStart(limit)} ${left}`; + return ` ${name.padEnd(label)} ${`${sign}${format(size)}`.padStart(used)} / ${`${sign}${format(max)}`.padStart(limit)} ${left}`; }) .join("\n"); } @@ -198,8 +198,8 @@ const over = checks.filter(([, bytes, max]) => bytes > max); if (over.length > 0) { console.error(); - for (const [name, bytes, max] of over) { - console.error(`over budget: ${name} is ${format(bytes)}, budget ${format(max)}`); + for (const [name, bytes, max, sign] of over) { + console.error(`over budget: ${name} is ${sign}${format(bytes)}, budget ${sign}${format(max)}`); } process.exit(1); } From f65a5c00e2c12d7ac71370ebbaaf5a3b69b9b967 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:08:57 +0900 Subject: [PATCH 02/52] docs(notes): record why bundle increments are measured from the core --- notes/design.md | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/notes/design.md b/notes/design.md index 161fa79..bafb419 100644 --- a/notes/design.md +++ b/notes/design.md @@ -39,7 +39,7 @@ import { Val } from "valof"; - **15.2 `Enum`** §7.4 の見直し。Variant をレコードに宣言して union を導出、ブランドの導出、タグ名のカスタムと `tag-mismatch`、companion に置く `match`、ts-pattern との線引き、型を確かめた記録(共通フィールド、`match` の型引数、`VariantOf` の表示、却下した戻り値の型引数・自由関数の `match`・Val のレコード、Trait の実装、タグ名を `Tag<…>` で渡すこと、トップレベルの条件型が宣言出力を壊すこと)、実装して分かったこと(Fault の置き場所、Variant 1 個の禁止、`then` を 3 箇所で落とす、宣言出力の CI、variance 測定と型コスト)、Variant ごとの seal と union の `seal`(入口を 2 つに分ける、builder を callback で渡す、`impl` で鎖を閉じる、却下した値の形)、`seal` の引数を排他的にしたこと、タグだけで分ける `[Tag].match` と `[Tag].key`(キーを文字列にする理由、却下した名前)、`implVariant` を Variant ごとの鎖にしたこと、steps を枠そのものにしたこと、タグ名の渡し方を変える 4 案の却下、companion を渡さない形(val.ts の門を共有する、`PayloadKeys` の分配、入口 2 つぶんのテスト) - **15.3 `path`** seal をまたぐ patch の合成。`abort` を合成側に置く判断、ハンドラが最終段である理由(HKT)、`glue` の `open` / `close`、`each` / `where`、却下した `deepPatch` - **15.4 `.impl` のコールバック形** 自分の companion を参照すると推論が回らない(TS7022)。contextual typing がコールバック越しでも効くことの実測、`implTrait` も callback、予算、却下したメンバのカリー化と、下流の `.impl` を型で塞ぐ案。**鎖は 2026-09-18 に閉じた**(1 回だけ、兄弟は注釈で呼ぶ、Trait の実装の受け手を `Tr` に、却下した型レベルの案内と lint 規則) -- **§16 予算の責務** バンドルと型を別のスクリプトに割る。宣言のバイト数を type-perf へ、予算を 64 kB に上げた理由 +- **§16 予算の責務** バンドルと型を別のスクリプトに割る。宣言のバイト数を type-perf へ、予算を 64 kB に上げた理由、16.1 バンドルの増分を実測の core から測る - **§17 Effect v4 調査** 落ちた typeclass と HKT の層(§15.3 の根拠)、値の中に等価性を置く形、クラスの構造比較に開く穴、向こうにあってこちらに無いもの --- @@ -6532,6 +6532,34 @@ type-perf に移せる理由でもある。 `index.d.mts` は entry を分けた後も同じ読み方をする。entry ファイル自体は再 export 2 行で 323 B しかなく、 中身は共有チャンクにあるので、type-perf は import を辿って合算する。 +### 16.1 増分は実測の core から測る、2026-10-15 + +**`1024 + 256` の書き方は、core の実測が増えたときの関係を保たなかった。**比べていたのは各エントリの実測と +「core の予算 + 増分」で、各エントリは core の予算の余りを借りて通っていた。 + +| | 実測 | 予算 | core からの増分 | 名目上の増分 | +| ---------- | ------- | ---------- | --------------- | ------------ | +| `Val` | 896 B | 1024 | | | +| + `equals` | 1,083 B | 1024 + 128 | **+187** | +128 | +| + `Trait` | 978 B | 1024 + 256 | +82 | +256 | +| + `Enum` | 1,370 B | 1024 + 384 | **+474** | +384 | +| 全部 | 1,633 B | 1024 + 640 | **+737** | +640 | + +`.implCreate.noseal`(#65)で core が 44 B 増え、全部の余りが 31 B、`Enum` の余りが 38 B になった。core が +1 B 増えれば、全部の余りも 1 B 減る。§16 の「core の予算を動かしたときも関係が保たれる」と合わない。 + +- **core だけを実測と比べる。**ほかは `エントリの実測 - core の実測` を増分の予算と比べる。core の増加は + core の予算でだけ止まる +- **増分を決め直した。**`equals` +256、`Trait` +256(据え置き)、`Enum` +640、全部 +1024。どれも 26% 以上の + 余りがある +- **予算の表は増分を印字する。**`added by Enum +474 B / +640 B` + +**却下: 仕組みはそのままで、数値だけ上げる。**`Enum` を +512、全部を +768 にする案。core が増えるたびに、 +また狭くなる。 + +**却下: `Enum` を +512 にする。**区切りはよいが、余りが 38 B(7%)で、直す前と変わらない。`match` の `_` が +実行時のコードを足すので、その PR で上げ直すことになる。 + --- ## 17. Effect v4 調査、2026-09-10 From 433d162bd9fbfd74827892ef150f17e7a1d1ce0e Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 13:47:06 +0900 Subject: [PATCH 03/52] refactor: write bundle-size with valof --- scripts/bundle-size.ts | 183 +++++++++++++++++++++++++---------------- 1 file changed, 112 insertions(+), 71 deletions(-) diff --git a/scripts/bundle-size.ts b/scripts/bundle-size.ts index 2687f44..5302b74 100644 --- a/scripts/bundle-size.ts +++ b/scripts/bundle-size.ts @@ -6,45 +6,78 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { minifySync, build, type Rolldown } from "vite"; +import { Val } from "../src/index.ts"; +import { Enum, type VariantOf, type VariantsOf } from "../src/experimental.ts"; const root = new URL("../", import.meta.url); -// `core` is the budget everyone pays. The other entries measure what `Trait`, `equals`, or both -// add on top. `Trait` ships from its own subpath, so the entry names the module per import. -const entries = { - core: { "./dist/index.mjs": ["Val"] }, - trait: { "./dist/index.mjs": ["Val"], "./dist/experimental.mjs": ["Trait"] }, - enum: { "./dist/index.mjs": ["Val"], "./dist/experimental.mjs": ["Enum"] }, - equals: { "./dist/index.mjs": ["Val", "equals"] }, - all: { "./dist/index.mjs": ["Val", "equals"], "./dist/experimental.mjs": ["Enum", "Trait"] }, -} as const satisfies Record>>; -type Entry = keyof typeof entries; -type Imports = Readonly>; - -/** What the entry imports, as the printed heading spells it. */ -const imported = (imports: Imports): string[] => Object.values(imports).flat(); +/** The names an entry imports, per module. */ +type Imports = Val<"Imports", Record>; +const Imports = Val.sealer().impl({ + /** What the entry imports, as the printed heading spells it. */ + names: (imports): string[] => Object.values(imports).flat(), +}); -const modes = ["production", "development"] as const; +type Sizes = Val<"Sizes", { minified: number; gzip: number; brotli: number }>; +const Sizes = Val.sealer(); /** - * What a user ships. A ratchet, unlike the budgets in `type-perf`: runtime code is written - * deliberately, so a byte more is a decision, not drift. + * A bundle a user ships, and its budget in production gzip. A ratchet, unlike the budgets in + * `type-perf`: runtime code is written deliberately, so a byte more is a decision, not drift. * * The declarations are not measured here. They cost a download and a parse, never a byte in the * user's bundle, and `type-perf` already measures what reads them. * - * `val` caps the core. The others cap what each entry adds to the measured core, so growth in the - * core spends only the core's room: what an import costs is the number to read. + * `Core` is the budget everyone pays. The others cap what they add to the measured core, so growth + * in the core spends only the core's room: what an import costs is the number to read. */ -const budget = { - val: 1024, - equals: 256, - trait: 256, - enum: 640, - all: 1024, -}; +type Entry = Enum< + "Entry", + { Core: {}; Equals: {}; Trait: {}; Enum: {}; All: {} }, + { imports: Imports; budget: number } +>; +type Name = keyof VariantsOf; +const Entry = Enum.sealer().impl({ + /** Whether the budget caps what the entry adds to the core, rather than its whole size. */ + adds: (entry): boolean => + Entry.match(entry, { + Core: () => false, + Equals: () => true, + Trait: () => true, + Enum: () => true, + All: () => true, + }), + label: (entry): string => (Entry.adds(entry) ? `added by ${entry._tag}` : "production gzip"), + amount: (entry, bytes: number): string => + Entry.adds(entry) ? `+${format(bytes)}` : format(bytes), +}); -type Sizes = { minified: number; gzip: number; brotli: number }; +// `Trait` and `Enum` ship from their own subpath, so the entry names the module per import. +const entries = { + Core: Entry.Core({ imports: Imports({ "./dist/index.mjs": ["Val"] }), budget: 1024 }), + Equals: Entry.Equals({ + imports: Imports({ "./dist/index.mjs": ["Val", "equals"] }), + budget: 256, + }), + Trait: Entry.Trait({ + imports: Imports({ "./dist/index.mjs": ["Val"], "./dist/experimental.mjs": ["Trait"] }), + budget: 256, + }), + Enum: Entry.Enum({ + imports: Imports({ "./dist/index.mjs": ["Val"], "./dist/experimental.mjs": ["Enum"] }), + budget: 640, + }), + All: Entry.All({ + imports: Imports({ + "./dist/index.mjs": ["Val", "equals"], + "./dist/experimental.mjs": ["Enum", "Trait"], + }), + budget: 1024, + }), +} satisfies { [N in Name]: VariantOf }; +const names = Object.keys(entries) as Name[]; + +const modes = ["production", "development"] as const; async function bundle(mode: string, imports: Imports): Promise { const dir = await mkdtemp(join(tmpdir(), "valof-size-")); @@ -53,7 +86,10 @@ async function bundle(mode: string, imports: Imports): Promise { ([module, names]) => `import { ${names.join(", ")} } from ${JSON.stringify(fileURLToPath(new URL(module, root)))};`, ); - await writeFile(file, `${lines.join("\n")}\nconsole.log(${imported(imports).join(", ")});\n`); + await writeFile( + file, + `${lines.join("\n")}\nconsole.log(${Imports.names(imports).join(", ")});\n`, + ); try { const result = (await build({ root: fileURLToPath(root), @@ -77,7 +113,7 @@ async function bundle(mode: string, imports: Imports): Promise { function measure(code: string): Sizes { const bytes = Buffer.from(minifySync("bundle.mjs", code).code, "utf8"); - return { + return Sizes({ minified: bytes.byteLength, gzip: gzipSync(bytes, { level: 9 }).byteLength, brotli: brotliCompressSync(bytes, { @@ -86,24 +122,29 @@ function measure(code: string): Sizes { [constants.BROTLI_PARAM_SIZE_HINT]: bytes.byteLength, }, }).byteLength, - }; + }); } function format(bytes: number): string { return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(2)} kB`; } -function table(rows: [string, Record][]): string { - const heads = Object.keys(rows[0]?.[1] ?? {}); +function table(rows: [string, Sizes][]): string { + const heads = ["minified", "gzip", "brotli"] as const; const label = Math.max(...rows.map(([name]) => name.length)); - const widths = heads.map((head, index) => - Math.max(head.length, ...rows.map(([, cells]) => format(Object.values(cells)[index]!).length)), + const widths = heads.map((head) => + Math.max(head.length, ...rows.map(([, sizes]) => format(sizes[head]).length)), ); - const line = (name: string, cells: string[]) => + const line = (name: string, cells: readonly string[]) => ` ${name.padEnd(label)} ${cells.map((cell, index) => cell.padStart(widths[index]!)).join(" ")}`; return [ line("", heads), - ...rows.map(([name, cells]) => line(name, Object.values(cells).map(format))), + ...rows.map(([name, sizes]) => + line( + name, + heads.map((head) => format(sizes[head])), + ), + ), ].join("\n"); } @@ -115,26 +156,27 @@ await promisify(execFile)(fileURLToPath(new URL("node_modules/.bin/vp", root)), process.exit(1); }); -const names = Object.keys(entries) as Entry[]; const code = Object.fromEntries( await Promise.all( names.map(async (name) => [ name, Object.fromEntries( - await Promise.all(modes.map(async (mode) => [mode, await bundle(mode, entries[name])])), + await Promise.all( + modes.map(async (mode) => [mode, await bundle(mode, entries[name].imports)]), + ), ) as Record<(typeof modes)[number], string>, ]), ), -) as Record>; +) as Record>; const bundles = Object.fromEntries( names.map((name) => [ name, Object.fromEntries(modes.map((mode) => [mode, measure(code[name][mode])])), ]), -) as Record>; +) as Record>; -const production = minifySync("production.mjs", code.core.production).code; +const production = minifySync("production.mjs", code.Core.production).code; if ( production.includes("snapshotSealInput") || production.includes("unsnapshotable seal input") || @@ -146,38 +188,35 @@ if (production.includes("Number.isNaN")) { throw new Error("equals remains in the production bundle that imports only Val"); } +// `replace-bundle-size-claims` reads these keys from `--json`. const measured = { - val: bundles.core, - trait: bundles.trait, - enum: bundles.enum, - equals: bundles.equals, - all: bundles.all, + val: bundles.Core, + trait: bundles.Trait, + enum: bundles.Enum, + equals: bundles.Equals, + all: bundles.All, }; -const core = measured.val.production.gzip; -const added = (name: Exclude) => - measured[name].production.gzip - core; - -// The sign marks a budget on what an entry adds to the core. -const checks = [ - ["production gzip", core, budget.val, ""], - ["added by Equals", added("equals"), budget.equals, "+"], - ["added by Trait", added("trait"), budget.trait, "+"], - ["added by Enum", added("enum"), budget.enum, "+"], - ["added by all", added("all"), budget.all, "+"], -] as const; +/** What the entry's budget caps, in production gzip. */ +const capped = (entry: Entry): number => { + const gzip = bundles[entry._tag].production.gzip; + return Entry.adds(entry) ? gzip - bundles.Core.production.gzip : gzip; +}; function budgets(): string { - const label = Math.max(...checks.map(([name]) => name.length)); - const used = Math.max(...checks.map(([, size, , sign]) => `${sign}${format(size)}`.length)); - const limit = Math.max(...checks.map(([, , max, sign]) => `${sign}${format(max)}`.length)); - return checks - .map(([name, size, max, sign]) => { + const all = Object.values(entries); + const label = Math.max(...all.map((entry) => Entry.label(entry).length)); + const used = Math.max(...all.map((entry) => Entry.amount(entry, capped(entry)).length)); + const limit = Math.max(...all.map((entry) => Entry.amount(entry, entry.budget).length)); + return all + .map((entry) => { + const bytes = capped(entry); + const max = entry.budget; const left = - size > max - ? `${format(size - max)} over` - : `${format(max - size)} left (${Math.round((1 - size / max) * 100)}%)`; - return ` ${name.padEnd(label)} ${`${sign}${format(size)}`.padStart(used)} / ${`${sign}${format(max)}`.padStart(limit)} ${left}`; + bytes > max + ? `${format(bytes - max)} over` + : `${format(max - bytes)} left (${Math.round((1 - bytes / max) * 100)}%)`; + return ` ${Entry.label(entry).padEnd(label)} ${Entry.amount(entry, bytes).padStart(used)} / ${Entry.amount(entry, max).padStart(limit)} ${left}`; }) .join("\n"); } @@ -186,7 +225,7 @@ if (process.argv.includes("--json")) { console.log(JSON.stringify(measured, null, 2)); } else { for (const name of names) { - console.log(`bundle import { ${imported(entries[name]).join(", ")} }`); + console.log(`bundle import { ${Imports.names(entries[name].imports).join(", ")} }`); console.log(table(modes.map((mode) => [mode, bundles[name][mode]]))); console.log(); } @@ -194,12 +233,14 @@ if (process.argv.includes("--json")) { console.log(budgets()); } -const over = checks.filter(([, bytes, max]) => bytes > max); +const over = Object.values(entries).filter((entry) => capped(entry) > entry.budget); if (over.length > 0) { console.error(); - for (const [name, bytes, max, sign] of over) { - console.error(`over budget: ${name} is ${sign}${format(bytes)}, budget ${sign}${format(max)}`); + for (const entry of over) { + console.error( + `over budget: ${Entry.label(entry)} is ${Entry.amount(entry, capped(entry))}, budget ${Entry.amount(entry, entry.budget)}`, + ); } process.exit(1); } From 4e9900daf7360763ed31d74de5cf178f515b30a7 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:49:33 +0900 Subject: [PATCH 04/52] refactor: register each bundle in bundle-size as a strategy, flagged as base or not --- scripts/bundle-size.ts | 282 ++++++++++++++++++++++++++--------------- 1 file changed, 181 insertions(+), 101 deletions(-) diff --git a/scripts/bundle-size.ts b/scripts/bundle-size.ts index 5302b74..bbc20f1 100644 --- a/scripts/bundle-size.ts +++ b/scripts/bundle-size.ts @@ -7,79 +7,142 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { minifySync, build, type Rolldown } from "vite"; import { Val } from "../src/index.ts"; -import { Enum, type VariantOf, type VariantsOf } from "../src/experimental.ts"; +import { Trait, type Dyn, type Self } from "../src/experimental.ts"; const root = new URL("../", import.meta.url); -/** The names an entry imports, per module. */ +type Mode = "production" | "development"; +const modes: readonly Mode[] = ["production", "development"]; + +/** The names a bundle imports, per module. */ type Imports = Val<"Imports", Record>; const Imports = Val.sealer().impl({ - /** What the entry imports, as the printed heading spells it. */ + /** What the bundle imports, as the printed heading spells it. */ names: (imports): string[] => Object.values(imports).flat(), + merge: (imports, other: Imports): Imports => { + const merged: Record = {}; + for (const source of [imports, other]) { + for (const [module, names] of Object.entries(source)) { + merged[module] = [...new Set([...(merged[module] ?? []), ...names])]; + } + } + return Imports(merged); + }, }); type Sizes = Val<"Sizes", { minified: number; gzip: number; brotli: number }>; const Sizes = Val.sealer(); +/** What a bundle imports, and its budget in production gzip. */ +type Spec = { imports: Imports; budget: number }; + /** * A bundle a user ships, and its budget in production gzip. A ratchet, unlike the budgets in * `type-perf`: runtime code is written deliberately, so a byte more is a decision, not drift. * * The declarations are not measured here. They cost a download and a parse, never a byte in the * user's bundle, and `type-perf` already measures what reads them. - * - * `Core` is the budget everyone pays. The others cap what they add to the measured core, so growth - * in the core spends only the core's room: what an import costs is the number to read. */ -type Entry = Enum< - "Entry", - { Core: {}; Equals: {}; Trait: {}; Enum: {}; All: {} }, - { imports: Imports; budget: number } +type Bundle = Trait< + "Bundle", + Spec, + { + /** The key in the `--json` output, which `replace-bundle-size-claims` reads. */ + key: (self: Self) => string; + label: (self: Self) => string; + } >; -type Name = keyof VariantsOf; -const Entry = Enum.sealer().impl({ - /** Whether the budget caps what the entry adds to the core, rather than its whole size. */ - adds: (entry): boolean => - Entry.match(entry, { - Core: () => false, - Equals: () => true, - Trait: () => true, - Enum: () => true, - All: () => true, - }), - label: (entry): string => (Entry.adds(entry) ? `added by ${entry._tag}` : "production gzip"), - amount: (entry, bytes: number): string => - Entry.adds(entry) ? `+${format(bytes)}` : format(bytes), +const Bundle = Trait.companion(); + +type Core = Val<"Core", Spec, Bundle>; +const Core = Val.sealer().implTrait(Bundle, { + key: () => "val", + label: () => "production gzip", +}); + +type WithEquals = Val<"WithEquals", Spec, Bundle>; +const WithEquals = Val.sealer().implTrait(Bundle, { + key: () => "equals", + label: () => "added by Equals", +}); + +type WithTrait = Val<"WithTrait", Spec, Bundle>; +const WithTrait = Val.sealer().implTrait(Bundle, { + key: () => "trait", + label: () => "added by Trait", +}); + +type WithEnum = Val<"WithEnum", Spec, Bundle>; +const WithEnum = Val.sealer().implTrait(Bundle, { + key: () => "enum", + label: () => "added by Enum", +}); + +type All = Val<"All", Spec, Bundle>; +const All = Val.sealer().implTrait(Bundle, { + key: () => "all", + label: () => "added by All", +}); + +type Registered = { bundle: Dyn; base: boolean }; + +/** + * Collects the bundles to measure. The base is every bundle registered with `base` set, bundled + * together. Each of those is capped by its whole size. Every other bundle is measured on top of + * the base and capped by what it adds, so growth in the base spends only the base's room: what an + * import costs is the number to read. + */ +type Registry = { + register: (bundle: Dyn, base: boolean) => Registry; + registered: readonly Registered[]; +}; +const registry = (registered: readonly Registered[] = []): Registry => ({ + register: (bundle, base) => registry([...registered, { bundle, base }]), + registered, }); -// `Trait` and `Enum` ship from their own subpath, so the entry names the module per import. -const entries = { - Core: Entry.Core({ imports: Imports({ "./dist/index.mjs": ["Val"] }), budget: 1024 }), - Equals: Entry.Equals({ - imports: Imports({ "./dist/index.mjs": ["Val", "equals"] }), - budget: 256, - }), - Trait: Entry.Trait({ - imports: Imports({ "./dist/index.mjs": ["Val"], "./dist/experimental.mjs": ["Trait"] }), - budget: 256, - }), - Enum: Entry.Enum({ - imports: Imports({ "./dist/index.mjs": ["Val"], "./dist/experimental.mjs": ["Enum"] }), - budget: 640, - }), - All: Entry.All({ - imports: Imports({ - "./dist/index.mjs": ["Val", "equals"], - "./dist/experimental.mjs": ["Enum", "Trait"], - }), - budget: 1024, - }), -} satisfies { [N in Name]: VariantOf }; -const names = Object.keys(entries) as Name[]; - -const modes = ["production", "development"] as const; - -async function bundle(mode: string, imports: Imports): Promise { +// `Trait` and `Enum` ship from their own subpath, so a bundle names the module per import. +const { registered } = registry() + .register( + Bundle.dyn(Core, Core({ imports: Imports({ "./dist/index.mjs": ["Val"] }), budget: 1024 })), + true, + ) + .register( + Bundle.dyn( + WithEquals, + WithEquals({ imports: Imports({ "./dist/index.mjs": ["equals"] }), budget: 256 }), + ), + false, + ) + .register( + Bundle.dyn( + WithTrait, + WithTrait({ imports: Imports({ "./dist/experimental.mjs": ["Trait"] }), budget: 256 }), + ), + false, + ) + .register( + Bundle.dyn( + WithEnum, + WithEnum({ imports: Imports({ "./dist/experimental.mjs": ["Enum"] }), budget: 640 }), + ), + false, + ) + .register( + Bundle.dyn( + All, + All({ + imports: Imports({ + "./dist/index.mjs": ["equals"], + "./dist/experimental.mjs": ["Enum", "Trait"], + }), + budget: 1024, + }), + ), + false, + ); + +async function compile(mode: Mode, imports: Imports): Promise { const dir = await mkdtemp(join(tmpdir(), "valof-size-")); const file = join(dir, "entry.mjs"); const lines = Object.entries(imports).map( @@ -111,7 +174,7 @@ async function bundle(mode: string, imports: Imports): Promise { } } -function measure(code: string): Sizes { +function sizes(code: string): Sizes { const bytes = Buffer.from(minifySync("bundle.mjs", code).code, "utf8"); return Sizes({ minified: bytes.byteLength, @@ -156,27 +219,42 @@ await promisify(execFile)(fileURLToPath(new URL("node_modules/.bin/vp", root)), process.exit(1); }); -const code = Object.fromEntries( - await Promise.all( - names.map(async (name) => [ - name, - Object.fromEntries( - await Promise.all( - modes.map(async (mode) => [mode, await bundle(mode, entries[name].imports)]), - ), - ) as Record<(typeof modes)[number], string>, - ]), - ), -) as Record>; +type Measured = { + imports: Imports; + code: Record; + sizes: Record; +}; + +async function measure(imports: Imports): Promise { + const [production, development] = await Promise.all([ + compile("production", imports), + compile("development", imports), + ]); + return { + imports, + code: { production, development }, + sizes: { production: sizes(production), development: sizes(development) }, + }; +} + +const bases = registered.filter(({ base }) => base).map(({ bundle }) => bundle.imports); +const [first, ...others] = bases; +if (first === undefined) throw new Error("register at least one bundle as the base"); +const baseImports = others.reduce((merged, imports) => Imports.merge(merged, imports), first); -const bundles = Object.fromEntries( - names.map((name) => [ - name, - Object.fromEntries(modes.map((mode) => [mode, measure(code[name][mode])])), - ]), -) as Record>; +const [base, results] = await Promise.all([ + measure(baseImports), + Promise.all( + registered.map(async (entry) => ({ + ...entry, + ...(await measure( + entry.base ? entry.bundle.imports : Imports.merge(baseImports, entry.bundle.imports), + )), + })), + ), +]); -const production = minifySync("production.mjs", code.Core.production).code; +const production = minifySync("production.mjs", base.code.production).code; if ( production.includes("snapshotSealInput") || production.includes("unsnapshotable seal input") || @@ -188,58 +266,60 @@ if (production.includes("Number.isNaN")) { throw new Error("equals remains in the production bundle that imports only Val"); } -// `replace-bundle-size-claims` reads these keys from `--json`. -const measured = { - val: bundles.Core, - trait: bundles.Trait, - enum: bundles.Enum, - equals: bundles.Equals, - all: bundles.All, -}; +type Result = (typeof results)[number]; -/** What the entry's budget caps, in production gzip. */ -const capped = (entry: Entry): number => { - const gzip = bundles[entry._tag].production.gzip; - return Entry.adds(entry) ? gzip - bundles.Core.production.gzip : gzip; -}; +/** The bytes the budget caps: a base bundle's whole size, or what another adds to the base. */ +const capped = ({ base: isBase, sizes }: Result): number => + isBase ? sizes.production.gzip : sizes.production.gzip - base.sizes.production.gzip; + +const amount = ({ base: isBase }: Result, bytes: number): string => + isBase ? format(bytes) : `+${format(bytes)}`; function budgets(): string { - const all = Object.values(entries); - const label = Math.max(...all.map((entry) => Entry.label(entry).length)); - const used = Math.max(...all.map((entry) => Entry.amount(entry, capped(entry)).length)); - const limit = Math.max(...all.map((entry) => Entry.amount(entry, entry.budget).length)); - return all - .map((entry) => { - const bytes = capped(entry); - const max = entry.budget; + const label = Math.max(...results.map(({ bundle }) => bundle.label().length)); + const used = Math.max(...results.map((result) => amount(result, capped(result)).length)); + const limit = Math.max(...results.map((result) => amount(result, result.bundle.budget).length)); + return results + .map((result) => { + const bytes = capped(result); + const max = result.bundle.budget; const left = bytes > max ? `${format(bytes - max)} over` : `${format(max - bytes)} left (${Math.round((1 - bytes / max) * 100)}%)`; - return ` ${Entry.label(entry).padEnd(label)} ${Entry.amount(entry, bytes).padStart(used)} / ${Entry.amount(entry, max).padStart(limit)} ${left}`; + return ` ${result.bundle.label().padEnd(label)} ${amount(result, bytes).padStart(used)} / ${amount(result, max).padStart(limit)} ${left}`; }) .join("\n"); } +// With one base bundle, the base is that bundle, already printed. +const printed = bases.length > 1 ? [base, ...results] : results; + if (process.argv.includes("--json")) { - console.log(JSON.stringify(measured, null, 2)); + console.log( + JSON.stringify( + Object.fromEntries(results.map(({ bundle, sizes }) => [bundle.key(), sizes])), + null, + 2, + ), + ); } else { - for (const name of names) { - console.log(`bundle import { ${Imports.names(entries[name].imports).join(", ")} }`); - console.log(table(modes.map((mode) => [mode, bundles[name][mode]]))); + for (const { imports, sizes } of printed) { + console.log(`bundle import { ${Imports.names(imports).join(", ")} }`); + console.log(table(modes.map((mode) => [mode, sizes[mode]]))); console.log(); } console.log("budget"); console.log(budgets()); } -const over = Object.values(entries).filter((entry) => capped(entry) > entry.budget); +const over = results.filter((result) => capped(result) > result.bundle.budget); if (over.length > 0) { console.error(); - for (const entry of over) { + for (const result of over) { console.error( - `over budget: ${Entry.label(entry)} is ${Entry.amount(entry, capped(entry))}, budget ${Entry.amount(entry, entry.budget)}`, + `over budget: ${result.bundle.label()} is ${amount(result, capped(result))}, budget ${amount(result, result.bundle.budget)}`, ); } process.exit(1); From 3bf404a1dc5853d45e9cee245ad4b399948deaba Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:51:01 +0900 Subject: [PATCH 05/52] chore: check every bundle in bundle-size, and label each by its base flag --- scripts/bundle-size.ts | 71 ++++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 33 deletions(-) diff --git a/scripts/bundle-size.ts b/scripts/bundle-size.ts index bbc20f1..5fefae1 100644 --- a/scripts/bundle-size.ts +++ b/scripts/bundle-size.ts @@ -49,7 +49,8 @@ type Bundle = Trait< { /** The key in the `--json` output, which `replace-bundle-size-claims` reads. */ key: (self: Self) => string; - label: (self: Self) => string; + /** The bundle's name in the budget table. */ + name: (self: Self) => string; } >; const Bundle = Trait.companion(); @@ -57,31 +58,31 @@ const Bundle = Trait.companion(); type Core = Val<"Core", Spec, Bundle>; const Core = Val.sealer().implTrait(Bundle, { key: () => "val", - label: () => "production gzip", + name: () => "Val", }); type WithEquals = Val<"WithEquals", Spec, Bundle>; const WithEquals = Val.sealer().implTrait(Bundle, { key: () => "equals", - label: () => "added by Equals", + name: () => "Equals", }); type WithTrait = Val<"WithTrait", Spec, Bundle>; const WithTrait = Val.sealer().implTrait(Bundle, { key: () => "trait", - label: () => "added by Trait", + name: () => "Trait", }); type WithEnum = Val<"WithEnum", Spec, Bundle>; const WithEnum = Val.sealer().implTrait(Bundle, { key: () => "enum", - label: () => "added by Enum", + name: () => "Enum", }); type All = Val<"All", Spec, Bundle>; const All = Val.sealer().implTrait(Bundle, { key: () => "all", - label: () => "added by All", + name: () => "All", }); type Registered = { bundle: Dyn; base: boolean }; @@ -242,28 +243,30 @@ const [first, ...others] = bases; if (first === undefined) throw new Error("register at least one bundle as the base"); const baseImports = others.reduce((merged, imports) => Imports.merge(merged, imports), first); -const [base, results] = await Promise.all([ - measure(baseImports), - Promise.all( - registered.map(async (entry) => ({ - ...entry, - ...(await measure( - entry.base ? entry.bundle.imports : Imports.merge(baseImports, entry.bundle.imports), - )), - })), - ), -]); - -const production = minifySync("production.mjs", base.code.production).code; -if ( - production.includes("snapshotSealInput") || - production.includes("unsnapshotable seal input") || - production.includes("The payload changed while the custom seal was running.") -) { - throw new Error("development-only seal stability checks remain in the production bundle"); -} -if (production.includes("Number.isNaN")) { - throw new Error("equals remains in the production bundle that imports only Val"); +const results = await Promise.all( + registered.map(async (entry) => ({ + ...entry, + ...(await measure( + entry.base ? entry.bundle.imports : Imports.merge(baseImports, entry.bundle.imports), + )), + })), +); +const [only, ...more] = results.filter((result) => result.base); +const base = only !== undefined && more.length === 0 ? only : await measure(baseImports); + +for (const { imports, code } of [base, ...results]) { + const production = minifySync("production.mjs", code.production).code; + const heading = `import { ${Imports.names(imports).join(", ")} }`; + if ( + production.includes("snapshotSealInput") || + production.includes("unsnapshotable seal input") || + production.includes("The payload changed while the custom seal was running.") + ) { + throw new Error(`development-only seal stability checks remain in production: ${heading}`); + } + if (!Imports.names(imports).includes("equals") && production.includes("Number.isNaN")) { + throw new Error(`equals remains in production without its import: ${heading}`); + } } type Result = (typeof results)[number]; @@ -275,8 +278,11 @@ const capped = ({ base: isBase, sizes }: Result): number => const amount = ({ base: isBase }: Result, bytes: number): string => isBase ? format(bytes) : `+${format(bytes)}`; +const label = ({ base: isBase, bundle }: Result): string => + isBase ? bundle.name() : `added by ${bundle.name()}`; + function budgets(): string { - const label = Math.max(...results.map(({ bundle }) => bundle.label().length)); + const width = Math.max(...results.map((result) => label(result).length)); const used = Math.max(...results.map((result) => amount(result, capped(result)).length)); const limit = Math.max(...results.map((result) => amount(result, result.bundle.budget).length)); return results @@ -287,13 +293,12 @@ function budgets(): string { bytes > max ? `${format(bytes - max)} over` : `${format(max - bytes)} left (${Math.round((1 - bytes / max) * 100)}%)`; - return ` ${result.bundle.label().padEnd(label)} ${amount(result, bytes).padStart(used)} / ${amount(result, max).padStart(limit)} ${left}`; + return ` ${label(result).padEnd(width)} ${amount(result, bytes).padStart(used)} / ${amount(result, max).padStart(limit)} ${left}`; }) .join("\n"); } -// With one base bundle, the base is that bundle, already printed. -const printed = bases.length > 1 ? [base, ...results] : results; +const printed: Measured[] = base === only ? results : [base, ...results]; if (process.argv.includes("--json")) { console.log( @@ -319,7 +324,7 @@ if (over.length > 0) { console.error(); for (const result of over) { console.error( - `over budget: ${result.bundle.label()} is ${amount(result, capped(result))}, budget ${amount(result, result.bundle.budget)}`, + `over budget: ${label(result)} is ${amount(result, capped(result))}, budget ${amount(result, result.bundle.budget)}`, ); } process.exit(1); From 8efcd581b4867db2e500c468c95dfb68f9b3e63c Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:55:42 +0900 Subject: [PATCH 06/52] chore: share column alignment in bundle-size, and name the budget's unit --- scripts/bundle-size.ts | 60 ++++++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/scripts/bundle-size.ts b/scripts/bundle-size.ts index 5fefae1..16f8118 100644 --- a/scripts/bundle-size.ts +++ b/scripts/bundle-size.ts @@ -193,23 +193,27 @@ function format(bytes: number): string { return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(2)} kB`; } +/** Pads every column to its widest cell, aligned to the given side. */ +function align(rows: readonly (readonly string[])[], sides: readonly ("left" | "right")[]) { + const columns = sides.map((side, index) => { + const cells = rows.map((row) => row[index] ?? ""); + const width = Math.max(...cells.map((cell) => cell.length)); + return cells.map((cell) => (side === "left" ? cell.padEnd(width) : cell.padStart(width))); + }); + return rows.map((_, index) => columns.map((cells) => cells[index] ?? "")); +} + function table(rows: [string, Sizes][]): string { const heads = ["minified", "gzip", "brotli"] as const; - const label = Math.max(...rows.map(([name]) => name.length)); - const widths = heads.map((head) => - Math.max(head.length, ...rows.map(([, sizes]) => format(sizes[head]).length)), - ); - const line = (name: string, cells: readonly string[]) => - ` ${name.padEnd(label)} ${cells.map((cell, index) => cell.padStart(widths[index]!)).join(" ")}`; - return [ - line("", heads), - ...rows.map(([name, sizes]) => - line( - name, - heads.map((head) => format(sizes[head])), - ), - ), - ].join("\n"); + return align( + [ + ["", ...heads], + ...rows.map(([name, sizes]) => [name, ...heads.map((head) => format(sizes[head]))]), + ], + ["left", "right", "right", "right"], + ) + .map((cells) => ` ${cells.join(" ")}`) + .join("\n"); } await promisify(execFile)(fileURLToPath(new URL("node_modules/.bin/vp", root)), ["pack"], { @@ -282,19 +286,17 @@ const label = ({ base: isBase, bundle }: Result): string => isBase ? bundle.name() : `added by ${bundle.name()}`; function budgets(): string { - const width = Math.max(...results.map((result) => label(result).length)); - const used = Math.max(...results.map((result) => amount(result, capped(result)).length)); - const limit = Math.max(...results.map((result) => amount(result, result.bundle.budget).length)); - return results - .map((result) => { - const bytes = capped(result); - const max = result.bundle.budget; - const left = - bytes > max - ? `${format(bytes - max)} over` - : `${format(max - bytes)} left (${Math.round((1 - bytes / max) * 100)}%)`; - return ` ${label(result).padEnd(width)} ${amount(result, bytes).padStart(used)} / ${amount(result, max).padStart(limit)} ${left}`; - }) + const rows = results.map((result) => { + const bytes = capped(result); + const max = result.bundle.budget; + const left = + bytes > max + ? `${format(bytes - max)} over` + : `${format(max - bytes)} left (${Math.round((1 - bytes / max) * 100)}%)`; + return [label(result), amount(result, bytes), amount(result, max), left]; + }); + return align(rows, ["left", "right", "right", "left"]) + .map(([name, used, limit, left]) => ` ${name} ${used} / ${limit} ${left}`.trimEnd()) .join("\n"); } @@ -314,7 +316,7 @@ if (process.argv.includes("--json")) { console.log(table(modes.map((mode) => [mode, sizes[mode]]))); console.log(); } - console.log("budget"); + console.log("budget (production gzip)"); console.log(budgets()); } From d9def50169b1cb06791c2006544d4e0a9b4d0eb1 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:59:09 +0900 Subject: [PATCH 07/52] refactor: replace the bundle strategies with one Bundle value --- scripts/bundle-size.ts | 107 ++++++++++++++++------------------------- 1 file changed, 42 insertions(+), 65 deletions(-) diff --git a/scripts/bundle-size.ts b/scripts/bundle-size.ts index 16f8118..b1f12fd 100644 --- a/scripts/bundle-size.ts +++ b/scripts/bundle-size.ts @@ -7,7 +7,6 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { minifySync, build, type Rolldown } from "vite"; import { Val } from "../src/index.ts"; -import { Trait, type Dyn, type Self } from "../src/experimental.ts"; const root = new URL("../", import.meta.url); @@ -33,9 +32,6 @@ const Imports = Val.sealer().impl({ type Sizes = Val<"Sizes", { minified: number; gzip: number; brotli: number }>; const Sizes = Val.sealer(); -/** What a bundle imports, and its budget in production gzip. */ -type Spec = { imports: Imports; budget: number }; - /** * A bundle a user ships, and its budget in production gzip. A ratchet, unlike the budgets in * `type-perf`: runtime code is written deliberately, so a byte more is a decision, not drift. @@ -43,49 +39,20 @@ type Spec = { imports: Imports; budget: number }; * The declarations are not measured here. They cost a download and a parse, never a byte in the * user's bundle, and `type-perf` already measures what reads them. */ -type Bundle = Trait< +type Bundle = Val< "Bundle", - Spec, { /** The key in the `--json` output, which `replace-bundle-size-claims` reads. */ - key: (self: Self) => string; + key: string; /** The bundle's name in the budget table. */ - name: (self: Self) => string; + name: string; + imports: Imports; + budget: number; } >; -const Bundle = Trait.companion(); - -type Core = Val<"Core", Spec, Bundle>; -const Core = Val.sealer().implTrait(Bundle, { - key: () => "val", - name: () => "Val", -}); - -type WithEquals = Val<"WithEquals", Spec, Bundle>; -const WithEquals = Val.sealer().implTrait(Bundle, { - key: () => "equals", - name: () => "Equals", -}); - -type WithTrait = Val<"WithTrait", Spec, Bundle>; -const WithTrait = Val.sealer().implTrait(Bundle, { - key: () => "trait", - name: () => "Trait", -}); +const Bundle = Val.sealer(); -type WithEnum = Val<"WithEnum", Spec, Bundle>; -const WithEnum = Val.sealer().implTrait(Bundle, { - key: () => "enum", - name: () => "Enum", -}); - -type All = Val<"All", Spec, Bundle>; -const All = Val.sealer().implTrait(Bundle, { - key: () => "all", - name: () => "All", -}); - -type Registered = { bundle: Dyn; base: boolean }; +type Registered = { bundle: Bundle; base: boolean }; /** * Collects the bundles to measure. The base is every bundle registered with `base` set, bundled @@ -94,7 +61,7 @@ type Registered = { bundle: Dyn; base: boolean }; * import costs is the number to read. */ type Registry = { - register: (bundle: Dyn, base: boolean) => Registry; + register: (bundle: Bundle, base: boolean) => Registry; registered: readonly Registered[]; }; const registry = (registered: readonly Registered[] = []): Registry => ({ @@ -105,41 +72,51 @@ const registry = (registered: readonly Registered[] = []): Registry => ({ // `Trait` and `Enum` ship from their own subpath, so a bundle names the module per import. const { registered } = registry() .register( - Bundle.dyn(Core, Core({ imports: Imports({ "./dist/index.mjs": ["Val"] }), budget: 1024 })), + Bundle({ + key: "val", + name: "Val", + imports: Imports({ "./dist/index.mjs": ["Val"] }), + budget: 1024, + }), true, ) .register( - Bundle.dyn( - WithEquals, - WithEquals({ imports: Imports({ "./dist/index.mjs": ["equals"] }), budget: 256 }), - ), + Bundle({ + key: "equals", + name: "Equals", + imports: Imports({ "./dist/index.mjs": ["equals"] }), + budget: 256, + }), false, ) .register( - Bundle.dyn( - WithTrait, - WithTrait({ imports: Imports({ "./dist/experimental.mjs": ["Trait"] }), budget: 256 }), - ), + Bundle({ + key: "trait", + name: "Trait", + imports: Imports({ "./dist/experimental.mjs": ["Trait"] }), + budget: 256, + }), false, ) .register( - Bundle.dyn( - WithEnum, - WithEnum({ imports: Imports({ "./dist/experimental.mjs": ["Enum"] }), budget: 640 }), - ), + Bundle({ + key: "enum", + name: "Enum", + imports: Imports({ "./dist/experimental.mjs": ["Enum"] }), + budget: 640, + }), false, ) .register( - Bundle.dyn( - All, - All({ - imports: Imports({ - "./dist/index.mjs": ["equals"], - "./dist/experimental.mjs": ["Enum", "Trait"], - }), - budget: 1024, + Bundle({ + key: "all", + name: "All", + imports: Imports({ + "./dist/index.mjs": ["equals"], + "./dist/experimental.mjs": ["Enum", "Trait"], }), - ), + budget: 1024, + }), false, ); @@ -283,7 +260,7 @@ const amount = ({ base: isBase }: Result, bytes: number): string => isBase ? format(bytes) : `+${format(bytes)}`; const label = ({ base: isBase, bundle }: Result): string => - isBase ? bundle.name() : `added by ${bundle.name()}`; + isBase ? bundle.name : `added by ${bundle.name}`; function budgets(): string { const rows = results.map((result) => { @@ -305,7 +282,7 @@ const printed: Measured[] = base === only ? results : [base, ...results]; if (process.argv.includes("--json")) { console.log( JSON.stringify( - Object.fromEntries(results.map(({ bundle, sizes }) => [bundle.key(), sizes])), + Object.fromEntries(results.map(({ bundle, sizes }) => [bundle.key, sizes])), null, 2, ), From e1c8754c4ef08379fa459709eca3e86a83da3757 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:06:06 +0900 Subject: [PATCH 08/52] refactor: list the bundles in a registry value, each flagging its own base --- scripts/bundle-size.ts | 143 ++++++++++++++++++----------------------- 1 file changed, 64 insertions(+), 79 deletions(-) diff --git a/scripts/bundle-size.ts b/scripts/bundle-size.ts index b1f12fd..2cf5ccf 100644 --- a/scripts/bundle-size.ts +++ b/scripts/bundle-size.ts @@ -46,79 +46,69 @@ type Bundle = Val< key: string; /** The bundle's name in the budget table. */ name: string; + /** + * Whether the bundle joins the base, which every base bundle forms together. A base bundle is + * capped by its whole size. Every other bundle is measured on top of the base and capped by + * what it adds, so growth in the base spends only the base's room: what an import costs is the + * number to read. + */ + base?: boolean; imports: Imports; budget: number; } >; const Bundle = Val.sealer(); -type Registered = { bundle: Bundle; base: boolean }; - -/** - * Collects the bundles to measure. The base is every bundle registered with `base` set, bundled - * together. Each of those is capped by its whole size. Every other bundle is measured on top of - * the base and capped by what it adds, so growth in the base spends only the base's room: what an - * import costs is the number to read. - */ -type Registry = { - register: (bundle: Bundle, base: boolean) => Registry; - registered: readonly Registered[]; -}; -const registry = (registered: readonly Registered[] = []): Registry => ({ - register: (bundle, base) => registry([...registered, { bundle, base }]), - registered, +/** The bundles to measure. */ +type Registry = Val<"Registry", Bundle[]>; +const Registry = Val.sealer().impl({ + /** What the base imports: every base bundle's imports, merged. */ + base: (registry): Imports => { + const [first, ...others] = registry + .filter((bundle) => bundle.base) + .map((bundle) => bundle.imports); + if (first === undefined) throw new Error("no bundle sets base"); + return others.reduce((merged, imports) => Imports.merge(merged, imports), first); + }, }); // `Trait` and `Enum` ship from their own subpath, so a bundle names the module per import. -const { registered } = registry() - .register( - Bundle({ - key: "val", - name: "Val", - imports: Imports({ "./dist/index.mjs": ["Val"] }), - budget: 1024, - }), - true, - ) - .register( - Bundle({ - key: "equals", - name: "Equals", - imports: Imports({ "./dist/index.mjs": ["equals"] }), - budget: 256, +const registry = Registry([ + Bundle({ + key: "val", + name: "Val", + base: true, + imports: Imports({ "./dist/index.mjs": ["Val"] }), + budget: 1024, + }), + Bundle({ + key: "equals", + name: "Equals", + imports: Imports({ "./dist/index.mjs": ["equals"] }), + budget: 256, + }), + Bundle({ + key: "trait", + name: "Trait", + imports: Imports({ "./dist/experimental.mjs": ["Trait"] }), + budget: 256, + }), + Bundle({ + key: "enum", + name: "Enum", + imports: Imports({ "./dist/experimental.mjs": ["Enum"] }), + budget: 640, + }), + Bundle({ + key: "all", + name: "All", + imports: Imports({ + "./dist/index.mjs": ["equals"], + "./dist/experimental.mjs": ["Enum", "Trait"], }), - false, - ) - .register( - Bundle({ - key: "trait", - name: "Trait", - imports: Imports({ "./dist/experimental.mjs": ["Trait"] }), - budget: 256, - }), - false, - ) - .register( - Bundle({ - key: "enum", - name: "Enum", - imports: Imports({ "./dist/experimental.mjs": ["Enum"] }), - budget: 640, - }), - false, - ) - .register( - Bundle({ - key: "all", - name: "All", - imports: Imports({ - "./dist/index.mjs": ["equals"], - "./dist/experimental.mjs": ["Enum", "Trait"], - }), - budget: 1024, - }), - false, - ); + budget: 1024, + }), +]); async function compile(mode: Mode, imports: Imports): Promise { const dir = await mkdtemp(join(tmpdir(), "valof-size-")); @@ -219,20 +209,15 @@ async function measure(imports: Imports): Promise { }; } -const bases = registered.filter(({ base }) => base).map(({ bundle }) => bundle.imports); -const [first, ...others] = bases; -if (first === undefined) throw new Error("register at least one bundle as the base"); -const baseImports = others.reduce((merged, imports) => Imports.merge(merged, imports), first); +const baseImports = Registry.base(registry); const results = await Promise.all( - registered.map(async (entry) => ({ - ...entry, - ...(await measure( - entry.base ? entry.bundle.imports : Imports.merge(baseImports, entry.bundle.imports), - )), + registry.map(async (bundle) => ({ + bundle, + ...(await measure(bundle.base ? bundle.imports : Imports.merge(baseImports, bundle.imports))), })), ); -const [only, ...more] = results.filter((result) => result.base); +const [only, ...more] = results.filter(({ bundle }) => bundle.base); const base = only !== undefined && more.length === 0 ? only : await measure(baseImports); for (const { imports, code } of [base, ...results]) { @@ -253,14 +238,14 @@ for (const { imports, code } of [base, ...results]) { type Result = (typeof results)[number]; /** The bytes the budget caps: a base bundle's whole size, or what another adds to the base. */ -const capped = ({ base: isBase, sizes }: Result): number => - isBase ? sizes.production.gzip : sizes.production.gzip - base.sizes.production.gzip; +const capped = ({ bundle, sizes }: Result): number => + bundle.base ? sizes.production.gzip : sizes.production.gzip - base.sizes.production.gzip; -const amount = ({ base: isBase }: Result, bytes: number): string => - isBase ? format(bytes) : `+${format(bytes)}`; +const amount = ({ bundle }: Result, bytes: number): string => + bundle.base ? format(bytes) : `+${format(bytes)}`; -const label = ({ base: isBase, bundle }: Result): string => - isBase ? bundle.name : `added by ${bundle.name}`; +const label = ({ bundle }: Result): string => + bundle.base ? bundle.name : `added by ${bundle.name}`; function budgets(): string { const rows = results.map((result) => { From dad4b0de3384dc197ffdc136c60b2025857fb463 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:16:10 +0900 Subject: [PATCH 09/52] refactor: split bundle-size into schema, measuring and formatting modules --- package.json | 2 +- scripts/bundle-size.ts | 295 -------------------------- scripts/bundle-size/format.ts | 69 ++++++ scripts/bundle-size/index.ts | 73 +++++++ scripts/bundle-size/measure.ts | 116 ++++++++++ scripts/bundle-size/schema.ts | 57 +++++ scripts/replace-bundle-size-claims.ts | 2 +- 7 files changed, 317 insertions(+), 297 deletions(-) delete mode 100644 scripts/bundle-size.ts create mode 100644 scripts/bundle-size/format.ts create mode 100644 scripts/bundle-size/index.ts create mode 100644 scripts/bundle-size/measure.ts create mode 100644 scripts/bundle-size/schema.ts diff --git a/package.json b/package.json index a1f6553..09e25e5 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "docs:serve": "mdbook serve docs", "test": "vp test", "check": "vp check", - "bundle-size": "node scripts/bundle-size.ts", + "bundle-size": "node scripts/bundle-size/index.ts", "replace-bundle-size-claims": "node scripts/replace-bundle-size-claims.ts", "ts-compatibility": "node scripts/ts-compatibility/index.ts", "type-perf": "node scripts/type-perf/index.ts", diff --git a/scripts/bundle-size.ts b/scripts/bundle-size.ts deleted file mode 100644 index 2cf5ccf..0000000 --- a/scripts/bundle-size.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; -import { gzipSync, brotliCompressSync, constants } from "node:zlib"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { minifySync, build, type Rolldown } from "vite"; -import { Val } from "../src/index.ts"; - -const root = new URL("../", import.meta.url); - -type Mode = "production" | "development"; -const modes: readonly Mode[] = ["production", "development"]; - -/** The names a bundle imports, per module. */ -type Imports = Val<"Imports", Record>; -const Imports = Val.sealer().impl({ - /** What the bundle imports, as the printed heading spells it. */ - names: (imports): string[] => Object.values(imports).flat(), - merge: (imports, other: Imports): Imports => { - const merged: Record = {}; - for (const source of [imports, other]) { - for (const [module, names] of Object.entries(source)) { - merged[module] = [...new Set([...(merged[module] ?? []), ...names])]; - } - } - return Imports(merged); - }, -}); - -type Sizes = Val<"Sizes", { minified: number; gzip: number; brotli: number }>; -const Sizes = Val.sealer(); - -/** - * A bundle a user ships, and its budget in production gzip. A ratchet, unlike the budgets in - * `type-perf`: runtime code is written deliberately, so a byte more is a decision, not drift. - * - * The declarations are not measured here. They cost a download and a parse, never a byte in the - * user's bundle, and `type-perf` already measures what reads them. - */ -type Bundle = Val< - "Bundle", - { - /** The key in the `--json` output, which `replace-bundle-size-claims` reads. */ - key: string; - /** The bundle's name in the budget table. */ - name: string; - /** - * Whether the bundle joins the base, which every base bundle forms together. A base bundle is - * capped by its whole size. Every other bundle is measured on top of the base and capped by - * what it adds, so growth in the base spends only the base's room: what an import costs is the - * number to read. - */ - base?: boolean; - imports: Imports; - budget: number; - } ->; -const Bundle = Val.sealer(); - -/** The bundles to measure. */ -type Registry = Val<"Registry", Bundle[]>; -const Registry = Val.sealer().impl({ - /** What the base imports: every base bundle's imports, merged. */ - base: (registry): Imports => { - const [first, ...others] = registry - .filter((bundle) => bundle.base) - .map((bundle) => bundle.imports); - if (first === undefined) throw new Error("no bundle sets base"); - return others.reduce((merged, imports) => Imports.merge(merged, imports), first); - }, -}); - -// `Trait` and `Enum` ship from their own subpath, so a bundle names the module per import. -const registry = Registry([ - Bundle({ - key: "val", - name: "Val", - base: true, - imports: Imports({ "./dist/index.mjs": ["Val"] }), - budget: 1024, - }), - Bundle({ - key: "equals", - name: "Equals", - imports: Imports({ "./dist/index.mjs": ["equals"] }), - budget: 256, - }), - Bundle({ - key: "trait", - name: "Trait", - imports: Imports({ "./dist/experimental.mjs": ["Trait"] }), - budget: 256, - }), - Bundle({ - key: "enum", - name: "Enum", - imports: Imports({ "./dist/experimental.mjs": ["Enum"] }), - budget: 640, - }), - Bundle({ - key: "all", - name: "All", - imports: Imports({ - "./dist/index.mjs": ["equals"], - "./dist/experimental.mjs": ["Enum", "Trait"], - }), - budget: 1024, - }), -]); - -async function compile(mode: Mode, imports: Imports): Promise { - const dir = await mkdtemp(join(tmpdir(), "valof-size-")); - const file = join(dir, "entry.mjs"); - const lines = Object.entries(imports).map( - ([module, names]) => - `import { ${names.join(", ")} } from ${JSON.stringify(fileURLToPath(new URL(module, root)))};`, - ); - await writeFile( - file, - `${lines.join("\n")}\nconsole.log(${Imports.names(imports).join(", ")});\n`, - ); - try { - const result = (await build({ - root: fileURLToPath(root), - logLevel: "silent", - configFile: false, - define: { "process.env.NODE_ENV": JSON.stringify(mode) }, - build: { - write: false, - minify: false, - target: "esnext", - lib: { entry: file, formats: ["es"], fileName: "entry" }, - }, - })) as Rolldown.RolldownOutput[]; - const chunk = result[0]?.output.find((output) => output.type === "chunk"); - if (chunk?.type !== "chunk") throw new Error(`no chunk emitted for ${mode}`); - return chunk.code; - } finally { - await rm(dir, { recursive: true, force: true }); - } -} - -function sizes(code: string): Sizes { - const bytes = Buffer.from(minifySync("bundle.mjs", code).code, "utf8"); - return Sizes({ - minified: bytes.byteLength, - gzip: gzipSync(bytes, { level: 9 }).byteLength, - brotli: brotliCompressSync(bytes, { - params: { - [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY, - [constants.BROTLI_PARAM_SIZE_HINT]: bytes.byteLength, - }, - }).byteLength, - }); -} - -function format(bytes: number): string { - return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(2)} kB`; -} - -/** Pads every column to its widest cell, aligned to the given side. */ -function align(rows: readonly (readonly string[])[], sides: readonly ("left" | "right")[]) { - const columns = sides.map((side, index) => { - const cells = rows.map((row) => row[index] ?? ""); - const width = Math.max(...cells.map((cell) => cell.length)); - return cells.map((cell) => (side === "left" ? cell.padEnd(width) : cell.padStart(width))); - }); - return rows.map((_, index) => columns.map((cells) => cells[index] ?? "")); -} - -function table(rows: [string, Sizes][]): string { - const heads = ["minified", "gzip", "brotli"] as const; - return align( - [ - ["", ...heads], - ...rows.map(([name, sizes]) => [name, ...heads.map((head) => format(sizes[head]))]), - ], - ["left", "right", "right", "right"], - ) - .map((cells) => ` ${cells.join(" ")}`) - .join("\n"); -} - -await promisify(execFile)(fileURLToPath(new URL("node_modules/.bin/vp", root)), ["pack"], { - cwd: fileURLToPath(root), -}).catch((error: { stdout?: string; stderr?: string }) => { - console.error(error.stdout ?? ""); - console.error(error.stderr ?? ""); - process.exit(1); -}); - -type Measured = { - imports: Imports; - code: Record; - sizes: Record; -}; - -async function measure(imports: Imports): Promise { - const [production, development] = await Promise.all([ - compile("production", imports), - compile("development", imports), - ]); - return { - imports, - code: { production, development }, - sizes: { production: sizes(production), development: sizes(development) }, - }; -} - -const baseImports = Registry.base(registry); - -const results = await Promise.all( - registry.map(async (bundle) => ({ - bundle, - ...(await measure(bundle.base ? bundle.imports : Imports.merge(baseImports, bundle.imports))), - })), -); -const [only, ...more] = results.filter(({ bundle }) => bundle.base); -const base = only !== undefined && more.length === 0 ? only : await measure(baseImports); - -for (const { imports, code } of [base, ...results]) { - const production = minifySync("production.mjs", code.production).code; - const heading = `import { ${Imports.names(imports).join(", ")} }`; - if ( - production.includes("snapshotSealInput") || - production.includes("unsnapshotable seal input") || - production.includes("The payload changed while the custom seal was running.") - ) { - throw new Error(`development-only seal stability checks remain in production: ${heading}`); - } - if (!Imports.names(imports).includes("equals") && production.includes("Number.isNaN")) { - throw new Error(`equals remains in production without its import: ${heading}`); - } -} - -type Result = (typeof results)[number]; - -/** The bytes the budget caps: a base bundle's whole size, or what another adds to the base. */ -const capped = ({ bundle, sizes }: Result): number => - bundle.base ? sizes.production.gzip : sizes.production.gzip - base.sizes.production.gzip; - -const amount = ({ bundle }: Result, bytes: number): string => - bundle.base ? format(bytes) : `+${format(bytes)}`; - -const label = ({ bundle }: Result): string => - bundle.base ? bundle.name : `added by ${bundle.name}`; - -function budgets(): string { - const rows = results.map((result) => { - const bytes = capped(result); - const max = result.bundle.budget; - const left = - bytes > max - ? `${format(bytes - max)} over` - : `${format(max - bytes)} left (${Math.round((1 - bytes / max) * 100)}%)`; - return [label(result), amount(result, bytes), amount(result, max), left]; - }); - return align(rows, ["left", "right", "right", "left"]) - .map(([name, used, limit, left]) => ` ${name} ${used} / ${limit} ${left}`.trimEnd()) - .join("\n"); -} - -const printed: Measured[] = base === only ? results : [base, ...results]; - -if (process.argv.includes("--json")) { - console.log( - JSON.stringify( - Object.fromEntries(results.map(({ bundle, sizes }) => [bundle.key, sizes])), - null, - 2, - ), - ); -} else { - for (const { imports, sizes } of printed) { - console.log(`bundle import { ${Imports.names(imports).join(", ")} }`); - console.log(table(modes.map((mode) => [mode, sizes[mode]]))); - console.log(); - } - console.log("budget (production gzip)"); - console.log(budgets()); -} - -const over = results.filter((result) => capped(result) > result.bundle.budget); - -if (over.length > 0) { - console.error(); - for (const result of over) { - console.error( - `over budget: ${label(result)} is ${amount(result, capped(result))}, budget ${amount(result, result.bundle.budget)}`, - ); - } - process.exit(1); -} diff --git a/scripts/bundle-size/format.ts b/scripts/bundle-size/format.ts new file mode 100644 index 0000000..4907115 --- /dev/null +++ b/scripts/bundle-size/format.ts @@ -0,0 +1,69 @@ +import { Imports } from "./schema.ts"; +import { capped, modes, type Measured, type Result, type Sizes } from "./measure.ts"; + +function format(bytes: number): string { + return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(2)} kB`; +} + +/** Pads every column to its widest cell, aligned to the given side. */ +function align(rows: readonly (readonly string[])[], sides: readonly ("left" | "right")[]) { + const columns = sides.map((side, index) => { + const cells = rows.map((row) => row[index] ?? ""); + const width = Math.max(...cells.map((cell) => cell.length)); + return cells.map((cell) => (side === "left" ? cell.padEnd(width) : cell.padStart(width))); + }); + return rows.map((_, index) => columns.map((cells) => cells[index] ?? "")); +} + +const amount = ({ bundle }: Result, bytes: number): string => + bundle.base ? format(bytes) : `+${format(bytes)}`; + +const label = ({ bundle }: Result): string => + bundle.base ? bundle.name : `added by ${bundle.name}`; + +/** A bundle's heading and its sizes in each mode. */ +export function sizes({ imports, sizes }: Measured): string { + const heads = ["minified", "gzip", "brotli"] as const; + const rows = align( + [ + ["", ...heads], + ...modes.map((mode) => [mode, ...heads.map((head) => format(sizes[mode][head]))]), + ], + ["left", "right", "right", "right"], + ); + return [ + `bundle import { ${Imports.names(imports).join(", ")} }`, + ...rows.map((cells) => ` ${cells.join(" ")}`), + ].join("\n"); +} + +/** Each bundle's capped bytes against its budget. */ +export function budgets(results: readonly Result[], base: Measured): string { + const rows = results.map((result) => { + const bytes = capped(result, base); + const max = result.bundle.budget; + const left = + bytes > max + ? `${format(bytes - max)} over` + : `${format(max - bytes)} left (${Math.round((1 - bytes / max) * 100)}%)`; + return [label(result), amount(result, bytes), amount(result, max), left]; + }); + return [ + "budget (production gzip)", + ...align(rows, ["left", "right", "right", "left"]).map(([name, used, limit, left]) => + ` ${name} ${used} / ${limit} ${left}`.trimEnd(), + ), + ].join("\n"); +} + +export function over(result: Result, base: Measured): string { + return `over budget: ${label(result)} is ${amount(result, capped(result, base))}, budget ${amount(result, result.bundle.budget)}`; +} + +/** The `--json` output, keyed as `replace-bundle-size-claims` reads it. */ +export function json(results: readonly Result[]): string { + const keyed: Record> = Object.fromEntries( + results.map(({ bundle, sizes }) => [bundle.key, sizes]), + ); + return JSON.stringify(keyed, null, 2); +} diff --git a/scripts/bundle-size/index.ts b/scripts/bundle-size/index.ts new file mode 100644 index 0000000..d7d1aa9 --- /dev/null +++ b/scripts/bundle-size/index.ts @@ -0,0 +1,73 @@ +import { Bundle, Imports, Registry } from "./schema.ts"; +import { budgets, json, over, sizes } from "./format.ts"; +import { assertProduction, capped, measure, pack } from "./measure.ts"; + +// `Trait` and `Enum` ship from their own subpath, so a bundle names the module per import. +const registry = Registry([ + Bundle({ + key: "val", + name: "Val", + base: true, + imports: Imports({ "./dist/index.mjs": ["Val"] }), + budget: 1024, + }), + Bundle({ + key: "equals", + name: "Equals", + imports: Imports({ "./dist/index.mjs": ["equals"] }), + budget: 256, + }), + Bundle({ + key: "trait", + name: "Trait", + imports: Imports({ "./dist/experimental.mjs": ["Trait"] }), + budget: 256, + }), + Bundle({ + key: "enum", + name: "Enum", + imports: Imports({ "./dist/experimental.mjs": ["Enum"] }), + budget: 640, + }), + Bundle({ + key: "all", + name: "All", + imports: Imports({ + "./dist/index.mjs": ["equals"], + "./dist/experimental.mjs": ["Enum", "Trait"], + }), + budget: 1024, + }), +]); + +await pack(); + +const baseImports = Registry.base(registry); +const results = await Promise.all( + registry.map(async (bundle) => ({ + bundle, + ...(await measure(bundle.base ? bundle.imports : Imports.merge(baseImports, bundle.imports))), + })), +); +const [only, ...more] = results.filter(({ bundle }) => bundle.base); +// With one base bundle, the base is that bundle, already measured. +const base = only !== undefined && more.length === 0 ? only : await measure(baseImports); + +for (const measured of [base, ...results]) assertProduction(measured); + +if (process.argv.includes("--json")) { + console.log(json(results)); +} else { + for (const measured of base === only ? results : [base, ...results]) { + console.log(sizes(measured)); + console.log(); + } + console.log(budgets(results, base)); +} + +const exceeded = results.filter((result) => capped(result, base) > result.bundle.budget); +if (exceeded.length > 0) { + console.error(); + for (const result of exceeded) console.error(over(result, base)); + process.exit(1); +} diff --git a/scripts/bundle-size/measure.ts b/scripts/bundle-size/measure.ts new file mode 100644 index 0000000..2f81364 --- /dev/null +++ b/scripts/bundle-size/measure.ts @@ -0,0 +1,116 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { gzipSync, brotliCompressSync, constants } from "node:zlib"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { minifySync, build, type Rolldown } from "vite"; +import { Val } from "../../src/index.ts"; +import { Imports, type Bundle } from "./schema.ts"; + +const root = new URL("../../", import.meta.url); + +export type Mode = "production" | "development"; +export const modes: readonly Mode[] = ["production", "development"]; + +export type Sizes = Val<"Sizes", { minified: number; gzip: number; brotli: number }>; +export const Sizes = Val.sealer(); + +export type Measured = { + imports: Imports; + code: Record; + sizes: Record; +}; + +/** A bundle, measured with the base under it unless it joins the base. */ +export type Result = Measured & { bundle: Bundle }; + +/** Builds `dist`, so the bundles import what a user installs. Exits when the build fails. */ +export async function pack(): Promise { + await promisify(execFile)(fileURLToPath(new URL("node_modules/.bin/vp", root)), ["pack"], { + cwd: fileURLToPath(root), + }).catch((error: { stdout?: string; stderr?: string }) => { + console.error(error.stdout ?? ""); + console.error(error.stderr ?? ""); + process.exit(1); + }); +} + +async function compile(mode: Mode, imports: Imports): Promise { + const dir = await mkdtemp(join(tmpdir(), "valof-size-")); + const file = join(dir, "entry.mjs"); + const lines = Object.entries(imports).map( + ([module, names]) => + `import { ${names.join(", ")} } from ${JSON.stringify(fileURLToPath(new URL(module, root)))};`, + ); + await writeFile( + file, + `${lines.join("\n")}\nconsole.log(${Imports.names(imports).join(", ")});\n`, + ); + try { + const result = (await build({ + root: fileURLToPath(root), + logLevel: "silent", + configFile: false, + define: { "process.env.NODE_ENV": JSON.stringify(mode) }, + build: { + write: false, + minify: false, + target: "esnext", + lib: { entry: file, formats: ["es"], fileName: "entry" }, + }, + })) as Rolldown.RolldownOutput[]; + const chunk = result[0]?.output.find((output) => output.type === "chunk"); + if (chunk?.type !== "chunk") throw new Error(`no chunk emitted for ${mode}`); + return chunk.code; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +function sizes(code: string): Sizes { + const bytes = Buffer.from(minifySync("bundle.mjs", code).code, "utf8"); + return Sizes({ + minified: bytes.byteLength, + gzip: gzipSync(bytes, { level: 9 }).byteLength, + brotli: brotliCompressSync(bytes, { + params: { + [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY, + [constants.BROTLI_PARAM_SIZE_HINT]: bytes.byteLength, + }, + }).byteLength, + }); +} + +export async function measure(imports: Imports): Promise { + const [production, development] = await Promise.all([ + compile("production", imports), + compile("development", imports), + ]); + return { + imports, + code: { production, development }, + sizes: { production: sizes(production), development: sizes(development) }, + }; +} + +/** The bytes the budget caps: a base bundle's whole size, or what another adds to the base. */ +export const capped = ({ bundle, sizes }: Result, base: Measured): number => + bundle.base ? sizes.production.gzip : sizes.production.gzip - base.sizes.production.gzip; + +/** Throws when a production bundle keeps code that only development or another import needs. */ +export function assertProduction({ imports, code }: Measured): void { + const production = minifySync("production.mjs", code.production).code; + const heading = `import { ${Imports.names(imports).join(", ")} }`; + if ( + production.includes("snapshotSealInput") || + production.includes("unsnapshotable seal input") || + production.includes("The payload changed while the custom seal was running.") + ) { + throw new Error(`development-only seal stability checks remain in production: ${heading}`); + } + if (!Imports.names(imports).includes("equals") && production.includes("Number.isNaN")) { + throw new Error(`equals remains in production without its import: ${heading}`); + } +} diff --git a/scripts/bundle-size/schema.ts b/scripts/bundle-size/schema.ts new file mode 100644 index 0000000..87c05b8 --- /dev/null +++ b/scripts/bundle-size/schema.ts @@ -0,0 +1,57 @@ +import { Val } from "../../src/index.ts"; + +/** The names a bundle imports, per module. */ +export type Imports = Val<"Imports", Record>; +export const Imports = Val.sealer().impl({ + /** What the bundle imports, as the printed heading spells it. */ + names: (imports): string[] => Object.values(imports).flat(), + merge: (imports, other: Imports): Imports => { + const merged: Record = {}; + for (const source of [imports, other]) { + for (const [module, names] of Object.entries(source)) { + merged[module] = [...new Set([...(merged[module] ?? []), ...names])]; + } + } + return Imports(merged); + }, +}); + +/** + * A bundle a user ships, and its budget in production gzip. A ratchet, unlike the budgets in + * `type-perf`: runtime code is written deliberately, so a byte more is a decision, not drift. + * + * The declarations are not measured here. They cost a download and a parse, never a byte in the + * user's bundle, and `type-perf` already measures what reads them. + */ +export type Bundle = Val< + "Bundle", + { + /** The key in the `--json` output, which `replace-bundle-size-claims` reads. */ + key: string; + /** The bundle's name in the budget table. */ + name: string; + /** + * Whether the bundle joins the base, which every base bundle forms together. A base bundle is + * capped by its whole size. Every other bundle is measured on top of the base and capped by + * what it adds, so growth in the base spends only the base's room: what an import costs is the + * number to read. + */ + base?: boolean; + imports: Imports; + budget: number; + } +>; +export const Bundle = Val.sealer(); + +/** The bundles to measure. */ +export type Registry = Val<"Registry", Bundle[]>; +export const Registry = Val.sealer().impl({ + /** What the base imports: every base bundle's imports, merged. */ + base: (registry): Imports => { + const [first, ...others] = registry + .filter((bundle) => bundle.base) + .map((bundle) => bundle.imports); + if (first === undefined) throw new Error("no bundle sets base"); + return others.reduce((merged, imports) => Imports.merge(merged, imports), first); + }, +}); diff --git a/scripts/replace-bundle-size-claims.ts b/scripts/replace-bundle-size-claims.ts index 9c8ca0b..edf5ac9 100644 --- a/scripts/replace-bundle-size-claims.ts +++ b/scripts/replace-bundle-size-claims.ts @@ -41,7 +41,7 @@ let stdout: string; try { ({ stdout } = await promisify(execFile)( process.execPath, - [fileURLToPath(new URL("bundle-size.ts", import.meta.url)), "--json"], + [fileURLToPath(new URL("bundle-size/index.ts", import.meta.url)), "--json"], { cwd: fileURLToPath(root) }, )); } catch (error) { From 5c6d7937e719c664851fddc4b12d7531598edbfc Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:21:18 +0900 Subject: [PATCH 10/52] refactor: measure the bundles through a Suite, split from Bundle --- scripts/bundle-size/{schema.ts => bundle.ts} | 13 --------- scripts/bundle-size/format.ts | 2 +- scripts/bundle-size/index.ts | 14 ++++------ scripts/bundle-size/measure.ts | 2 +- scripts/bundle-size/suite.ts | 28 ++++++++++++++++++++ 5 files changed, 35 insertions(+), 24 deletions(-) rename scripts/bundle-size/{schema.ts => bundle.ts} (76%) create mode 100644 scripts/bundle-size/suite.ts diff --git a/scripts/bundle-size/schema.ts b/scripts/bundle-size/bundle.ts similarity index 76% rename from scripts/bundle-size/schema.ts rename to scripts/bundle-size/bundle.ts index 87c05b8..61728df 100644 --- a/scripts/bundle-size/schema.ts +++ b/scripts/bundle-size/bundle.ts @@ -42,16 +42,3 @@ export type Bundle = Val< } >; export const Bundle = Val.sealer(); - -/** The bundles to measure. */ -export type Registry = Val<"Registry", Bundle[]>; -export const Registry = Val.sealer().impl({ - /** What the base imports: every base bundle's imports, merged. */ - base: (registry): Imports => { - const [first, ...others] = registry - .filter((bundle) => bundle.base) - .map((bundle) => bundle.imports); - if (first === undefined) throw new Error("no bundle sets base"); - return others.reduce((merged, imports) => Imports.merge(merged, imports), first); - }, -}); diff --git a/scripts/bundle-size/format.ts b/scripts/bundle-size/format.ts index 4907115..ad5d584 100644 --- a/scripts/bundle-size/format.ts +++ b/scripts/bundle-size/format.ts @@ -1,4 +1,4 @@ -import { Imports } from "./schema.ts"; +import { Imports } from "./bundle.ts"; import { capped, modes, type Measured, type Result, type Sizes } from "./measure.ts"; function format(bytes: number): string { diff --git a/scripts/bundle-size/index.ts b/scripts/bundle-size/index.ts index d7d1aa9..dc21593 100644 --- a/scripts/bundle-size/index.ts +++ b/scripts/bundle-size/index.ts @@ -1,9 +1,10 @@ -import { Bundle, Imports, Registry } from "./schema.ts"; +import { Bundle, Imports } from "./bundle.ts"; +import { Suite } from "./suite.ts"; import { budgets, json, over, sizes } from "./format.ts"; import { assertProduction, capped, measure, pack } from "./measure.ts"; // `Trait` and `Enum` ship from their own subpath, so a bundle names the module per import. -const registry = Registry([ +const suite = Suite([ Bundle({ key: "val", name: "Val", @@ -42,13 +43,8 @@ const registry = Registry([ await pack(); -const baseImports = Registry.base(registry); -const results = await Promise.all( - registry.map(async (bundle) => ({ - bundle, - ...(await measure(bundle.base ? bundle.imports : Imports.merge(baseImports, bundle.imports))), - })), -); +const baseImports = Suite.base(suite); +const results = await Suite.measure(suite, measure); const [only, ...more] = results.filter(({ bundle }) => bundle.base); // With one base bundle, the base is that bundle, already measured. const base = only !== undefined && more.length === 0 ? only : await measure(baseImports); diff --git a/scripts/bundle-size/measure.ts b/scripts/bundle-size/measure.ts index 2f81364..ef6273b 100644 --- a/scripts/bundle-size/measure.ts +++ b/scripts/bundle-size/measure.ts @@ -7,7 +7,7 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { minifySync, build, type Rolldown } from "vite"; import { Val } from "../../src/index.ts"; -import { Imports, type Bundle } from "./schema.ts"; +import { Imports, type Bundle } from "./bundle.ts"; const root = new URL("../../", import.meta.url); diff --git a/scripts/bundle-size/suite.ts b/scripts/bundle-size/suite.ts new file mode 100644 index 0000000..81e32b9 --- /dev/null +++ b/scripts/bundle-size/suite.ts @@ -0,0 +1,28 @@ +import { Val } from "../../src/index.ts"; +import { Imports, type Bundle } from "./bundle.ts"; + +/** The bundles measured together, against the base they share. */ +export type Suite = Val<"Suite", Bundle[]>; +export const Suite = Val.sealer().impl({ + /** What the base imports: every base bundle's imports, merged. */ + base: (suite): Imports => { + const [first, ...others] = suite + .filter((bundle) => bundle.base) + .map((bundle) => bundle.imports); + if (first === undefined) throw new Error("no bundle sets base"); + return others.reduce((merged, imports) => Imports.merge(merged, imports), first); + }, + /** Measures each bundle with `measure`: a base bundle alone, any other on top of the base. */ + measure: ( + suite: Suite, + measure: (imports: Imports) => Promise, + ): Promise<(T & { bundle: Bundle })[]> => { + const base = Suite.base(suite); + return Promise.all( + suite.map(async (bundle) => ({ + bundle, + ...(await measure(bundle.base ? bundle.imports : Imports.merge(base, bundle.imports))), + })), + ); + }, +}); From 0fed8b639d539b4cd16d03227bda1560e66b056a Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:26:53 +0900 Subject: [PATCH 11/52] refactor: report bundle-size failures as better-result Results --- package.json | 1 + pnpm-lock.yaml | 8 ++ scripts/bundle-size/format.ts | 12 +- scripts/bundle-size/index.ts | 31 +++-- scripts/bundle-size/measure.ts | 225 ++++++++++++++++++++++----------- scripts/bundle-size/suite.ts | 36 ++++-- 6 files changed, 211 insertions(+), 102 deletions(-) diff --git a/package.json b/package.json index 09e25e5..ade63c6 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ }, "devDependencies": { "@types/node": "^26.5.0", + "better-result": "^3.0.1", "bumpp": "^11.1.0", "eslint": "^10.10.0", "handlebars": "^4.7.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7393998..d7be215 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -219,6 +219,9 @@ importers: '@types/node': specifier: ^26.5.0 version: 26.5.0 + better-result: + specifier: ^3.0.1 + version: 3.0.1 bumpp: specifier: ^11.1.0 version: 11.1.0 @@ -1398,6 +1401,9 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + better-result@3.0.1: + resolution: {integrity: sha512-rnCeqEXkKPHmz5MO5ZroPRDyHe4YvlNPyy7qj8cVcWs/GnvSvC3086G74/220W+wWcIWlXgzvcjskFV0A4blUQ==} + brace-expansion@5.0.9: resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} @@ -2907,6 +2913,8 @@ snapshots: balanced-match@4.0.4: {} + better-result@3.0.1: {} + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 diff --git a/scripts/bundle-size/format.ts b/scripts/bundle-size/format.ts index ad5d584..10bce85 100644 --- a/scripts/bundle-size/format.ts +++ b/scripts/bundle-size/format.ts @@ -1,5 +1,5 @@ import { Imports } from "./bundle.ts"; -import { capped, modes, type Measured, type Result, type Sizes } from "./measure.ts"; +import { capped, modes, type Measured, type MeasuredBundle, type Sizes } from "./measure.ts"; function format(bytes: number): string { return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(2)} kB`; @@ -15,10 +15,10 @@ function align(rows: readonly (readonly string[])[], sides: readonly ("left" | " return rows.map((_, index) => columns.map((cells) => cells[index] ?? "")); } -const amount = ({ bundle }: Result, bytes: number): string => +const amount = ({ bundle }: MeasuredBundle, bytes: number): string => bundle.base ? format(bytes) : `+${format(bytes)}`; -const label = ({ bundle }: Result): string => +const label = ({ bundle }: MeasuredBundle): string => bundle.base ? bundle.name : `added by ${bundle.name}`; /** A bundle's heading and its sizes in each mode. */ @@ -38,7 +38,7 @@ export function sizes({ imports, sizes }: Measured): string { } /** Each bundle's capped bytes against its budget. */ -export function budgets(results: readonly Result[], base: Measured): string { +export function budgets(results: readonly MeasuredBundle[], base: Measured): string { const rows = results.map((result) => { const bytes = capped(result, base); const max = result.bundle.budget; @@ -56,12 +56,12 @@ export function budgets(results: readonly Result[], base: Measured): string { ].join("\n"); } -export function over(result: Result, base: Measured): string { +export function over(result: MeasuredBundle, base: Measured): string { return `over budget: ${label(result)} is ${amount(result, capped(result, base))}, budget ${amount(result, result.bundle.budget)}`; } /** The `--json` output, keyed as `replace-bundle-size-claims` reads it. */ -export function json(results: readonly Result[]): string { +export function json(results: readonly MeasuredBundle[]): string { const keyed: Record> = Object.fromEntries( results.map(({ bundle, sizes }) => [bundle.key, sizes]), ); diff --git a/scripts/bundle-size/index.ts b/scripts/bundle-size/index.ts index dc21593..401374a 100644 --- a/scripts/bundle-size/index.ts +++ b/scripts/bundle-size/index.ts @@ -1,7 +1,8 @@ import { Bundle, Imports } from "./bundle.ts"; import { Suite } from "./suite.ts"; import { budgets, json, over, sizes } from "./format.ts"; -import { assertProduction, capped, measure, pack } from "./measure.ts"; +import { Result as R } from "better-result"; +import { checkProduction, capped, measure, pack } from "./measure.ts"; // `Trait` and `Enum` ship from their own subpath, so a bundle names the module per import. const suite = Suite([ @@ -41,21 +42,29 @@ const suite = Suite([ }), ]); -await pack(); +const measured = await R.gen(async function* () { + yield* R.await(pack()); + const baseImports = yield* Suite.base(suite); + const results = yield* R.await(Suite.measure(suite, measure)); + const [only, ...more] = results.filter(({ bundle }) => bundle.base); + // With one base bundle, the base is that bundle, already measured. + const base = + only !== undefined && more.length === 0 ? only : yield* R.await(measure(baseImports)); + for (const each of [base, ...results]) yield* checkProduction(each); + return R.ok({ base, results, extra: base === only ? [] : [base] }); +}); -const baseImports = Suite.base(suite); -const results = await Suite.measure(suite, measure); -const [only, ...more] = results.filter(({ bundle }) => bundle.base); -// With one base bundle, the base is that bundle, already measured. -const base = only !== undefined && more.length === 0 ? only : await measure(baseImports); - -for (const measured of [base, ...results]) assertProduction(measured); +if (measured.isErr()) { + console.error(measured.error.message); + process.exit(1); +} +const { base, results, extra } = measured.value; if (process.argv.includes("--json")) { console.log(json(results)); } else { - for (const measured of base === only ? results : [base, ...results]) { - console.log(sizes(measured)); + for (const each of [...extra, ...results]) { + console.log(sizes(each)); console.log(); } console.log(budgets(results, base)); diff --git a/scripts/bundle-size/measure.ts b/scripts/bundle-size/measure.ts index ef6273b..7a2a3b7 100644 --- a/scripts/bundle-size/measure.ts +++ b/scripts/bundle-size/measure.ts @@ -5,6 +5,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; +import { Result as R, TaggedError } from "better-result"; import { minifySync, build, type Rolldown } from "vite"; import { Val } from "../../src/index.ts"; import { Imports, type Bundle } from "./bundle.ts"; @@ -24,93 +25,175 @@ export type Measured = { }; /** A bundle, measured with the base under it unless it joins the base. */ -export type Result = Measured & { bundle: Bundle }; - -/** Builds `dist`, so the bundles import what a user installs. Exits when the build fails. */ -export async function pack(): Promise { - await promisify(execFile)(fileURLToPath(new URL("node_modules/.bin/vp", root)), ["pack"], { - cwd: fileURLToPath(root), - }).catch((error: { stdout?: string; stderr?: string }) => { - console.error(error.stdout ?? ""); - console.error(error.stderr ?? ""); - process.exit(1); +export type MeasuredBundle = Measured & { bundle: Bundle }; + +export class PackFailed extends TaggedError("PackFailed")<{ message: string }> {} + +export class NoChunk extends TaggedError("NoChunk")<{ mode: Mode; message: string }> {} + +export class DevelopmentCodeRemains extends TaggedError("DevelopmentCodeRemains")<{ + imports: Imports; + message: string; +}> {} + +export class EqualsRemains extends TaggedError("EqualsRemains")<{ + imports: Imports; + message: string; +}> {} + +/** Builds `dist`, so the bundles import what a user installs. */ +export const pack = (): Promise> => + R.tryPromise({ + try: async () => { + await promisify(execFile)(fileURLToPath(new URL("node_modules/.bin/vp", root)), ["pack"], { + cwd: fileURLToPath(root), + }); + }, + catch: (cause) => { + const { stdout = "", stderr = "" } = cause as { stdout?: string; stderr?: string }; + const output = `${stdout}${stderr}` || String(cause); + return new PackFailed({ message: `vp pack failed\n${output}` }); + }, }); -} -async function compile(mode: Mode, imports: Imports): Promise { - const dir = await mkdtemp(join(tmpdir(), "valof-size-")); - const file = join(dir, "entry.mjs"); - const lines = Object.entries(imports).map( - ([module, names]) => - `import { ${names.join(", ")} } from ${JSON.stringify(fileURLToPath(new URL(module, root)))};`, - ); - await writeFile( - file, - `${lines.join("\n")}\nconsole.log(${Imports.names(imports).join(", ")});\n`, - ); - try { - const result = (await build({ - root: fileURLToPath(root), - logLevel: "silent", - configFile: false, - define: { "process.env.NODE_ENV": JSON.stringify(mode) }, - build: { - write: false, - minify: false, - target: "esnext", - lib: { entry: file, formats: ["es"], fileName: "entry" }, - }, - })) as Rolldown.RolldownOutput[]; - const chunk = result[0]?.output.find((output) => output.type === "chunk"); - if (chunk?.type !== "chunk") throw new Error(`no chunk emitted for ${mode}`); - return chunk.code; - } finally { - await rm(dir, { recursive: true, force: true }); - } -} +/** Something a bundle's compiling step could not do: the temporary file, or the build itself. */ +export class CompileFailed extends TaggedError("CompileFailed")<{ + mode: Mode; + cause: unknown; + message: string; +}> {} -function sizes(code: string): Sizes { - const bytes = Buffer.from(minifySync("bundle.mjs", code).code, "utf8"); - return Sizes({ - minified: bytes.byteLength, - gzip: gzipSync(bytes, { level: 9 }).byteLength, - brotli: brotliCompressSync(bytes, { - params: { - [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY, - [constants.BROTLI_PARAM_SIZE_HINT]: bytes.byteLength, - }, - }).byteLength, +export class MinifyFailed extends TaggedError("MinifyFailed")<{ + cause: unknown; + message: string; +}> {} + +const attempt = (mode: Mode, step: string, run: () => Promise) => + R.tryPromise({ + try: run, + catch: (cause) => + new CompileFailed({ mode, cause, message: `${step} failed for ${mode}: ${String(cause)}` }), }); -} -export async function measure(imports: Imports): Promise { - const [production, development] = await Promise.all([ - compile("production", imports), - compile("development", imports), - ]); - return { - imports, - code: { production, development }, - sizes: { production: sizes(production), development: sizes(development) }, - }; -} +const minify = (code: string): R => + R.try({ + try: () => minifySync("bundle.mjs", code).code, + catch: (cause) => new MinifyFailed({ cause, message: `minify failed: ${String(cause)}` }), + }); + +const compile = (mode: Mode, imports: Imports): Promise> => + R.gen(async function* () { + const dir = yield* R.await( + attempt(mode, "creating the entry directory", () => mkdtemp(join(tmpdir(), "valof-size-"))), + ); + const file = join(dir, "entry.mjs"); + const lines = Object.entries(imports).map( + ([module, names]) => + `import { ${names.join(", ")} } from ${JSON.stringify(fileURLToPath(new URL(module, root)))};`, + ); + const built = await R.gen(async function* () { + yield* R.await( + attempt(mode, "writing the entry", () => + writeFile( + file, + `${lines.join("\n")}\nconsole.log(${Imports.names(imports).join(", ")});\n`, + ), + ), + ); + const result = yield* R.await( + attempt( + mode, + "the build", + async () => + (await build({ + root: fileURLToPath(root), + logLevel: "silent", + configFile: false, + define: { "process.env.NODE_ENV": JSON.stringify(mode) }, + build: { + write: false, + minify: false, + target: "esnext", + lib: { entry: file, formats: ["es"], fileName: "entry" }, + }, + })) as Rolldown.RolldownOutput[], + ), + ); + const chunk = result[0]?.output.find((output) => output.type === "chunk"); + return chunk?.type === "chunk" + ? R.ok(chunk.code) + : R.err(new NoChunk({ mode, message: `no chunk emitted for ${mode}` })); + }); + // Removed whether or not the build succeeded. + yield* R.await( + attempt(mode, "removing the entry directory", () => + rm(dir, { recursive: true, force: true }), + ), + ); + return built; + }); + +const sizes = (code: string): R => + minify(code).map((minified) => { + const bytes = Buffer.from(minified, "utf8"); + return Sizes({ + minified: bytes.byteLength, + gzip: gzipSync(bytes, { level: 9 }).byteLength, + brotli: brotliCompressSync(bytes, { + params: { + [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY, + [constants.BROTLI_PARAM_SIZE_HINT]: bytes.byteLength, + }, + }).byteLength, + }); + }); + +export const measure = ( + imports: Imports, +): Promise> => + R.gen(async function* () { + const [production, development] = yield* R.await( + R.allAsync([compile("production", imports), compile("development", imports)] as const), + ); + return R.ok({ + imports, + code: { production, development }, + sizes: { production: yield* sizes(production), development: yield* sizes(development) }, + }); + }); /** The bytes the budget caps: a base bundle's whole size, or what another adds to the base. */ -export const capped = ({ bundle, sizes }: Result, base: Measured): number => +export const capped = ({ bundle, sizes }: MeasuredBundle, base: Measured): number => bundle.base ? sizes.production.gzip : sizes.production.gzip - base.sizes.production.gzip; -/** Throws when a production bundle keeps code that only development or another import needs. */ -export function assertProduction({ imports, code }: Measured): void { - const production = minifySync("production.mjs", code.production).code; +/** Fails when a production bundle keeps code that only development or another import needs. */ +export function checkProduction({ + imports, + code, +}: Measured): R { + const minified = minify(code.production); + if (minified.isErr()) return minified; + const production = minified.value; const heading = `import { ${Imports.names(imports).join(", ")} }`; if ( production.includes("snapshotSealInput") || production.includes("unsnapshotable seal input") || production.includes("The payload changed while the custom seal was running.") ) { - throw new Error(`development-only seal stability checks remain in production: ${heading}`); + return R.err( + new DevelopmentCodeRemains({ + imports, + message: `development-only seal stability checks remain in production: ${heading}`, + }), + ); } if (!Imports.names(imports).includes("equals") && production.includes("Number.isNaN")) { - throw new Error(`equals remains in production without its import: ${heading}`); + return R.err( + new EqualsRemains({ + imports, + message: `equals remains in production without its import: ${heading}`, + }), + ); } + return R.ok(undefined); } diff --git a/scripts/bundle-size/suite.ts b/scripts/bundle-size/suite.ts index 81e32b9..ed96724 100644 --- a/scripts/bundle-size/suite.ts +++ b/scripts/bundle-size/suite.ts @@ -1,28 +1,36 @@ +import { Result as R, TaggedError } from "better-result"; import { Val } from "../../src/index.ts"; import { Imports, type Bundle } from "./bundle.ts"; +export class NoBase extends TaggedError("NoBase")<{ message: string }> {} + /** The bundles measured together, against the base they share. */ export type Suite = Val<"Suite", Bundle[]>; export const Suite = Val.sealer().impl({ /** What the base imports: every base bundle's imports, merged. */ - base: (suite): Imports => { + base: (suite): R => { const [first, ...others] = suite .filter((bundle) => bundle.base) .map((bundle) => bundle.imports); - if (first === undefined) throw new Error("no bundle sets base"); - return others.reduce((merged, imports) => Imports.merge(merged, imports), first); + if (first === undefined) return R.err(new NoBase({ message: "no bundle sets base" })); + return R.ok(others.reduce((merged, imports) => Imports.merge(merged, imports), first)); }, /** Measures each bundle with `measure`: a base bundle alone, any other on top of the base. */ - measure: ( + measure: ( suite: Suite, - measure: (imports: Imports) => Promise, - ): Promise<(T & { bundle: Bundle })[]> => { - const base = Suite.base(suite); - return Promise.all( - suite.map(async (bundle) => ({ - bundle, - ...(await measure(bundle.base ? bundle.imports : Imports.merge(base, bundle.imports))), - })), - ); - }, + measure: (imports: Imports) => Promise>, + ): Promise> => + R.gen(async function* () { + const base = yield* Suite.base(suite); + const measured = yield* R.await( + R.allAsync( + suite.map(async (bundle) => + (await measure(bundle.base ? bundle.imports : Imports.merge(base, bundle.imports))).map( + (result) => ({ ...result, bundle }), + ), + ), + ), + ); + return R.ok(measured); + }), }); From 18cc4959647caf6e441b1c4bb1abf215a617c2ef Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:32:00 +0900 Subject: [PATCH 12/52] refactor: move the bundle-size run into Cli.run --- scripts/bundle-size/cli.ts | 48 ++++++++++++++++++++++++++++++++++++ scripts/bundle-size/index.ts | 39 ++--------------------------- 2 files changed, 50 insertions(+), 37 deletions(-) create mode 100644 scripts/bundle-size/cli.ts diff --git a/scripts/bundle-size/cli.ts b/scripts/bundle-size/cli.ts new file mode 100644 index 0000000..bd3dd49 --- /dev/null +++ b/scripts/bundle-size/cli.ts @@ -0,0 +1,48 @@ +import { Result as R } from "better-result"; +import { budgets, json, over, sizes } from "./format.ts"; +import { checkProduction, capped, measure, pack } from "./measure.ts"; +import { Suite } from "./suite.ts"; + +export const Cli = { + /** + * Builds `dist`, measures the suite, and prints its sizes and budgets, or the sizes as JSON with + * `--json`. Sets a failing exit code when a step fails or a bundle is over its budget. + */ + run: async (suite: Suite): Promise => { + const measured = await R.gen(async function* () { + yield* R.await(pack()); + const baseImports = yield* Suite.base(suite); + const results = yield* R.await(Suite.measure(suite, measure)); + const [only, ...more] = results.filter(({ bundle }) => bundle.base); + // With one base bundle, the base is that bundle, already measured. + const base = + only !== undefined && more.length === 0 ? only : yield* R.await(measure(baseImports)); + for (const each of [base, ...results]) yield* checkProduction(each); + return R.ok({ base, results, extra: base === only ? [] : [base] }); + }); + + if (measured.isErr()) { + console.error(measured.error.message); + process.exitCode = 1; + return; + } + const { base, results, extra } = measured.value; + + if (process.argv.includes("--json")) { + console.log(json(results)); + } else { + for (const each of [...extra, ...results]) { + console.log(sizes(each)); + console.log(); + } + console.log(budgets(results, base)); + } + + const exceeded = results.filter((result) => capped(result, base) > result.bundle.budget); + if (exceeded.length > 0) { + console.error(); + for (const result of exceeded) console.error(over(result, base)); + process.exitCode = 1; + } + }, +}; diff --git a/scripts/bundle-size/index.ts b/scripts/bundle-size/index.ts index 401374a..4c271dc 100644 --- a/scripts/bundle-size/index.ts +++ b/scripts/bundle-size/index.ts @@ -1,8 +1,6 @@ import { Bundle, Imports } from "./bundle.ts"; +import { Cli } from "./cli.ts"; import { Suite } from "./suite.ts"; -import { budgets, json, over, sizes } from "./format.ts"; -import { Result as R } from "better-result"; -import { checkProduction, capped, measure, pack } from "./measure.ts"; // `Trait` and `Enum` ship from their own subpath, so a bundle names the module per import. const suite = Suite([ @@ -42,37 +40,4 @@ const suite = Suite([ }), ]); -const measured = await R.gen(async function* () { - yield* R.await(pack()); - const baseImports = yield* Suite.base(suite); - const results = yield* R.await(Suite.measure(suite, measure)); - const [only, ...more] = results.filter(({ bundle }) => bundle.base); - // With one base bundle, the base is that bundle, already measured. - const base = - only !== undefined && more.length === 0 ? only : yield* R.await(measure(baseImports)); - for (const each of [base, ...results]) yield* checkProduction(each); - return R.ok({ base, results, extra: base === only ? [] : [base] }); -}); - -if (measured.isErr()) { - console.error(measured.error.message); - process.exit(1); -} -const { base, results, extra } = measured.value; - -if (process.argv.includes("--json")) { - console.log(json(results)); -} else { - for (const each of [...extra, ...results]) { - console.log(sizes(each)); - console.log(); - } - console.log(budgets(results, base)); -} - -const exceeded = results.filter((result) => capped(result, base) > result.bundle.budget); -if (exceeded.length > 0) { - console.error(); - for (const result of exceeded) console.error(over(result, base)); - process.exit(1); -} +await Cli.run(suite); From a8353dbbfd21605ede293fd656788d4946c49495 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:33:42 +0900 Subject: [PATCH 13/52] refactor: measure the whole suite in Suite.measure --- scripts/bundle-size/cli.ts | 19 +++-------- scripts/bundle-size/suite.ts | 63 ++++++++++++++++++++++++++++++------ 2 files changed, 57 insertions(+), 25 deletions(-) diff --git a/scripts/bundle-size/cli.ts b/scripts/bundle-size/cli.ts index bd3dd49..f3cfe85 100644 --- a/scripts/bundle-size/cli.ts +++ b/scripts/bundle-size/cli.ts @@ -1,25 +1,14 @@ -import { Result as R } from "better-result"; import { budgets, json, over, sizes } from "./format.ts"; -import { checkProduction, capped, measure, pack } from "./measure.ts"; +import { capped } from "./measure.ts"; import { Suite } from "./suite.ts"; export const Cli = { /** - * Builds `dist`, measures the suite, and prints its sizes and budgets, or the sizes as JSON with - * `--json`. Sets a failing exit code when a step fails or a bundle is over its budget. + * Measures the suite, and prints its sizes and budgets, or the sizes as JSON with `--json`. Sets a + * failing exit code when measuring fails or a bundle is over its budget. */ run: async (suite: Suite): Promise => { - const measured = await R.gen(async function* () { - yield* R.await(pack()); - const baseImports = yield* Suite.base(suite); - const results = yield* R.await(Suite.measure(suite, measure)); - const [only, ...more] = results.filter(({ bundle }) => bundle.base); - // With one base bundle, the base is that bundle, already measured. - const base = - only !== undefined && more.length === 0 ? only : yield* R.await(measure(baseImports)); - for (const each of [base, ...results]) yield* checkProduction(each); - return R.ok({ base, results, extra: base === only ? [] : [base] }); - }); + const measured = await Suite.measure(suite); if (measured.isErr()) { console.error(measured.error.message); diff --git a/scripts/bundle-size/suite.ts b/scripts/bundle-size/suite.ts index ed96724..3cb7710 100644 --- a/scripts/bundle-size/suite.ts +++ b/scripts/bundle-size/suite.ts @@ -1,9 +1,31 @@ import { Result as R, TaggedError } from "better-result"; import { Val } from "../../src/index.ts"; import { Imports, type Bundle } from "./bundle.ts"; +import { + checkProduction, + measure, + pack, + type CompileFailed, + type DevelopmentCodeRemains, + type EqualsRemains, + type Measured, + type MeasuredBundle, + type MinifyFailed, + type NoChunk, + type PackFailed, +} from "./measure.ts"; export class NoBase extends TaggedError("NoBase")<{ message: string }> {} +/** What measuring a suite found. */ +export type Measurement = { + /** The base, which every bundle not in it is measured on top of. */ + base: Measured; + results: MeasuredBundle[]; + /** The base again, when it was measured apart from every bundle, so `results` leaves it out. */ + extra: Measured[]; +}; + /** The bundles measured together, against the base they share. */ export type Suite = Val<"Suite", Bundle[]>; export const Suite = Val.sealer().impl({ @@ -15,22 +37,43 @@ export const Suite = Val.sealer().impl({ if (first === undefined) return R.err(new NoBase({ message: "no bundle sets base" })); return R.ok(others.reduce((merged, imports) => Imports.merge(merged, imports), first)); }, - /** Measures each bundle with `measure`: a base bundle alone, any other on top of the base. */ - measure: ( + /** + * Builds `dist`, and measures each bundle: a base bundle alone, any other on top of the base. + * Fails when a production bundle keeps code it should not. + */ + measure: ( suite: Suite, - measure: (imports: Imports) => Promise>, - ): Promise> => + ): Promise< + R< + Measurement, + | PackFailed + | NoBase + | NoChunk + | CompileFailed + | MinifyFailed + | DevelopmentCodeRemains + | EqualsRemains + > + > => R.gen(async function* () { - const base = yield* Suite.base(suite); - const measured = yield* R.await( + yield* R.await(pack()); + const baseImports = yield* Suite.base(suite); + const results = yield* R.await( R.allAsync( suite.map(async (bundle) => - (await measure(bundle.base ? bundle.imports : Imports.merge(base, bundle.imports))).map( - (result) => ({ ...result, bundle }), - ), + ( + await measure( + bundle.base ? bundle.imports : Imports.merge(baseImports, bundle.imports), + ) + ).map((result) => ({ ...result, bundle })), ), ), ); - return R.ok(measured); + const [only, ...more] = results.filter(({ bundle }) => bundle.base); + // With one base bundle, the base is that bundle, already measured. + const base = + only !== undefined && more.length === 0 ? only : yield* R.await(measure(baseImports)); + for (const each of [base, ...results]) yield* checkProduction(each); + return R.ok({ base, results, extra: base === only ? [] : [base] }); }), }); From d71ffe4d918e8e5a6f0fcabf9d858fb95cca1083 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:35:50 +0900 Subject: [PATCH 14/52] refactor: branch Cli.run on the measured result with match --- scripts/bundle-size/cli.ts | 46 +++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/scripts/bundle-size/cli.ts b/scripts/bundle-size/cli.ts index f3cfe85..1f6f267 100644 --- a/scripts/bundle-size/cli.ts +++ b/scripts/bundle-size/cli.ts @@ -8,30 +8,26 @@ export const Cli = { * failing exit code when measuring fails or a bundle is over its budget. */ run: async (suite: Suite): Promise => { - const measured = await Suite.measure(suite); - - if (measured.isErr()) { - console.error(measured.error.message); - process.exitCode = 1; - return; - } - const { base, results, extra } = measured.value; - - if (process.argv.includes("--json")) { - console.log(json(results)); - } else { - for (const each of [...extra, ...results]) { - console.log(sizes(each)); - console.log(); - } - console.log(budgets(results, base)); - } - - const exceeded = results.filter((result) => capped(result, base) > result.bundle.budget); - if (exceeded.length > 0) { - console.error(); - for (const result of exceeded) console.error(over(result, base)); - process.exitCode = 1; - } + process.exitCode = (await Suite.measure(suite)).match({ + ok: ({ base, results, extra }) => { + console.log( + process.argv.includes("--json") + ? json(results) + : [ + ...[...extra, ...results].map((each) => `${sizes(each)}\n`), + budgets(results, base), + ].join("\n"), + ); + const overs = results + .filter((result) => capped(result, base) > result.bundle.budget) + .map((result) => over(result, base)); + for (const line of overs.length > 0 ? ["", ...overs] : []) console.error(line); + return overs.length > 0 ? 1 : 0; + }, + err: (error) => { + console.error(error.message); + return 1; + }, + }); }, }; From ba01a2a1310746f972710a0c195b0bae14e1a3c6 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:39:42 +0900 Subject: [PATCH 15/52] refactor: build the bundle-size output in report --- scripts/bundle-size/cli.ts | 14 ++++---------- scripts/bundle-size/format.ts | 16 +++++++++++++--- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/scripts/bundle-size/cli.ts b/scripts/bundle-size/cli.ts index 1f6f267..780d9a3 100644 --- a/scripts/bundle-size/cli.ts +++ b/scripts/bundle-size/cli.ts @@ -1,4 +1,4 @@ -import { budgets, json, over, sizes } from "./format.ts"; +import { over, report } from "./format.ts"; import { capped } from "./measure.ts"; import { Suite } from "./suite.ts"; @@ -9,15 +9,9 @@ export const Cli = { */ run: async (suite: Suite): Promise => { process.exitCode = (await Suite.measure(suite)).match({ - ok: ({ base, results, extra }) => { - console.log( - process.argv.includes("--json") - ? json(results) - : [ - ...[...extra, ...results].map((each) => `${sizes(each)}\n`), - budgets(results, base), - ].join("\n"), - ); + ok: (measurement) => { + const { base, results } = measurement; + console.log(report(measurement, process.argv.includes("--json"))); const overs = results .filter((result) => capped(result, base) > result.bundle.budget) .map((result) => over(result, base)); diff --git a/scripts/bundle-size/format.ts b/scripts/bundle-size/format.ts index 10bce85..82070cc 100644 --- a/scripts/bundle-size/format.ts +++ b/scripts/bundle-size/format.ts @@ -1,5 +1,6 @@ import { Imports } from "./bundle.ts"; import { capped, modes, type Measured, type MeasuredBundle, type Sizes } from "./measure.ts"; +import type { Measurement } from "./suite.ts"; function format(bytes: number): string { return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(2)} kB`; @@ -22,7 +23,7 @@ const label = ({ bundle }: MeasuredBundle): string => bundle.base ? bundle.name : `added by ${bundle.name}`; /** A bundle's heading and its sizes in each mode. */ -export function sizes({ imports, sizes }: Measured): string { +function sizes({ imports, sizes }: Measured): string { const heads = ["minified", "gzip", "brotli"] as const; const rows = align( [ @@ -38,7 +39,7 @@ export function sizes({ imports, sizes }: Measured): string { } /** Each bundle's capped bytes against its budget. */ -export function budgets(results: readonly MeasuredBundle[], base: Measured): string { +function budgets(results: readonly MeasuredBundle[], base: Measured): string { const rows = results.map((result) => { const bytes = capped(result, base); const max = result.bundle.budget; @@ -61,9 +62,18 @@ export function over(result: MeasuredBundle, base: Measured): string { } /** The `--json` output, keyed as `replace-bundle-size-claims` reads it. */ -export function json(results: readonly MeasuredBundle[]): string { +function json(results: readonly MeasuredBundle[]): string { const keyed: Record> = Object.fromEntries( results.map(({ bundle, sizes }) => [bundle.key, sizes]), ); return JSON.stringify(keyed, null, 2); } + +/** What a run prints: every bundle's sizes and the budgets, or the sizes as JSON. */ +export function report({ base, results, extra }: Measurement, asJson: boolean): string { + return asJson + ? json(results) + : [...[...extra, ...results].map((each) => `${sizes(each)}\n`), budgets(results, base)].join( + "\n", + ); +} From 55ec20fe56745d1428f68ae8f1f901ba270c8702 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:40:34 +0900 Subject: [PATCH 16/52] refactor: carry each bundle's capped bytes on MeasuredBundle --- scripts/bundle-size/cli.ts | 8 +++----- scripts/bundle-size/format.ts | 16 +++++++--------- scripts/bundle-size/measure.ts | 16 ++++++++++++---- scripts/bundle-size/suite.ts | 7 ++++++- 4 files changed, 28 insertions(+), 19 deletions(-) diff --git a/scripts/bundle-size/cli.ts b/scripts/bundle-size/cli.ts index 780d9a3..7e401ec 100644 --- a/scripts/bundle-size/cli.ts +++ b/scripts/bundle-size/cli.ts @@ -1,5 +1,4 @@ import { over, report } from "./format.ts"; -import { capped } from "./measure.ts"; import { Suite } from "./suite.ts"; export const Cli = { @@ -10,11 +9,10 @@ export const Cli = { run: async (suite: Suite): Promise => { process.exitCode = (await Suite.measure(suite)).match({ ok: (measurement) => { - const { base, results } = measurement; console.log(report(measurement, process.argv.includes("--json"))); - const overs = results - .filter((result) => capped(result, base) > result.bundle.budget) - .map((result) => over(result, base)); + const overs = measurement.results + .filter((result) => result.capped > result.bundle.budget) + .map(over); for (const line of overs.length > 0 ? ["", ...overs] : []) console.error(line); return overs.length > 0 ? 1 : 0; }, diff --git a/scripts/bundle-size/format.ts b/scripts/bundle-size/format.ts index 82070cc..997c349 100644 --- a/scripts/bundle-size/format.ts +++ b/scripts/bundle-size/format.ts @@ -1,5 +1,5 @@ import { Imports } from "./bundle.ts"; -import { capped, modes, type Measured, type MeasuredBundle, type Sizes } from "./measure.ts"; +import { modes, type Measured, type MeasuredBundle, type Sizes } from "./measure.ts"; import type { Measurement } from "./suite.ts"; function format(bytes: number): string { @@ -39,9 +39,9 @@ function sizes({ imports, sizes }: Measured): string { } /** Each bundle's capped bytes against its budget. */ -function budgets(results: readonly MeasuredBundle[], base: Measured): string { +function budgets(results: readonly MeasuredBundle[]): string { const rows = results.map((result) => { - const bytes = capped(result, base); + const bytes = result.capped; const max = result.bundle.budget; const left = bytes > max @@ -57,8 +57,8 @@ function budgets(results: readonly MeasuredBundle[], base: Measured): string { ].join("\n"); } -export function over(result: MeasuredBundle, base: Measured): string { - return `over budget: ${label(result)} is ${amount(result, capped(result, base))}, budget ${amount(result, result.bundle.budget)}`; +export function over(result: MeasuredBundle): string { + return `over budget: ${label(result)} is ${amount(result, result.capped)}, budget ${amount(result, result.bundle.budget)}`; } /** The `--json` output, keyed as `replace-bundle-size-claims` reads it. */ @@ -70,10 +70,8 @@ function json(results: readonly MeasuredBundle[]): string { } /** What a run prints: every bundle's sizes and the budgets, or the sizes as JSON. */ -export function report({ base, results, extra }: Measurement, asJson: boolean): string { +export function report({ results, extra }: Measurement, asJson: boolean): string { return asJson ? json(results) - : [...[...extra, ...results].map((each) => `${sizes(each)}\n`), budgets(results, base)].join( - "\n", - ); + : [...[...extra, ...results].map((each) => `${sizes(each)}\n`), budgets(results)].join("\n"); } diff --git a/scripts/bundle-size/measure.ts b/scripts/bundle-size/measure.ts index 7a2a3b7..c4a595b 100644 --- a/scripts/bundle-size/measure.ts +++ b/scripts/bundle-size/measure.ts @@ -25,7 +25,11 @@ export type Measured = { }; /** A bundle, measured with the base under it unless it joins the base. */ -export type MeasuredBundle = Measured & { bundle: Bundle }; +export type MeasuredBundle = Measured & { + bundle: Bundle; + /** The bytes the budget caps: a base bundle's whole size, or what another adds to the base. */ + capped: number; +}; export class PackFailed extends TaggedError("PackFailed")<{ message: string }> {} @@ -162,9 +166,13 @@ export const measure = ( }); }); -/** The bytes the budget caps: a base bundle's whole size, or what another adds to the base. */ -export const capped = ({ bundle, sizes }: MeasuredBundle, base: Measured): number => - bundle.base ? sizes.production.gzip : sizes.production.gzip - base.sizes.production.gzip; +/** Adds the bytes its budget caps, which {@link MeasuredBundle.capped} describes. */ +export const cap = (measured: Measured & { bundle: Bundle }, base: Measured): MeasuredBundle => ({ + ...measured, + capped: measured.bundle.base + ? measured.sizes.production.gzip + : measured.sizes.production.gzip - base.sizes.production.gzip, +}); /** Fails when a production bundle keeps code that only development or another import needs. */ export function checkProduction({ diff --git a/scripts/bundle-size/suite.ts b/scripts/bundle-size/suite.ts index 3cb7710..77d6fe4 100644 --- a/scripts/bundle-size/suite.ts +++ b/scripts/bundle-size/suite.ts @@ -2,6 +2,7 @@ import { Result as R, TaggedError } from "better-result"; import { Val } from "../../src/index.ts"; import { Imports, type Bundle } from "./bundle.ts"; import { + cap, checkProduction, measure, pack, @@ -74,6 +75,10 @@ export const Suite = Val.sealer().impl({ const base = only !== undefined && more.length === 0 ? only : yield* R.await(measure(baseImports)); for (const each of [base, ...results]) yield* checkProduction(each); - return R.ok({ base, results, extra: base === only ? [] : [base] }); + return R.ok({ + base, + results: results.map((result) => cap(result, base)), + extra: base === only ? [] : [base], + }); }), }); From 10d90e616762d97eecb214330b3611018027752a Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:42:11 +0900 Subject: [PATCH 17/52] refactor: model bundle-size measurements as Build, Measured and MeasuredBundle values --- scripts/bundle-size/cli.ts | 5 +- scripts/bundle-size/format.ts | 20 ++++-- scripts/bundle-size/measure.ts | 113 +++++++++++++++++---------------- scripts/bundle-size/suite.ts | 23 ++++--- 4 files changed, 89 insertions(+), 72 deletions(-) diff --git a/scripts/bundle-size/cli.ts b/scripts/bundle-size/cli.ts index 7e401ec..a5dab4d 100644 --- a/scripts/bundle-size/cli.ts +++ b/scripts/bundle-size/cli.ts @@ -1,4 +1,5 @@ import { over, report } from "./format.ts"; +import { MeasuredBundle } from "./measure.ts"; import { Suite } from "./suite.ts"; export const Cli = { @@ -10,9 +11,7 @@ export const Cli = { process.exitCode = (await Suite.measure(suite)).match({ ok: (measurement) => { console.log(report(measurement, process.argv.includes("--json"))); - const overs = measurement.results - .filter((result) => result.capped > result.bundle.budget) - .map(over); + const overs = measurement.results.filter(MeasuredBundle.over).map(over); for (const line of overs.length > 0 ? ["", ...overs] : []) console.error(line); return overs.length > 0 ? 1 : 0; }, diff --git a/scripts/bundle-size/format.ts b/scripts/bundle-size/format.ts index 997c349..50f649d 100644 --- a/scripts/bundle-size/format.ts +++ b/scripts/bundle-size/format.ts @@ -1,5 +1,5 @@ import { Imports } from "./bundle.ts"; -import { modes, type Measured, type MeasuredBundle, type Sizes } from "./measure.ts"; +import { modes, type Measured, type MeasuredBundle } from "./measure.ts"; import type { Measurement } from "./suite.ts"; function format(bytes: number): string { @@ -23,17 +23,17 @@ const label = ({ bundle }: MeasuredBundle): string => bundle.base ? bundle.name : `added by ${bundle.name}`; /** A bundle's heading and its sizes in each mode. */ -function sizes({ imports, sizes }: Measured): string { +function sizes(measured: Measured): string { const heads = ["minified", "gzip", "brotli"] as const; const rows = align( [ ["", ...heads], - ...modes.map((mode) => [mode, ...heads.map((head) => format(sizes[mode][head]))]), + ...modes.map((mode) => [mode, ...heads.map((head) => format(measured[mode].sizes[head]))]), ], ["left", "right", "right", "right"], ); return [ - `bundle import { ${Imports.names(imports).join(", ")} }`, + `bundle import { ${Imports.names(measured.imports).join(", ")} }`, ...rows.map((cells) => ` ${cells.join(" ")}`), ].join("\n"); } @@ -63,8 +63,11 @@ export function over(result: MeasuredBundle): string { /** The `--json` output, keyed as `replace-bundle-size-claims` reads it. */ function json(results: readonly MeasuredBundle[]): string { - const keyed: Record> = Object.fromEntries( - results.map(({ bundle, sizes }) => [bundle.key, sizes]), + const keyed = Object.fromEntries( + results.map(({ bundle, measured }) => [ + bundle.key, + Object.fromEntries(modes.map((mode) => [mode, measured[mode].sizes])), + ]), ); return JSON.stringify(keyed, null, 2); } @@ -73,5 +76,8 @@ function json(results: readonly MeasuredBundle[]): string { export function report({ results, extra }: Measurement, asJson: boolean): string { return asJson ? json(results) - : [...[...extra, ...results].map((each) => `${sizes(each)}\n`), budgets(results)].join("\n"); + : [ + ...[...extra, ...results.map(({ measured }) => measured)].map((each) => `${sizes(each)}\n`), + budgets(results), + ].join("\n"); } diff --git a/scripts/bundle-size/measure.ts b/scripts/bundle-size/measure.ts index c4a595b..956125d 100644 --- a/scripts/bundle-size/measure.ts +++ b/scripts/bundle-size/measure.ts @@ -18,18 +18,63 @@ export const modes: readonly Mode[] = ["production", "development"]; export type Sizes = Val<"Sizes", { minified: number; gzip: number; brotli: number }>; export const Sizes = Val.sealer(); -export type Measured = { - imports: Imports; - code: Record; - sizes: Record; -}; +/** One mode's build of a bundle: its code, and what it weighs. */ +export type Build = Val<"Build", { code: string; sizes: Sizes }>; +export const Build = Val.sealer(); + +/** What a set of imports weighs, built in each mode. */ +export type Measured = Val<"Measured", { imports: Imports; production: Build; development: Build }>; +export const Measured = Val.sealer().impl({ + /** Fails when the production build keeps code that only development or another import needs. */ + check: (measured): R => + minify(measured.production.code).andThen((production) => { + const { imports } = measured; + const heading = `import { ${Imports.names(imports).join(", ")} }`; + if ( + production.includes("snapshotSealInput") || + production.includes("unsnapshotable seal input") || + production.includes("The payload changed while the custom seal was running.") + ) { + return R.err( + new DevelopmentCodeRemains({ + imports, + message: `development-only seal stability checks remain in production: ${heading}`, + }), + ); + } + if (!Imports.names(imports).includes("equals") && production.includes("Number.isNaN")) { + return R.err( + new EqualsRemains({ + imports, + message: `equals remains in production without its import: ${heading}`, + }), + ); + } + return R.ok(undefined); + }), +}); /** A bundle, measured with the base under it unless it joins the base. */ -export type MeasuredBundle = Measured & { - bundle: Bundle; - /** The bytes the budget caps: a base bundle's whole size, or what another adds to the base. */ - capped: number; -}; +export type MeasuredBundle = Val< + "MeasuredBundle", + { + bundle: Bundle; + measured: Measured; + /** The bytes the budget caps: a base bundle's whole size, or what another adds to the base. */ + capped: number; + } +>; +export const MeasuredBundle = Val.companion() + .implCreate((bundle: Bundle, measured: Measured, base: Measured) => ({ + bundle, + measured, + capped: bundle.base + ? measured.production.sizes.gzip + : measured.production.sizes.gzip - base.production.sizes.gzip, + })) + .impl({ + over: (result): boolean => result.capped > result.bundle.budget, + }); export class PackFailed extends TaggedError("PackFailed")<{ message: string }> {} @@ -159,49 +204,11 @@ export const measure = ( const [production, development] = yield* R.await( R.allAsync([compile("production", imports), compile("development", imports)] as const), ); - return R.ok({ - imports, - code: { production, development }, - sizes: { production: yield* sizes(production), development: yield* sizes(development) }, - }); - }); - -/** Adds the bytes its budget caps, which {@link MeasuredBundle.capped} describes. */ -export const cap = (measured: Measured & { bundle: Bundle }, base: Measured): MeasuredBundle => ({ - ...measured, - capped: measured.bundle.base - ? measured.sizes.production.gzip - : measured.sizes.production.gzip - base.sizes.production.gzip, -}); - -/** Fails when a production bundle keeps code that only development or another import needs. */ -export function checkProduction({ - imports, - code, -}: Measured): R { - const minified = minify(code.production); - if (minified.isErr()) return minified; - const production = minified.value; - const heading = `import { ${Imports.names(imports).join(", ")} }`; - if ( - production.includes("snapshotSealInput") || - production.includes("unsnapshotable seal input") || - production.includes("The payload changed while the custom seal was running.") - ) { - return R.err( - new DevelopmentCodeRemains({ + return R.ok( + Measured({ imports, - message: `development-only seal stability checks remain in production: ${heading}`, + production: Build({ code: production, sizes: yield* sizes(production) }), + development: Build({ code: development, sizes: yield* sizes(development) }), }), ); - } - if (!Imports.names(imports).includes("equals") && production.includes("Number.isNaN")) { - return R.err( - new EqualsRemains({ - imports, - message: `equals remains in production without its import: ${heading}`, - }), - ); - } - return R.ok(undefined); -} + }); diff --git a/scripts/bundle-size/suite.ts b/scripts/bundle-size/suite.ts index 77d6fe4..77ac8e3 100644 --- a/scripts/bundle-size/suite.ts +++ b/scripts/bundle-size/suite.ts @@ -2,15 +2,13 @@ import { Result as R, TaggedError } from "better-result"; import { Val } from "../../src/index.ts"; import { Imports, type Bundle } from "./bundle.ts"; import { - cap, - checkProduction, + Measured, + MeasuredBundle, measure, pack, type CompileFailed, type DevelopmentCodeRemains, type EqualsRemains, - type Measured, - type MeasuredBundle, type MinifyFailed, type NoChunk, type PackFailed, @@ -66,19 +64,26 @@ export const Suite = Val.sealer().impl({ await measure( bundle.base ? bundle.imports : Imports.merge(baseImports, bundle.imports), ) - ).map((result) => ({ ...result, bundle })), + ).map((measured) => ({ bundle, measured })), ), ), ); const [only, ...more] = results.filter(({ bundle }) => bundle.base); // With one base bundle, the base is that bundle, already measured. const base = - only !== undefined && more.length === 0 ? only : yield* R.await(measure(baseImports)); - for (const each of [base, ...results]) yield* checkProduction(each); + only !== undefined && more.length === 0 + ? only.measured + : yield* R.await(measure(baseImports)); + const extra = base === only?.measured ? [] : [base]; + for (const measured of [...extra, ...results.map(({ measured }) => measured)]) { + yield* Measured.check(measured); + } return R.ok({ base, - results: results.map((result) => cap(result, base)), - extra: base === only ? [] : [base], + results: results.map(({ bundle, measured }) => + MeasuredBundle.create(bundle, measured, base), + ), + extra, }); }), }); From 6fa0e0c4c8eba17d1899ebc3bbf244fe603d762e Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:47:25 +0900 Subject: [PATCH 18/52] refactor: make Measurement a value --- scripts/bundle-size/suite.ts | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/scripts/bundle-size/suite.ts b/scripts/bundle-size/suite.ts index 77ac8e3..0998ed0 100644 --- a/scripts/bundle-size/suite.ts +++ b/scripts/bundle-size/suite.ts @@ -17,13 +17,17 @@ import { export class NoBase extends TaggedError("NoBase")<{ message: string }> {} /** What measuring a suite found. */ -export type Measurement = { - /** The base, which every bundle not in it is measured on top of. */ - base: Measured; - results: MeasuredBundle[]; - /** The base again, when it was measured apart from every bundle, so `results` leaves it out. */ - extra: Measured[]; -}; +export type Measurement = Val< + "Measurement", + { + /** The base, which every bundle not in it is measured on top of. */ + base: Measured; + results: MeasuredBundle[]; + /** The base again, when it was measured apart from every bundle, so `results` leaves it out. */ + extra: Measured[]; + } +>; +export const Measurement = Val.sealer(); /** The bundles measured together, against the base they share. */ export type Suite = Val<"Suite", Bundle[]>; @@ -78,12 +82,14 @@ export const Suite = Val.sealer().impl({ for (const measured of [...extra, ...results.map(({ measured }) => measured)]) { yield* Measured.check(measured); } - return R.ok({ - base, - results: results.map(({ bundle, measured }) => - MeasuredBundle.create(bundle, measured, base), - ), - extra, - }); + return R.ok( + Measurement({ + base, + results: results.map(({ bundle, measured }) => + MeasuredBundle.create(bundle, measured, base), + ), + extra, + }), + ); }), }); From 92c287c87ce63dc288938c5b8f8a7df3274fc43b Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:01:07 +0900 Subject: [PATCH 19/52] chore: check bundle-size builds by comparing their sizes --- scripts/bundle-size/measure.ts | 193 ++++++++++++++++++++------------- scripts/bundle-size/suite.ts | 24 ++-- 2 files changed, 127 insertions(+), 90 deletions(-) diff --git a/scripts/bundle-size/measure.ts b/scripts/bundle-size/measure.ts index 956125d..abcf05f 100644 --- a/scripts/bundle-size/measure.ts +++ b/scripts/bundle-size/measure.ts @@ -1,7 +1,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { gzipSync, brotliCompressSync, constants } from "node:zlib"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -10,7 +10,28 @@ import { minifySync, build, type Rolldown } from "vite"; import { Val } from "../../src/index.ts"; import { Imports, type Bundle } from "./bundle.ts"; -const root = new URL("../../", import.meta.url); +export class PackFailed extends TaggedError("PackFailed")<{ message: string }> {} + +export class NoChunk extends TaggedError("NoChunk")<{ mode: Mode; message: string }> {} + +/** Something a bundle's compiling step could not do: the temporary file, or the build itself. */ +export class CompileFailed extends TaggedError("CompileFailed")<{ + mode: Mode; + cause: unknown; + message: string; +}> {} + +export class MinifyFailed extends TaggedError("MinifyFailed")<{ + cause: unknown; + message: string; +}> {} + +export class BranchIgnored extends TaggedError("BranchIgnored")<{ + imports: Imports; + message: string; +}> {} + +export class AddsNothing extends TaggedError("AddsNothing")<{ bundle: Bundle; message: string }> {} export type Mode = "production" | "development"; export const modes: readonly Mode[] = ["production", "development"]; @@ -19,39 +40,42 @@ export type Sizes = Val<"Sizes", { minified: number; gzip: number; brotli: numbe export const Sizes = Val.sealer(); /** One mode's build of a bundle: its code, and what it weighs. */ -export type Build = Val<"Build", { code: string; sizes: Sizes }>; +export type Build = Val< + "Build", + { + code: string; + sizes: Sizes; + /** The code's length, leaving out the entry that imports the library. */ + library: number; + } +>; export const Build = Val.sealer(); /** What a set of imports weighs, built in each mode. */ -export type Measured = Val<"Measured", { imports: Imports; production: Build; development: Build }>; +export type Measured = Val< + "Measured", + { + imports: Imports; + /** Whether the bundled library branches on `NODE_ENV`. */ + branches: boolean; + production: Build; + development: Build; + } +>; export const Measured = Val.sealer().impl({ - /** Fails when the production build keeps code that only development or another import needs. */ - check: (measured): R => - minify(measured.production.code).andThen((production) => { - const { imports } = measured; - const heading = `import { ${Imports.names(imports).join(", ")} }`; - if ( - production.includes("snapshotSealInput") || - production.includes("unsnapshotable seal input") || - production.includes("The payload changed while the custom seal was running.") - ) { - return R.err( - new DevelopmentCodeRemains({ - imports, - message: `development-only seal stability checks remain in production: ${heading}`, + /** + * Fails when the library branches on `NODE_ENV`, yet both builds weigh the same: production + * keeps the code only development needs. + */ + check: (measured): R => + measured.branches && measured.production.sizes.minified === measured.development.sizes.minified + ? R.err( + new BranchIgnored({ + imports: measured.imports, + message: `production keeps the development branches: import { ${Imports.names(measured.imports).join(", ")} }`, }), - ); - } - if (!Imports.names(imports).includes("equals") && production.includes("Number.isNaN")) { - return R.err( - new EqualsRemains({ - imports, - message: `equals remains in production without its import: ${heading}`, - }), - ); - } - return R.ok(undefined); - }), + ) + : R.ok(undefined), }); /** A bundle, measured with the base under it unless it joins the base. */ @@ -74,25 +98,26 @@ export const MeasuredBundle = Val.companion() })) .impl({ over: (result): boolean => result.capped > result.bundle.budget, + /** + * Fails when a bundle on top of the base adds no library code to it: what it imports is in the + * base already. + */ + check: (result, base: Measured): R => + !result.bundle.base && result.measured.production.library === base.production.library + ? R.err( + new AddsNothing({ + bundle: result.bundle, + message: `${result.bundle.name} adds nothing to the base, which already holds it`, + }), + ) + : R.ok(undefined), }); -export class PackFailed extends TaggedError("PackFailed")<{ message: string }> {} - -export class NoChunk extends TaggedError("NoChunk")<{ mode: Mode; message: string }> {} - -export class DevelopmentCodeRemains extends TaggedError("DevelopmentCodeRemains")<{ - imports: Imports; - message: string; -}> {} - -export class EqualsRemains extends TaggedError("EqualsRemains")<{ - imports: Imports; - message: string; -}> {} +const root = new URL("../../", import.meta.url); /** Builds `dist`, so the bundles import what a user installs. */ -export const pack = (): Promise> => - R.tryPromise({ +export function pack(): Promise> { + return R.tryPromise({ try: async () => { await promisify(execFile)(fileURLToPath(new URL("node_modules/.bin/vp", root)), ["pack"], { cwd: fileURLToPath(root), @@ -104,34 +129,28 @@ export const pack = (): Promise> => return new PackFailed({ message: `vp pack failed\n${output}` }); }, }); +} -/** Something a bundle's compiling step could not do: the temporary file, or the build itself. */ -export class CompileFailed extends TaggedError("CompileFailed")<{ - mode: Mode; - cause: unknown; - message: string; -}> {} - -export class MinifyFailed extends TaggedError("MinifyFailed")<{ - cause: unknown; - message: string; -}> {} - -const attempt = (mode: Mode, step: string, run: () => Promise) => - R.tryPromise({ +function attempt(mode: Mode, step: string, run: () => Promise): Promise> { + return R.tryPromise({ try: run, catch: (cause) => new CompileFailed({ mode, cause, message: `${step} failed for ${mode}: ${String(cause)}` }), }); +} -const minify = (code: string): R => - R.try({ +function minify(code: string): R { + return R.try({ try: () => minifySync("bundle.mjs", code).code, catch: (cause) => new MinifyFailed({ cause, message: `minify failed: ${String(cause)}` }), }); +} -const compile = (mode: Mode, imports: Imports): Promise> => - R.gen(async function* () { +/** A bundle's code, its library modules, and the code's length without the entry. */ +type Compiled = { code: string; modules: string[]; library: number }; + +function compile(mode: Mode, imports: Imports): Promise> { + return R.gen(async function* () { const dir = yield* R.await( attempt(mode, "creating the entry directory", () => mkdtemp(join(tmpdir(), "valof-size-"))), ); @@ -169,9 +188,17 @@ const compile = (mode: Mode, imports: Imports): Promise output.type === "chunk"); - return chunk?.type === "chunk" - ? R.ok(chunk.code) - : R.err(new NoChunk({ mode, message: `no chunk emitted for ${mode}` })); + if (chunk?.type !== "chunk") { + return R.err(new NoChunk({ mode, message: `no chunk emitted for ${mode}` })); + } + const entry = chunk.facadeModuleId === null ? undefined : chunk.modules[chunk.facadeModuleId]; + return R.ok({ + code: chunk.code, + modules: chunk.moduleIds.filter((id) => id !== chunk.facadeModuleId), + // A module's own `renderedLength` counts code that tree-shaking drops, so the library is + // what the chunk holds beyond its entry. + library: chunk.code.length - (entry?.renderedLength ?? 0), + }); }); // Removed whether or not the build succeeded. yield* R.await( @@ -181,9 +208,10 @@ const compile = (mode: Mode, imports: Imports): Promise => - minify(code).map((minified) => { +function sizes(code: string): R { + return minify(code).map((minified) => { const bytes = Buffer.from(minified, "utf8"); return Sizes({ minified: bytes.byteLength, @@ -196,19 +224,38 @@ const sizes = (code: string): R => }).byteLength, }); }); +} + +/** Whether a module's source reads `NODE_ENV`, before `define` replaces it. */ +function branches(modules: readonly string[], mode: Mode): Promise> { + return attempt(mode, "reading the library", async () => { + const sources = await Promise.all(modules.map((module) => readFile(module, "utf8"))); + return sources.some((source) => source.includes("NODE_ENV")); + }); +} -export const measure = ( +export function measure( imports: Imports, -): Promise> => - R.gen(async function* () { +): Promise> { + return R.gen(async function* () { const [production, development] = yield* R.await( R.allAsync([compile("production", imports), compile("development", imports)] as const), ); return R.ok( Measured({ imports, - production: Build({ code: production, sizes: yield* sizes(production) }), - development: Build({ code: development, sizes: yield* sizes(development) }), + branches: yield* R.await(branches(development.modules, "development")), + production: Build({ + code: production.code, + sizes: yield* sizes(production.code), + library: production.library, + }), + development: Build({ + code: development.code, + sizes: yield* sizes(development.code), + library: development.library, + }), }), ); }); +} diff --git a/scripts/bundle-size/suite.ts b/scripts/bundle-size/suite.ts index 0998ed0..0589614 100644 --- a/scripts/bundle-size/suite.ts +++ b/scripts/bundle-size/suite.ts @@ -6,9 +6,9 @@ import { MeasuredBundle, measure, pack, + type AddsNothing, + type BranchIgnored, type CompileFailed, - type DevelopmentCodeRemains, - type EqualsRemains, type MinifyFailed, type NoChunk, type PackFailed, @@ -49,13 +49,7 @@ export const Suite = Val.sealer().impl({ ): Promise< R< Measurement, - | PackFailed - | NoBase - | NoChunk - | CompileFailed - | MinifyFailed - | DevelopmentCodeRemains - | EqualsRemains + PackFailed | NoBase | NoChunk | CompileFailed | MinifyFailed | BranchIgnored | AddsNothing > > => R.gen(async function* () { @@ -82,14 +76,10 @@ export const Suite = Val.sealer().impl({ for (const measured of [...extra, ...results.map(({ measured }) => measured)]) { yield* Measured.check(measured); } - return R.ok( - Measurement({ - base, - results: results.map(({ bundle, measured }) => - MeasuredBundle.create(bundle, measured, base), - ), - extra, - }), + const bundles = results.map(({ bundle, measured }) => + MeasuredBundle.create(bundle, measured, base), ); + for (const result of bundles) yield* MeasuredBundle.check(result, base); + return R.ok(Measurement({ base, results: bundles, extra })); }), }); From 79dc26d624f431794ef6b005cf5ed56984be53d7 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:01:13 +0900 Subject: [PATCH 20/52] refactor: split the bundle-size toolchain out of measure, declaring functions with function --- scripts/bundle-size/cli.ts | 2 +- scripts/bundle-size/format.ts | 12 ++- scripts/bundle-size/measure/index.ts | 90 ++++++++++++++++++ .../{measure.ts => measure/toolchain.ts} | 93 +------------------ scripts/bundle-size/suite.ts | 7 +- 5 files changed, 103 insertions(+), 101 deletions(-) create mode 100644 scripts/bundle-size/measure/index.ts rename scripts/bundle-size/{measure.ts => measure/toolchain.ts} (67%) diff --git a/scripts/bundle-size/cli.ts b/scripts/bundle-size/cli.ts index a5dab4d..dfba23a 100644 --- a/scripts/bundle-size/cli.ts +++ b/scripts/bundle-size/cli.ts @@ -1,5 +1,5 @@ import { over, report } from "./format.ts"; -import { MeasuredBundle } from "./measure.ts"; +import { MeasuredBundle } from "./measure/index.ts"; import { Suite } from "./suite.ts"; export const Cli = { diff --git a/scripts/bundle-size/format.ts b/scripts/bundle-size/format.ts index 50f649d..948640a 100644 --- a/scripts/bundle-size/format.ts +++ b/scripts/bundle-size/format.ts @@ -1,5 +1,5 @@ import { Imports } from "./bundle.ts"; -import { modes, type Measured, type MeasuredBundle } from "./measure.ts"; +import { modes, type Measured, type MeasuredBundle } from "./measure/index.ts"; import type { Measurement } from "./suite.ts"; function format(bytes: number): string { @@ -16,11 +16,13 @@ function align(rows: readonly (readonly string[])[], sides: readonly ("left" | " return rows.map((_, index) => columns.map((cells) => cells[index] ?? "")); } -const amount = ({ bundle }: MeasuredBundle, bytes: number): string => - bundle.base ? format(bytes) : `+${format(bytes)}`; +function amount({ bundle }: MeasuredBundle, bytes: number): string { + return bundle.base ? format(bytes) : `+${format(bytes)}`; +} -const label = ({ bundle }: MeasuredBundle): string => - bundle.base ? bundle.name : `added by ${bundle.name}`; +function label({ bundle }: MeasuredBundle): string { + return bundle.base ? bundle.name : `added by ${bundle.name}`; +} /** A bundle's heading and its sizes in each mode. */ function sizes(measured: Measured): string { diff --git a/scripts/bundle-size/measure/index.ts b/scripts/bundle-size/measure/index.ts new file mode 100644 index 0000000..5f858f0 --- /dev/null +++ b/scripts/bundle-size/measure/index.ts @@ -0,0 +1,90 @@ +import { Result as R, TaggedError } from "better-result"; +import { Val } from "../../../src/index.ts"; +import { Imports, type Bundle } from "../bundle.ts"; + +export class BranchIgnored extends TaggedError("BranchIgnored")<{ + imports: Imports; + message: string; +}> {} + +export class AddsNothing extends TaggedError("AddsNothing")<{ bundle: Bundle; message: string }> {} + +export type Mode = "production" | "development"; +export const modes: readonly Mode[] = ["production", "development"]; + +export type Sizes = Val<"Sizes", { minified: number; gzip: number; brotli: number }>; +export const Sizes = Val.sealer(); + +/** One mode's build of a bundle: its code, and what it weighs. */ +export type Build = Val< + "Build", + { + code: string; + sizes: Sizes; + /** The code's length, leaving out the entry that imports the library. */ + library: number; + } +>; +export const Build = Val.sealer(); + +/** What a set of imports weighs, built in each mode. */ +export type Measured = Val< + "Measured", + { + imports: Imports; + /** Whether the bundled library branches on `NODE_ENV`. */ + branches: boolean; + production: Build; + development: Build; + } +>; +export const Measured = Val.sealer().impl({ + /** + * Fails when the library branches on `NODE_ENV`, yet both builds weigh the same: production + * keeps the code only development needs. + */ + check: (measured): R => + measured.branches && measured.production.sizes.minified === measured.development.sizes.minified + ? R.err( + new BranchIgnored({ + imports: measured.imports, + message: `production keeps the development branches: import { ${Imports.names(measured.imports).join(", ")} }`, + }), + ) + : R.ok(undefined), +}); + +/** A bundle, measured with the base under it unless it joins the base. */ +export type MeasuredBundle = Val< + "MeasuredBundle", + { + bundle: Bundle; + measured: Measured; + /** The bytes the budget caps: a base bundle's whole size, or what another adds to the base. */ + capped: number; + } +>; +export const MeasuredBundle = Val.companion() + .implCreate((bundle: Bundle, measured: Measured, base: Measured) => ({ + bundle, + measured, + capped: bundle.base + ? measured.production.sizes.gzip + : measured.production.sizes.gzip - base.production.sizes.gzip, + })) + .impl({ + over: (result): boolean => result.capped > result.bundle.budget, + /** + * Fails when a bundle on top of the base adds no library code to it: what it imports is in the + * base already. + */ + check: (result, base: Measured): R => + !result.bundle.base && result.measured.production.library === base.production.library + ? R.err( + new AddsNothing({ + bundle: result.bundle, + message: `${result.bundle.name} adds nothing to the base, which already holds it`, + }), + ) + : R.ok(undefined), + }); diff --git a/scripts/bundle-size/measure.ts b/scripts/bundle-size/measure/toolchain.ts similarity index 67% rename from scripts/bundle-size/measure.ts rename to scripts/bundle-size/measure/toolchain.ts index abcf05f..bcf4b67 100644 --- a/scripts/bundle-size/measure.ts +++ b/scripts/bundle-size/measure/toolchain.ts @@ -7,8 +7,8 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { Result as R, TaggedError } from "better-result"; import { minifySync, build, type Rolldown } from "vite"; -import { Val } from "../../src/index.ts"; -import { Imports, type Bundle } from "./bundle.ts"; +import { Imports } from "../bundle.ts"; +import { Build, Measured, Sizes, type Mode } from "./index.ts"; export class PackFailed extends TaggedError("PackFailed")<{ message: string }> {} @@ -26,94 +26,7 @@ export class MinifyFailed extends TaggedError("MinifyFailed")<{ message: string; }> {} -export class BranchIgnored extends TaggedError("BranchIgnored")<{ - imports: Imports; - message: string; -}> {} - -export class AddsNothing extends TaggedError("AddsNothing")<{ bundle: Bundle; message: string }> {} - -export type Mode = "production" | "development"; -export const modes: readonly Mode[] = ["production", "development"]; - -export type Sizes = Val<"Sizes", { minified: number; gzip: number; brotli: number }>; -export const Sizes = Val.sealer(); - -/** One mode's build of a bundle: its code, and what it weighs. */ -export type Build = Val< - "Build", - { - code: string; - sizes: Sizes; - /** The code's length, leaving out the entry that imports the library. */ - library: number; - } ->; -export const Build = Val.sealer(); - -/** What a set of imports weighs, built in each mode. */ -export type Measured = Val< - "Measured", - { - imports: Imports; - /** Whether the bundled library branches on `NODE_ENV`. */ - branches: boolean; - production: Build; - development: Build; - } ->; -export const Measured = Val.sealer().impl({ - /** - * Fails when the library branches on `NODE_ENV`, yet both builds weigh the same: production - * keeps the code only development needs. - */ - check: (measured): R => - measured.branches && measured.production.sizes.minified === measured.development.sizes.minified - ? R.err( - new BranchIgnored({ - imports: measured.imports, - message: `production keeps the development branches: import { ${Imports.names(measured.imports).join(", ")} }`, - }), - ) - : R.ok(undefined), -}); - -/** A bundle, measured with the base under it unless it joins the base. */ -export type MeasuredBundle = Val< - "MeasuredBundle", - { - bundle: Bundle; - measured: Measured; - /** The bytes the budget caps: a base bundle's whole size, or what another adds to the base. */ - capped: number; - } ->; -export const MeasuredBundle = Val.companion() - .implCreate((bundle: Bundle, measured: Measured, base: Measured) => ({ - bundle, - measured, - capped: bundle.base - ? measured.production.sizes.gzip - : measured.production.sizes.gzip - base.production.sizes.gzip, - })) - .impl({ - over: (result): boolean => result.capped > result.bundle.budget, - /** - * Fails when a bundle on top of the base adds no library code to it: what it imports is in the - * base already. - */ - check: (result, base: Measured): R => - !result.bundle.base && result.measured.production.library === base.production.library - ? R.err( - new AddsNothing({ - bundle: result.bundle, - message: `${result.bundle.name} adds nothing to the base, which already holds it`, - }), - ) - : R.ok(undefined), - }); - -const root = new URL("../../", import.meta.url); +const root = new URL("../../../", import.meta.url); /** Builds `dist`, so the bundles import what a user installs. */ export function pack(): Promise> { diff --git a/scripts/bundle-size/suite.ts b/scripts/bundle-size/suite.ts index 0589614..e40c77b 100644 --- a/scripts/bundle-size/suite.ts +++ b/scripts/bundle-size/suite.ts @@ -1,18 +1,15 @@ import { Result as R, TaggedError } from "better-result"; import { Val } from "../../src/index.ts"; import { Imports, type Bundle } from "./bundle.ts"; +import { Measured, MeasuredBundle, type AddsNothing, type BranchIgnored } from "./measure/index.ts"; import { - Measured, - MeasuredBundle, measure, pack, - type AddsNothing, - type BranchIgnored, type CompileFailed, type MinifyFailed, type NoChunk, type PackFailed, -} from "./measure.ts"; +} from "./measure/toolchain.ts"; export class NoBase extends TaggedError("NoBase")<{ message: string }> {} From 1f05c1c56e87d8b4d72ed1c962c918e4a80dc13b Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:04:48 +0900 Subject: [PATCH 21/52] docs(notes): record why bundle-size checks compare sizes --- notes/design.md | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/notes/design.md b/notes/design.md index bafb419..d68e89e 100644 --- a/notes/design.md +++ b/notes/design.md @@ -39,7 +39,7 @@ import { Val } from "valof"; - **15.2 `Enum`** §7.4 の見直し。Variant をレコードに宣言して union を導出、ブランドの導出、タグ名のカスタムと `tag-mismatch`、companion に置く `match`、ts-pattern との線引き、型を確かめた記録(共通フィールド、`match` の型引数、`VariantOf` の表示、却下した戻り値の型引数・自由関数の `match`・Val のレコード、Trait の実装、タグ名を `Tag<…>` で渡すこと、トップレベルの条件型が宣言出力を壊すこと)、実装して分かったこと(Fault の置き場所、Variant 1 個の禁止、`then` を 3 箇所で落とす、宣言出力の CI、variance 測定と型コスト)、Variant ごとの seal と union の `seal`(入口を 2 つに分ける、builder を callback で渡す、`impl` で鎖を閉じる、却下した値の形)、`seal` の引数を排他的にしたこと、タグだけで分ける `[Tag].match` と `[Tag].key`(キーを文字列にする理由、却下した名前)、`implVariant` を Variant ごとの鎖にしたこと、steps を枠そのものにしたこと、タグ名の渡し方を変える 4 案の却下、companion を渡さない形(val.ts の門を共有する、`PayloadKeys` の分配、入口 2 つぶんのテスト) - **15.3 `path`** seal をまたぐ patch の合成。`abort` を合成側に置く判断、ハンドラが最終段である理由(HKT)、`glue` の `open` / `close`、`each` / `where`、却下した `deepPatch` - **15.4 `.impl` のコールバック形** 自分の companion を参照すると推論が回らない(TS7022)。contextual typing がコールバック越しでも効くことの実測、`implTrait` も callback、予算、却下したメンバのカリー化と、下流の `.impl` を型で塞ぐ案。**鎖は 2026-09-18 に閉じた**(1 回だけ、兄弟は注釈で呼ぶ、Trait の実装の受け手を `Tr` に、却下した型レベルの案内と lint 規則) -- **§16 予算の責務** バンドルと型を別のスクリプトに割る。宣言のバイト数を type-perf へ、予算を 64 kB に上げた理由、16.1 バンドルの増分を実測の core から測る +- **§16 予算の責務** バンドルと型を別のスクリプトに割る。宣言のバイト数を type-perf へ、予算を 64 kB に上げた理由、16.1 バンドルの増分を実測の core から測る、16.2 production の検査はサイズを比べる - **§17 Effect v4 調査** 落ちた typeclass と HKT の層(§15.3 の根拠)、値の中に等価性を置く形、クラスの構造比較に開く穴、向こうにあってこちらに無いもの --- @@ -6560,6 +6560,43 @@ type-perf に移せる理由でもある。 **却下: `Enum` を +512 にする。**区切りはよいが、余りが 38 B(7%)で、直す前と変わらない。`match` の `_` が 実行時のコードを足すので、その PR で上げ直すことになる。 +### 16.2 production の検査はサイズを比べる、2026-10-15 + +**前の検査は文字列を探していた。**production の core に `snapshotSealInput` など development 用の +文字列 3 つが残っていないか、`equals` を import しないバンドルに `Number.isNaN` が残っていないかを見ていた。 +関数名とメッセージが変わると黙って効かなくなり、新しい development 用のコードは書き足さないと見ない。 + +**`Number.isNaN` は `equals` の目印になっていなかった。**`development` を `true` に固定すると、`equals` を +import しない core で「equals が残っている」と出た。development 用のコードも `Number.isNaN` を使う。 + +今は 2 つの検査とも、特定の名前や文字列に頼らず、サイズで比べる。 + +| 検査 | 失敗する条件 | +| --------------- | ---------------------------------------------------------------------------------------------------- | +| `BranchIgnored` | バンドルに入ったソースが `NODE_ENV` を読むのに、production と development の minify 後のサイズが同じ | +| `AddsNothing` | ベースの上に積んだバンドルのライブラリ部分が、ベースと同じ長さ。足した import はベースに入っている | + +- **ライブラリ部分は「チャンクの長さ − エントリの `renderedLength`」で測る。**エントリは import した名前を + `console.log` に渡すので、全体を比べると名前の分だけ必ず違う。各モジュールの `renderedLength` は + tree-shaking の前の長さで、`equals` の有無で同じ 13,923 だった。使えない +- **`NODE_ENV` を読むかどうかは、`define` が置き換える前のソースで見る。**ビルド後のコードには残らない + +壊して確かめた結果。 + +| 壊し方 | 結果 | +| -------------------------------------------------------------- | ------------------------------------------ | +| `src/val.ts` のトップレベルで `equals` を参照し、core に漏らす | `AddsNothing` | +| `define` を効かなくし、両方のモードを同じコードにする | `BranchIgnored` | +| `development` を `true` に固定する | 検査は通る。ソースが `NODE_ENV` を読まない | + +**拾えないもの: development 用の分岐の一部だけが production に残る場合。**`development` で囲み忘れた +検査が 1 つあっても、ほかの分岐は消えるのでサイズは違ったまま。予算が拾うのは、core の余りより大きい漏れだけ。 +`development` を `true` に固定したときは、core が 896 B から 1.65 kB になり、予算を 670 B 超えた。 + +**却下: 検査をやめて予算に任せる。**core の余り 128 B より大きい漏れは予算が拾う。実測では development 用の +コードが約 +790 B、`equals` が +187 B。ただし `equals` は余りより 59 B 大きいだけで、core が増えないまま +core の予算を 60 B 上げれば通る。サイズの比較は余りに左右されない。 + --- ## 17. Effect v4 調査、2026-09-10 From b7e854f7b22513f965043247de46d799bea41047 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:07:56 +0900 Subject: [PATCH 22/52] refactor: put the exported functions first in format and toolchain --- scripts/bundle-size/format.ts | 28 ++++++------- scripts/bundle-size/measure/toolchain.ts | 52 ++++++++++++------------ 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/scripts/bundle-size/format.ts b/scripts/bundle-size/format.ts index 948640a..e9c3a92 100644 --- a/scripts/bundle-size/format.ts +++ b/scripts/bundle-size/format.ts @@ -2,6 +2,20 @@ import { Imports } from "./bundle.ts"; import { modes, type Measured, type MeasuredBundle } from "./measure/index.ts"; import type { Measurement } from "./suite.ts"; +export function over(result: MeasuredBundle): string { + return `over budget: ${label(result)} is ${amount(result, result.capped)}, budget ${amount(result, result.bundle.budget)}`; +} + +/** What a run prints: every bundle's sizes and the budgets, or the sizes as JSON. */ +export function report({ results, extra }: Measurement, asJson: boolean): string { + return asJson + ? json(results) + : [ + ...[...extra, ...results.map(({ measured }) => measured)].map((each) => `${sizes(each)}\n`), + budgets(results), + ].join("\n"); +} + function format(bytes: number): string { return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(2)} kB`; } @@ -59,10 +73,6 @@ function budgets(results: readonly MeasuredBundle[]): string { ].join("\n"); } -export function over(result: MeasuredBundle): string { - return `over budget: ${label(result)} is ${amount(result, result.capped)}, budget ${amount(result, result.bundle.budget)}`; -} - /** The `--json` output, keyed as `replace-bundle-size-claims` reads it. */ function json(results: readonly MeasuredBundle[]): string { const keyed = Object.fromEntries( @@ -73,13 +83,3 @@ function json(results: readonly MeasuredBundle[]): string { ); return JSON.stringify(keyed, null, 2); } - -/** What a run prints: every bundle's sizes and the budgets, or the sizes as JSON. */ -export function report({ results, extra }: Measurement, asJson: boolean): string { - return asJson - ? json(results) - : [ - ...[...extra, ...results.map(({ measured }) => measured)].map((each) => `${sizes(each)}\n`), - budgets(results), - ].join("\n"); -} diff --git a/scripts/bundle-size/measure/toolchain.ts b/scripts/bundle-size/measure/toolchain.ts index bcf4b67..fc3cfc3 100644 --- a/scripts/bundle-size/measure/toolchain.ts +++ b/scripts/bundle-size/measure/toolchain.ts @@ -44,6 +44,32 @@ export function pack(): Promise> { }); } +export function measure( + imports: Imports, +): Promise> { + return R.gen(async function* () { + const [production, development] = yield* R.await( + R.allAsync([compile("production", imports), compile("development", imports)] as const), + ); + return R.ok( + Measured({ + imports, + branches: yield* R.await(branches(development.modules, "development")), + production: Build({ + code: production.code, + sizes: yield* sizes(production.code), + library: production.library, + }), + development: Build({ + code: development.code, + sizes: yield* sizes(development.code), + library: development.library, + }), + }), + ); + }); +} + function attempt(mode: Mode, step: string, run: () => Promise): Promise> { return R.tryPromise({ try: run, @@ -146,29 +172,3 @@ function branches(modules: readonly string[], mode: Mode): Promise source.includes("NODE_ENV")); }); } - -export function measure( - imports: Imports, -): Promise> { - return R.gen(async function* () { - const [production, development] = yield* R.await( - R.allAsync([compile("production", imports), compile("development", imports)] as const), - ); - return R.ok( - Measured({ - imports, - branches: yield* R.await(branches(development.modules, "development")), - production: Build({ - code: production.code, - sizes: yield* sizes(production.code), - library: production.library, - }), - development: Build({ - code: development.code, - sizes: yield* sizes(development.code), - library: development.library, - }), - }), - ); - }); -} From a79b2ee8fd9918e280db9e04c93b2e82eb76a16e Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:10:13 +0900 Subject: [PATCH 23/52] refactor: put the ok branch first in bundle-size results --- scripts/bundle-size/measure/index.ts | 16 ++++++++-------- scripts/bundle-size/suite.ts | 5 +++-- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/scripts/bundle-size/measure/index.ts b/scripts/bundle-size/measure/index.ts index 5f858f0..7eb8de2 100644 --- a/scripts/bundle-size/measure/index.ts +++ b/scripts/bundle-size/measure/index.ts @@ -44,14 +44,14 @@ export const Measured = Val.sealer().impl({ * keeps the code only development needs. */ check: (measured): R => - measured.branches && measured.production.sizes.minified === measured.development.sizes.minified - ? R.err( + !measured.branches || measured.production.sizes.minified !== measured.development.sizes.minified + ? R.ok() + : R.err( new BranchIgnored({ imports: measured.imports, message: `production keeps the development branches: import { ${Imports.names(measured.imports).join(", ")} }`, }), - ) - : R.ok(undefined), + ), }); /** A bundle, measured with the base under it unless it joins the base. */ @@ -79,12 +79,12 @@ export const MeasuredBundle = Val.companion() * base already. */ check: (result, base: Measured): R => - !result.bundle.base && result.measured.production.library === base.production.library - ? R.err( + result.bundle.base || result.measured.production.library !== base.production.library + ? R.ok() + : R.err( new AddsNothing({ bundle: result.bundle, message: `${result.bundle.name} adds nothing to the base, which already holds it`, }), - ) - : R.ok(undefined), + ), }); diff --git a/scripts/bundle-size/suite.ts b/scripts/bundle-size/suite.ts index e40c77b..be77764 100644 --- a/scripts/bundle-size/suite.ts +++ b/scripts/bundle-size/suite.ts @@ -34,8 +34,9 @@ export const Suite = Val.sealer().impl({ const [first, ...others] = suite .filter((bundle) => bundle.base) .map((bundle) => bundle.imports); - if (first === undefined) return R.err(new NoBase({ message: "no bundle sets base" })); - return R.ok(others.reduce((merged, imports) => Imports.merge(merged, imports), first)); + return first !== undefined + ? R.ok(others.reduce((merged, imports) => Imports.merge(merged, imports), first)) + : R.err(new NoBase({ message: "no bundle sets base" })); }, /** * Builds `dist`, and measures each bundle: a base bundle alone, any other on top of the base. From 9ff34956dc1a69cbae2e19a9a5823774e562c4b8 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:58:03 +0900 Subject: [PATCH 24/52] refactor: drop bundle-size comments the code already states --- scripts/bundle-size/bundle.ts | 17 +---------------- scripts/bundle-size/cli.ts | 4 ---- scripts/bundle-size/format.ts | 11 ++++------- scripts/bundle-size/index.ts | 1 - scripts/bundle-size/measure/index.ts | 19 +++---------------- scripts/bundle-size/measure/toolchain.ts | 9 ++------- scripts/bundle-size/suite.ts | 18 ++++-------------- 7 files changed, 14 insertions(+), 65 deletions(-) diff --git a/scripts/bundle-size/bundle.ts b/scripts/bundle-size/bundle.ts index 61728df..b49ae6c 100644 --- a/scripts/bundle-size/bundle.ts +++ b/scripts/bundle-size/bundle.ts @@ -1,9 +1,7 @@ import { Val } from "../../src/index.ts"; -/** The names a bundle imports, per module. */ export type Imports = Val<"Imports", Record>; export const Imports = Val.sealer().impl({ - /** What the bundle imports, as the printed heading spells it. */ names: (imports): string[] => Object.values(imports).flat(), merge: (imports, other: Imports): Imports => { const merged: Record = {}; @@ -16,26 +14,13 @@ export const Imports = Val.sealer().impl({ }, }); -/** - * A bundle a user ships, and its budget in production gzip. A ratchet, unlike the budgets in - * `type-perf`: runtime code is written deliberately, so a byte more is a decision, not drift. - * - * The declarations are not measured here. They cost a download and a parse, never a byte in the - * user's bundle, and `type-perf` already measures what reads them. - */ export type Bundle = Val< "Bundle", { /** The key in the `--json` output, which `replace-bundle-size-claims` reads. */ key: string; - /** The bundle's name in the budget table. */ name: string; - /** - * Whether the bundle joins the base, which every base bundle forms together. A base bundle is - * capped by its whole size. Every other bundle is measured on top of the base and capped by - * what it adds, so growth in the base spends only the base's room: what an import costs is the - * number to read. - */ + /** A base bundle is capped by its whole size, any other by what it adds to the base. */ base?: boolean; imports: Imports; budget: number; diff --git a/scripts/bundle-size/cli.ts b/scripts/bundle-size/cli.ts index dfba23a..edb5fb8 100644 --- a/scripts/bundle-size/cli.ts +++ b/scripts/bundle-size/cli.ts @@ -3,10 +3,6 @@ import { MeasuredBundle } from "./measure/index.ts"; import { Suite } from "./suite.ts"; export const Cli = { - /** - * Measures the suite, and prints its sizes and budgets, or the sizes as JSON with `--json`. Sets a - * failing exit code when measuring fails or a bundle is over its budget. - */ run: async (suite: Suite): Promise => { process.exitCode = (await Suite.measure(suite)).match({ ok: (measurement) => { diff --git a/scripts/bundle-size/format.ts b/scripts/bundle-size/format.ts index e9c3a92..0c9b442 100644 --- a/scripts/bundle-size/format.ts +++ b/scripts/bundle-size/format.ts @@ -6,12 +6,13 @@ export function over(result: MeasuredBundle): string { return `over budget: ${label(result)} is ${amount(result, result.capped)}, budget ${amount(result, result.bundle.budget)}`; } -/** What a run prints: every bundle's sizes and the budgets, or the sizes as JSON. */ -export function report({ results, extra }: Measurement, asJson: boolean): string { +export function report({ results, separateBase }: Measurement, asJson: boolean): string { return asJson ? json(results) : [ - ...[...extra, ...results.map(({ measured }) => measured)].map((each) => `${sizes(each)}\n`), + ...[...separateBase, ...results.map(({ measured }) => measured)].map( + (each) => `${sizes(each)}\n`, + ), budgets(results), ].join("\n"); } @@ -20,7 +21,6 @@ function format(bytes: number): string { return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(2)} kB`; } -/** Pads every column to its widest cell, aligned to the given side. */ function align(rows: readonly (readonly string[])[], sides: readonly ("left" | "right")[]) { const columns = sides.map((side, index) => { const cells = rows.map((row) => row[index] ?? ""); @@ -38,7 +38,6 @@ function label({ bundle }: MeasuredBundle): string { return bundle.base ? bundle.name : `added by ${bundle.name}`; } -/** A bundle's heading and its sizes in each mode. */ function sizes(measured: Measured): string { const heads = ["minified", "gzip", "brotli"] as const; const rows = align( @@ -54,7 +53,6 @@ function sizes(measured: Measured): string { ].join("\n"); } -/** Each bundle's capped bytes against its budget. */ function budgets(results: readonly MeasuredBundle[]): string { const rows = results.map((result) => { const bytes = result.capped; @@ -73,7 +71,6 @@ function budgets(results: readonly MeasuredBundle[]): string { ].join("\n"); } -/** The `--json` output, keyed as `replace-bundle-size-claims` reads it. */ function json(results: readonly MeasuredBundle[]): string { const keyed = Object.fromEntries( results.map(({ bundle, measured }) => [ diff --git a/scripts/bundle-size/index.ts b/scripts/bundle-size/index.ts index 4c271dc..4c090fa 100644 --- a/scripts/bundle-size/index.ts +++ b/scripts/bundle-size/index.ts @@ -2,7 +2,6 @@ import { Bundle, Imports } from "./bundle.ts"; import { Cli } from "./cli.ts"; import { Suite } from "./suite.ts"; -// `Trait` and `Enum` ship from their own subpath, so a bundle names the module per import. const suite = Suite([ Bundle({ key: "val", diff --git a/scripts/bundle-size/measure/index.ts b/scripts/bundle-size/measure/index.ts index 7eb8de2..21de7e4 100644 --- a/scripts/bundle-size/measure/index.ts +++ b/scripts/bundle-size/measure/index.ts @@ -15,11 +15,9 @@ export const modes: readonly Mode[] = ["production", "development"]; export type Sizes = Val<"Sizes", { minified: number; gzip: number; brotli: number }>; export const Sizes = Val.sealer(); -/** One mode's build of a bundle: its code, and what it weighs. */ export type Build = Val< "Build", { - code: string; sizes: Sizes; /** The code's length, leaving out the entry that imports the library. */ library: number; @@ -27,24 +25,19 @@ export type Build = Val< >; export const Build = Val.sealer(); -/** What a set of imports weighs, built in each mode. */ export type Measured = Val< "Measured", { imports: Imports; - /** Whether the bundled library branches on `NODE_ENV`. */ - branches: boolean; + readsNodeEnv: boolean; production: Build; development: Build; } >; export const Measured = Val.sealer().impl({ - /** - * Fails when the library branches on `NODE_ENV`, yet both builds weigh the same: production - * keeps the code only development needs. - */ check: (measured): R => - !measured.branches || measured.production.sizes.minified !== measured.development.sizes.minified + !measured.readsNodeEnv || + measured.production.sizes.minified !== measured.development.sizes.minified ? R.ok() : R.err( new BranchIgnored({ @@ -54,13 +47,11 @@ export const Measured = Val.sealer().impl({ ), }); -/** A bundle, measured with the base under it unless it joins the base. */ export type MeasuredBundle = Val< "MeasuredBundle", { bundle: Bundle; measured: Measured; - /** The bytes the budget caps: a base bundle's whole size, or what another adds to the base. */ capped: number; } >; @@ -74,10 +65,6 @@ export const MeasuredBundle = Val.companion() })) .impl({ over: (result): boolean => result.capped > result.bundle.budget, - /** - * Fails when a bundle on top of the base adds no library code to it: what it imports is in the - * base already. - */ check: (result, base: Measured): R => result.bundle.base || result.measured.production.library !== base.production.library ? R.ok() diff --git a/scripts/bundle-size/measure/toolchain.ts b/scripts/bundle-size/measure/toolchain.ts index fc3cfc3..a8a7479 100644 --- a/scripts/bundle-size/measure/toolchain.ts +++ b/scripts/bundle-size/measure/toolchain.ts @@ -14,7 +14,6 @@ export class PackFailed extends TaggedError("PackFailed")<{ message: string }> { export class NoChunk extends TaggedError("NoChunk")<{ mode: Mode; message: string }> {} -/** Something a bundle's compiling step could not do: the temporary file, or the build itself. */ export class CompileFailed extends TaggedError("CompileFailed")<{ mode: Mode; cause: unknown; @@ -28,7 +27,6 @@ export class MinifyFailed extends TaggedError("MinifyFailed")<{ const root = new URL("../../../", import.meta.url); -/** Builds `dist`, so the bundles import what a user installs. */ export function pack(): Promise> { return R.tryPromise({ try: async () => { @@ -54,14 +52,12 @@ export function measure( return R.ok( Measured({ imports, - branches: yield* R.await(branches(development.modules, "development")), + readsNodeEnv: yield* R.await(readsNodeEnv(development.modules, "development")), production: Build({ - code: production.code, sizes: yield* sizes(production.code), library: production.library, }), development: Build({ - code: development.code, sizes: yield* sizes(development.code), library: development.library, }), @@ -85,7 +81,6 @@ function minify(code: string): R { }); } -/** A bundle's code, its library modules, and the code's length without the entry. */ type Compiled = { code: string; modules: string[]; library: number }; function compile(mode: Mode, imports: Imports): Promise> { @@ -166,7 +161,7 @@ function sizes(code: string): R { } /** Whether a module's source reads `NODE_ENV`, before `define` replaces it. */ -function branches(modules: readonly string[], mode: Mode): Promise> { +function readsNodeEnv(modules: readonly string[], mode: Mode): Promise> { return attempt(mode, "reading the library", async () => { const sources = await Promise.all(modules.map((module) => readFile(module, "utf8"))); return sources.some((source) => source.includes("NODE_ENV")); diff --git a/scripts/bundle-size/suite.ts b/scripts/bundle-size/suite.ts index be77764..5eb9b90 100644 --- a/scripts/bundle-size/suite.ts +++ b/scripts/bundle-size/suite.ts @@ -13,23 +13,18 @@ import { export class NoBase extends TaggedError("NoBase")<{ message: string }> {} -/** What measuring a suite found. */ export type Measurement = Val< "Measurement", { - /** The base, which every bundle not in it is measured on top of. */ base: Measured; results: MeasuredBundle[]; - /** The base again, when it was measured apart from every bundle, so `results` leaves it out. */ - extra: Measured[]; + separateBase: Measured[]; } >; export const Measurement = Val.sealer(); -/** The bundles measured together, against the base they share. */ export type Suite = Val<"Suite", Bundle[]>; export const Suite = Val.sealer().impl({ - /** What the base imports: every base bundle's imports, merged. */ base: (suite): R => { const [first, ...others] = suite .filter((bundle) => bundle.base) @@ -38,10 +33,6 @@ export const Suite = Val.sealer().impl({ ? R.ok(others.reduce((merged, imports) => Imports.merge(merged, imports), first)) : R.err(new NoBase({ message: "no bundle sets base" })); }, - /** - * Builds `dist`, and measures each bundle: a base bundle alone, any other on top of the base. - * Fails when a production bundle keeps code it should not. - */ measure: ( suite: Suite, ): Promise< @@ -65,19 +56,18 @@ export const Suite = Val.sealer().impl({ ), ); const [only, ...more] = results.filter(({ bundle }) => bundle.base); - // With one base bundle, the base is that bundle, already measured. const base = only !== undefined && more.length === 0 ? only.measured : yield* R.await(measure(baseImports)); - const extra = base === only?.measured ? [] : [base]; - for (const measured of [...extra, ...results.map(({ measured }) => measured)]) { + const separateBase = base === only?.measured ? [] : [base]; + for (const measured of [...separateBase, ...results.map(({ measured }) => measured)]) { yield* Measured.check(measured); } const bundles = results.map(({ bundle, measured }) => MeasuredBundle.create(bundle, measured, base), ); for (const result of bundles) yield* MeasuredBundle.check(result, base); - return R.ok(Measurement({ base, results: bundles, extra })); + return R.ok(Measurement({ base, results: bundles, separateBase })); }), }); From f956217668ee2a3986b56e3a8a072a1c4ff1dc29 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:58:04 +0900 Subject: [PATCH 25/52] refactor: build the CompileFailed and MinifyFailed messages in their constructors --- scripts/bundle-size/measure/toolchain.ts | 67 ++++++++++++++---------- 1 file changed, 38 insertions(+), 29 deletions(-) diff --git a/scripts/bundle-size/measure/toolchain.ts b/scripts/bundle-size/measure/toolchain.ts index a8a7479..55db01b 100644 --- a/scripts/bundle-size/measure/toolchain.ts +++ b/scripts/bundle-size/measure/toolchain.ts @@ -16,14 +16,23 @@ export class NoChunk extends TaggedError("NoChunk")<{ mode: Mode; message: strin export class CompileFailed extends TaggedError("CompileFailed")<{ mode: Mode; + step: string; cause: unknown; message: string; -}> {} +}> { + constructor({ mode, step, cause }: { mode: Mode; step: string; cause: unknown }) { + super({ mode, step, cause, message: `${step} failed for ${mode}: ${String(cause)}` }); + } +} export class MinifyFailed extends TaggedError("MinifyFailed")<{ cause: unknown; message: string; -}> {} +}> { + constructor({ cause }: { cause: unknown }) { + super({ cause, message: `minify failed: ${String(cause)}` }); + } +} const root = new URL("../../../", import.meta.url); @@ -66,18 +75,10 @@ export function measure( }); } -function attempt(mode: Mode, step: string, run: () => Promise): Promise> { - return R.tryPromise({ - try: run, - catch: (cause) => - new CompileFailed({ mode, cause, message: `${step} failed for ${mode}: ${String(cause)}` }), - }); -} - function minify(code: string): R { return R.try({ try: () => minifySync("bundle.mjs", code).code, - catch: (cause) => new MinifyFailed({ cause, message: `minify failed: ${String(cause)}` }), + catch: (cause) => new MinifyFailed({ cause }), }); } @@ -86,7 +87,10 @@ type Compiled = { code: string; modules: string[]; library: number }; function compile(mode: Mode, imports: Imports): Promise> { return R.gen(async function* () { const dir = yield* R.await( - attempt(mode, "creating the entry directory", () => mkdtemp(join(tmpdir(), "valof-size-"))), + R.tryPromise({ + try: () => mkdtemp(join(tmpdir(), "valof-size-")), + catch: (cause) => new CompileFailed({ mode, step: "creating the entry directory", cause }), + }), ); const file = join(dir, "entry.mjs"); const lines = Object.entries(imports).map( @@ -95,18 +99,18 @@ function compile(mode: Mode, imports: Imports): Promise - writeFile( - file, - `${lines.join("\n")}\nconsole.log(${Imports.names(imports).join(", ")});\n`, - ), - ), + R.tryPromise({ + try: () => + writeFile( + file, + `${lines.join("\n")}\nconsole.log(${Imports.names(imports).join(", ")});\n`, + ), + catch: (cause) => new CompileFailed({ mode, step: "writing the entry", cause }), + }), ); const result = yield* R.await( - attempt( - mode, - "the build", - async () => + R.tryPromise({ + try: async () => (await build({ root: fileURLToPath(root), logLevel: "silent", @@ -119,7 +123,8 @@ function compile(mode: Mode, imports: Imports): Promise new CompileFailed({ mode, step: "the build", cause }), + }), ); const chunk = result[0]?.output.find((output) => output.type === "chunk"); if (chunk?.type !== "chunk") { @@ -136,9 +141,10 @@ function compile(mode: Mode, imports: Imports): Promise - rm(dir, { recursive: true, force: true }), - ), + R.tryPromise({ + try: () => rm(dir, { recursive: true, force: true }), + catch: (cause) => new CompileFailed({ mode, step: "removing the entry directory", cause }), + }), ); return built; }); @@ -162,8 +168,11 @@ function sizes(code: string): R { /** Whether a module's source reads `NODE_ENV`, before `define` replaces it. */ function readsNodeEnv(modules: readonly string[], mode: Mode): Promise> { - return attempt(mode, "reading the library", async () => { - const sources = await Promise.all(modules.map((module) => readFile(module, "utf8"))); - return sources.some((source) => source.includes("NODE_ENV")); + return R.tryPromise({ + try: async () => { + const sources = await Promise.all(modules.map((module) => readFile(module, "utf8"))); + return sources.some((source) => source.includes("NODE_ENV")); + }, + catch: (cause) => new CompileFailed({ mode, step: "reading the library", cause }), }); } From bad167e31c07f5dcfcebb279c1f61f95b6f31714 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:01:44 +0900 Subject: [PATCH 26/52] refactor: declare Compiled as a Val --- scripts/bundle-size/measure/toolchain.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/scripts/bundle-size/measure/toolchain.ts b/scripts/bundle-size/measure/toolchain.ts index 55db01b..4bff9f5 100644 --- a/scripts/bundle-size/measure/toolchain.ts +++ b/scripts/bundle-size/measure/toolchain.ts @@ -7,6 +7,7 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { Result as R, TaggedError } from "better-result"; import { minifySync, build, type Rolldown } from "vite"; +import { Val } from "../../../src/index.ts"; import { Imports } from "../bundle.ts"; import { Build, Measured, Sizes, type Mode } from "./index.ts"; @@ -82,7 +83,8 @@ function minify(code: string): R { }); } -type Compiled = { code: string; modules: string[]; library: number }; +type Compiled = Val<"Compiled", { code: string; modules: string[]; library: number }>; +const Compiled = Val.sealer(); function compile(mode: Mode, imports: Imports): Promise> { return R.gen(async function* () { @@ -131,13 +133,15 @@ function compile(mode: Mode, imports: Imports): Promise id !== chunk.facadeModuleId), - // A module's own `renderedLength` counts code that tree-shaking drops, so the library is - // what the chunk holds beyond its entry. - library: chunk.code.length - (entry?.renderedLength ?? 0), - }); + return R.ok( + Compiled({ + code: chunk.code, + modules: chunk.moduleIds.filter((id) => id !== chunk.facadeModuleId), + // A module's own `renderedLength` counts code that tree-shaking drops, so the library is + // what the chunk holds beyond its entry. + library: chunk.code.length - (entry?.renderedLength ?? 0), + }), + ); }); // Removed whether or not the build succeeded. yield* R.await( From 81ad63e7c7801b8c5fbc638d776fe704cd35e0a2 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:04:08 +0900 Subject: [PATCH 27/52] refactor: ask Modules whether they read NODE_ENV --- scripts/bundle-size/measure/toolchain.ts | 30 +++++++++++++----------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/scripts/bundle-size/measure/toolchain.ts b/scripts/bundle-size/measure/toolchain.ts index 4bff9f5..4fb3d65 100644 --- a/scripts/bundle-size/measure/toolchain.ts +++ b/scripts/bundle-size/measure/toolchain.ts @@ -62,7 +62,7 @@ export function measure( return R.ok( Measured({ imports, - readsNodeEnv: yield* R.await(readsNodeEnv(development.modules, "development")), + readsNodeEnv: yield* R.await(Modules.readsNodeEnv(development.modules, "development")), production: Build({ sizes: yield* sizes(production.code), library: production.library, @@ -83,7 +83,20 @@ function minify(code: string): R { }); } -type Compiled = Val<"Compiled", { code: string; modules: string[]; library: number }>; +type Modules = Val<"Modules", string[]>; +const Modules = Val.sealer().impl({ + /** Whether a module's source reads `NODE_ENV`, before `define` replaces it. */ + readsNodeEnv: (modules, mode: Mode): Promise> => + R.tryPromise({ + try: async () => { + const sources = await Promise.all(modules.map((module) => readFile(module, "utf8"))); + return sources.some((source) => source.includes("NODE_ENV")); + }, + catch: (cause) => new CompileFailed({ mode, step: "reading the library", cause }), + }), +}); + +type Compiled = Val<"Compiled", { code: string; modules: Modules; library: number }>; const Compiled = Val.sealer(); function compile(mode: Mode, imports: Imports): Promise> { @@ -136,7 +149,7 @@ function compile(mode: Mode, imports: Imports): Promise id !== chunk.facadeModuleId), + modules: Modules(chunk.moduleIds.filter((id) => id !== chunk.facadeModuleId)), // A module's own `renderedLength` counts code that tree-shaking drops, so the library is // what the chunk holds beyond its entry. library: chunk.code.length - (entry?.renderedLength ?? 0), @@ -169,14 +182,3 @@ function sizes(code: string): R { }); }); } - -/** Whether a module's source reads `NODE_ENV`, before `define` replaces it. */ -function readsNodeEnv(modules: readonly string[], mode: Mode): Promise> { - return R.tryPromise({ - try: async () => { - const sources = await Promise.all(modules.map((module) => readFile(module, "utf8"))); - return sources.some((source) => source.includes("NODE_ENV")); - }, - catch: (cause) => new CompileFailed({ mode, step: "reading the library", cause }), - }); -} From 1931ac9871fd124c5244c714a338227489db3c53 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:08:26 +0900 Subject: [PATCH 28/52] refactor: declare Code as a Val that minifies and sizes itself --- scripts/bundle-size/measure/toolchain.ts | 52 ++++++++++++------------ 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/scripts/bundle-size/measure/toolchain.ts b/scripts/bundle-size/measure/toolchain.ts index 4fb3d65..b543429 100644 --- a/scripts/bundle-size/measure/toolchain.ts +++ b/scripts/bundle-size/measure/toolchain.ts @@ -64,11 +64,11 @@ export function measure( imports, readsNodeEnv: yield* R.await(Modules.readsNodeEnv(development.modules, "development")), production: Build({ - sizes: yield* sizes(production.code), + sizes: yield* Code.sizes(production.code), library: production.library, }), development: Build({ - sizes: yield* sizes(development.code), + sizes: yield* Code.sizes(development.code), library: development.library, }), }), @@ -76,12 +76,28 @@ export function measure( }); } -function minify(code: string): R { - return R.try({ - try: () => minifySync("bundle.mjs", code).code, - catch: (cause) => new MinifyFailed({ cause }), - }); -} +type Code = Val<"Code", string>; +const Code = Val.sealer().impl({ + minify: (code): R => + R.try({ + try: () => Code(minifySync("bundle.mjs", code).code), + catch: (cause) => new MinifyFailed({ cause }), + }), + sizes: (code): R => + Code.minify(code).map((minified) => { + const bytes = Buffer.from(minified, "utf8"); + return Sizes({ + minified: bytes.byteLength, + gzip: gzipSync(bytes, { level: 9 }).byteLength, + brotli: brotliCompressSync(bytes, { + params: { + [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY, + [constants.BROTLI_PARAM_SIZE_HINT]: bytes.byteLength, + }, + }).byteLength, + }); + }), +}); type Modules = Val<"Modules", string[]>; const Modules = Val.sealer().impl({ @@ -96,7 +112,7 @@ const Modules = Val.sealer().impl({ }), }); -type Compiled = Val<"Compiled", { code: string; modules: Modules; library: number }>; +type Compiled = Val<"Compiled", { code: Code; modules: Modules; library: number }>; const Compiled = Val.sealer(); function compile(mode: Mode, imports: Imports): Promise> { @@ -148,7 +164,7 @@ function compile(mode: Mode, imports: Imports): Promise id !== chunk.facadeModuleId)), // A module's own `renderedLength` counts code that tree-shaking drops, so the library is // what the chunk holds beyond its entry. @@ -166,19 +182,3 @@ function compile(mode: Mode, imports: Imports): Promise { - return minify(code).map((minified) => { - const bytes = Buffer.from(minified, "utf8"); - return Sizes({ - minified: bytes.byteLength, - gzip: gzipSync(bytes, { level: 9 }).byteLength, - brotli: brotliCompressSync(bytes, { - params: { - [constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY, - [constants.BROTLI_PARAM_SIZE_HINT]: bytes.byteLength, - }, - }).byteLength, - }); - }); -} From eda1bdea89ccd5205c83e4fcc031d48cfc4c11c1 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:08:28 +0900 Subject: [PATCH 29/52] refactor: ask each Module whether it reads NODE_ENV --- scripts/bundle-size/measure/toolchain.ts | 25 +++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/scripts/bundle-size/measure/toolchain.ts b/scripts/bundle-size/measure/toolchain.ts index b543429..d74f7bd 100644 --- a/scripts/bundle-size/measure/toolchain.ts +++ b/scripts/bundle-size/measure/toolchain.ts @@ -62,7 +62,11 @@ export function measure( return R.ok( Measured({ imports, - readsNodeEnv: yield* R.await(Modules.readsNodeEnv(development.modules, "development")), + readsNodeEnv: (yield* R.await( + R.allAsync( + development.modules.map((module) => Module.readsNodeEnv(module, "development")), + ), + )).some((reads) => reads), production: Build({ sizes: yield* Code.sizes(production.code), library: production.library, @@ -99,20 +103,17 @@ const Code = Val.sealer().impl({ }), }); -type Modules = Val<"Modules", string[]>; -const Modules = Val.sealer().impl({ - /** Whether a module's source reads `NODE_ENV`, before `define` replaces it. */ - readsNodeEnv: (modules, mode: Mode): Promise> => +type Module = Val<"Module", string>; +const Module = Val.sealer().impl({ + /** Whether the module's source reads `NODE_ENV`, before `define` replaces it. */ + readsNodeEnv: (module, mode: Mode): Promise> => R.tryPromise({ - try: async () => { - const sources = await Promise.all(modules.map((module) => readFile(module, "utf8"))); - return sources.some((source) => source.includes("NODE_ENV")); - }, + try: async () => (await readFile(module, "utf8")).includes("NODE_ENV"), catch: (cause) => new CompileFailed({ mode, step: "reading the library", cause }), }), }); -type Compiled = Val<"Compiled", { code: Code; modules: Modules; library: number }>; +type Compiled = Val<"Compiled", { code: Code; modules: Module[]; library: number }>; const Compiled = Val.sealer(); function compile(mode: Mode, imports: Imports): Promise> { @@ -165,7 +166,9 @@ function compile(mode: Mode, imports: Imports): Promise id !== chunk.facadeModuleId)), + modules: chunk.moduleIds + .filter((id) => id !== chunk.facadeModuleId) + .map((id) => Module(id)), // A module's own `renderedLength` counts code that tree-shaking drops, so the library is // what the chunk holds beyond its entry. library: chunk.code.length - (entry?.renderedLength ?? 0), From db802e85a73556180af69bf55216897d0a632e04 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:17:15 +0900 Subject: [PATCH 30/52] refactor: suffix the bundle-size error names with Error --- scripts/bundle-size/measure/index.ts | 15 ++++++---- scripts/bundle-size/measure/toolchain.ts | 36 ++++++++++++------------ scripts/bundle-size/suite.ts | 29 +++++++++++++------ 3 files changed, 47 insertions(+), 33 deletions(-) diff --git a/scripts/bundle-size/measure/index.ts b/scripts/bundle-size/measure/index.ts index 21de7e4..a63c130 100644 --- a/scripts/bundle-size/measure/index.ts +++ b/scripts/bundle-size/measure/index.ts @@ -2,12 +2,15 @@ import { Result as R, TaggedError } from "better-result"; import { Val } from "../../../src/index.ts"; import { Imports, type Bundle } from "../bundle.ts"; -export class BranchIgnored extends TaggedError("BranchIgnored")<{ +export class BranchIgnoredError extends TaggedError("BranchIgnoredError")<{ imports: Imports; message: string; }> {} -export class AddsNothing extends TaggedError("AddsNothing")<{ bundle: Bundle; message: string }> {} +export class AddsNothingError extends TaggedError("AddsNothingError")<{ + bundle: Bundle; + message: string; +}> {} export type Mode = "production" | "development"; export const modes: readonly Mode[] = ["production", "development"]; @@ -35,12 +38,12 @@ export type Measured = Val< } >; export const Measured = Val.sealer().impl({ - check: (measured): R => + check: (measured): R => !measured.readsNodeEnv || measured.production.sizes.minified !== measured.development.sizes.minified ? R.ok() : R.err( - new BranchIgnored({ + new BranchIgnoredError({ imports: measured.imports, message: `production keeps the development branches: import { ${Imports.names(measured.imports).join(", ")} }`, }), @@ -65,11 +68,11 @@ export const MeasuredBundle = Val.companion() })) .impl({ over: (result): boolean => result.capped > result.bundle.budget, - check: (result, base: Measured): R => + check: (result, base: Measured): R => result.bundle.base || result.measured.production.library !== base.production.library ? R.ok() : R.err( - new AddsNothing({ + new AddsNothingError({ bundle: result.bundle, message: `${result.bundle.name} adds nothing to the base, which already holds it`, }), diff --git a/scripts/bundle-size/measure/toolchain.ts b/scripts/bundle-size/measure/toolchain.ts index d74f7bd..ed994a8 100644 --- a/scripts/bundle-size/measure/toolchain.ts +++ b/scripts/bundle-size/measure/toolchain.ts @@ -11,11 +11,11 @@ import { Val } from "../../../src/index.ts"; import { Imports } from "../bundle.ts"; import { Build, Measured, Sizes, type Mode } from "./index.ts"; -export class PackFailed extends TaggedError("PackFailed")<{ message: string }> {} +export class PackError extends TaggedError("PackError")<{ message: string }> {} -export class NoChunk extends TaggedError("NoChunk")<{ mode: Mode; message: string }> {} +export class NoChunkError extends TaggedError("NoChunkError")<{ mode: Mode; message: string }> {} -export class CompileFailed extends TaggedError("CompileFailed")<{ +export class CompileError extends TaggedError("CompileError")<{ mode: Mode; step: string; cause: unknown; @@ -26,7 +26,7 @@ export class CompileFailed extends TaggedError("CompileFailed")<{ } } -export class MinifyFailed extends TaggedError("MinifyFailed")<{ +export class MinifyError extends TaggedError("MinifyError")<{ cause: unknown; message: string; }> { @@ -37,7 +37,7 @@ export class MinifyFailed extends TaggedError("MinifyFailed")<{ const root = new URL("../../../", import.meta.url); -export function pack(): Promise> { +export function pack(): Promise> { return R.tryPromise({ try: async () => { await promisify(execFile)(fileURLToPath(new URL("node_modules/.bin/vp", root)), ["pack"], { @@ -47,14 +47,14 @@ export function pack(): Promise> { catch: (cause) => { const { stdout = "", stderr = "" } = cause as { stdout?: string; stderr?: string }; const output = `${stdout}${stderr}` || String(cause); - return new PackFailed({ message: `vp pack failed\n${output}` }); + return new PackError({ message: `vp pack failed\n${output}` }); }, }); } export function measure( imports: Imports, -): Promise> { +): Promise> { return R.gen(async function* () { const [production, development] = yield* R.await( R.allAsync([compile("production", imports), compile("development", imports)] as const), @@ -82,12 +82,12 @@ export function measure( type Code = Val<"Code", string>; const Code = Val.sealer().impl({ - minify: (code): R => + minify: (code): R => R.try({ try: () => Code(minifySync("bundle.mjs", code).code), - catch: (cause) => new MinifyFailed({ cause }), + catch: (cause) => new MinifyError({ cause }), }), - sizes: (code): R => + sizes: (code): R => Code.minify(code).map((minified) => { const bytes = Buffer.from(minified, "utf8"); return Sizes({ @@ -106,22 +106,22 @@ const Code = Val.sealer().impl({ type Module = Val<"Module", string>; const Module = Val.sealer().impl({ /** Whether the module's source reads `NODE_ENV`, before `define` replaces it. */ - readsNodeEnv: (module, mode: Mode): Promise> => + readsNodeEnv: (module, mode: Mode): Promise> => R.tryPromise({ try: async () => (await readFile(module, "utf8")).includes("NODE_ENV"), - catch: (cause) => new CompileFailed({ mode, step: "reading the library", cause }), + catch: (cause) => new CompileError({ mode, step: "reading the library", cause }), }), }); type Compiled = Val<"Compiled", { code: Code; modules: Module[]; library: number }>; const Compiled = Val.sealer(); -function compile(mode: Mode, imports: Imports): Promise> { +function compile(mode: Mode, imports: Imports): Promise> { return R.gen(async function* () { const dir = yield* R.await( R.tryPromise({ try: () => mkdtemp(join(tmpdir(), "valof-size-")), - catch: (cause) => new CompileFailed({ mode, step: "creating the entry directory", cause }), + catch: (cause) => new CompileError({ mode, step: "creating the entry directory", cause }), }), ); const file = join(dir, "entry.mjs"); @@ -137,7 +137,7 @@ function compile(mode: Mode, imports: Imports): Promise new CompileFailed({ mode, step: "writing the entry", cause }), + catch: (cause) => new CompileError({ mode, step: "writing the entry", cause }), }), ); const result = yield* R.await( @@ -155,12 +155,12 @@ function compile(mode: Mode, imports: Imports): Promise new CompileFailed({ mode, step: "the build", cause }), + catch: (cause) => new CompileError({ mode, step: "the build", cause }), }), ); const chunk = result[0]?.output.find((output) => output.type === "chunk"); if (chunk?.type !== "chunk") { - return R.err(new NoChunk({ mode, message: `no chunk emitted for ${mode}` })); + return R.err(new NoChunkError({ mode, message: `no chunk emitted for ${mode}` })); } const entry = chunk.facadeModuleId === null ? undefined : chunk.modules[chunk.facadeModuleId]; return R.ok( @@ -179,7 +179,7 @@ function compile(mode: Mode, imports: Imports): Promise rm(dir, { recursive: true, force: true }), - catch: (cause) => new CompileFailed({ mode, step: "removing the entry directory", cause }), + catch: (cause) => new CompileError({ mode, step: "removing the entry directory", cause }), }), ); return built; diff --git a/scripts/bundle-size/suite.ts b/scripts/bundle-size/suite.ts index 5eb9b90..fd225f4 100644 --- a/scripts/bundle-size/suite.ts +++ b/scripts/bundle-size/suite.ts @@ -1,17 +1,22 @@ import { Result as R, TaggedError } from "better-result"; import { Val } from "../../src/index.ts"; import { Imports, type Bundle } from "./bundle.ts"; -import { Measured, MeasuredBundle, type AddsNothing, type BranchIgnored } from "./measure/index.ts"; +import { + Measured, + MeasuredBundle, + type AddsNothingError, + type BranchIgnoredError, +} from "./measure/index.ts"; import { measure, pack, - type CompileFailed, - type MinifyFailed, - type NoChunk, - type PackFailed, + type CompileError, + type MinifyError, + type NoChunkError, + type PackError, } from "./measure/toolchain.ts"; -export class NoBase extends TaggedError("NoBase")<{ message: string }> {} +export class NoBaseError extends TaggedError("NoBaseError")<{ message: string }> {} export type Measurement = Val< "Measurement", @@ -25,20 +30,26 @@ export const Measurement = Val.sealer(); export type Suite = Val<"Suite", Bundle[]>; export const Suite = Val.sealer().impl({ - base: (suite): R => { + base: (suite): R => { const [first, ...others] = suite .filter((bundle) => bundle.base) .map((bundle) => bundle.imports); return first !== undefined ? R.ok(others.reduce((merged, imports) => Imports.merge(merged, imports), first)) - : R.err(new NoBase({ message: "no bundle sets base" })); + : R.err(new NoBaseError({ message: "no bundle sets base" })); }, measure: ( suite: Suite, ): Promise< R< Measurement, - PackFailed | NoBase | NoChunk | CompileFailed | MinifyFailed | BranchIgnored | AddsNothing + | PackError + | NoBaseError + | NoChunkError + | CompileError + | MinifyError + | BranchIgnoredError + | AddsNothingError > > => R.gen(async function* () { From b08d01b5031541324afb5d58f15aae227e1edf32 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 20:03:21 +0900 Subject: [PATCH 31/52] =?UTF-8?q?docs(notes):=20use=20the=20renamed=20bund?= =?UTF-8?q?le-size=20errors=20in=20=C2=A716.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- notes/design.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/notes/design.md b/notes/design.md index d68e89e..d980d46 100644 --- a/notes/design.md +++ b/notes/design.md @@ -6571,10 +6571,10 @@ import しない core で「equals が残っている」と出た。development 今は 2 つの検査とも、特定の名前や文字列に頼らず、サイズで比べる。 -| 検査 | 失敗する条件 | -| --------------- | ---------------------------------------------------------------------------------------------------- | -| `BranchIgnored` | バンドルに入ったソースが `NODE_ENV` を読むのに、production と development の minify 後のサイズが同じ | -| `AddsNothing` | ベースの上に積んだバンドルのライブラリ部分が、ベースと同じ長さ。足した import はベースに入っている | +| 検査 | 失敗する条件 | +| -------------------- | ---------------------------------------------------------------------------------------------------- | +| `BranchIgnoredError` | バンドルに入ったソースが `NODE_ENV` を読むのに、production と development の minify 後のサイズが同じ | +| `AddsNothingError` | ベースの上に積んだバンドルのライブラリ部分が、ベースと同じ長さ。足した import はベースに入っている | - **ライブラリ部分は「チャンクの長さ − エントリの `renderedLength`」で測る。**エントリは import した名前を `console.log` に渡すので、全体を比べると名前の分だけ必ず違う。各モジュールの `renderedLength` は @@ -6585,8 +6585,8 @@ import しない core で「equals が残っている」と出た。development | 壊し方 | 結果 | | -------------------------------------------------------------- | ------------------------------------------ | -| `src/val.ts` のトップレベルで `equals` を参照し、core に漏らす | `AddsNothing` | -| `define` を効かなくし、両方のモードを同じコードにする | `BranchIgnored` | +| `src/val.ts` のトップレベルで `equals` を参照し、core に漏らす | `AddsNothingError` | +| `define` を効かなくし、両方のモードを同じコードにする | `BranchIgnoredError` | | `development` を `true` に固定する | 検査は通る。ソースが `NODE_ENV` を読まない | **拾えないもの: development 用の分岐の一部だけが production に残る場合。**`development` で囲み忘れた From 572aef14dc5d0195a4ec953121213abff17eb6d2 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:22:36 +0900 Subject: [PATCH 32/52] refactor: cast the build promise instead of wrapping it in async --- scripts/bundle-size/measure/toolchain.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/bundle-size/measure/toolchain.ts b/scripts/bundle-size/measure/toolchain.ts index ed994a8..865c05b 100644 --- a/scripts/bundle-size/measure/toolchain.ts +++ b/scripts/bundle-size/measure/toolchain.ts @@ -142,8 +142,8 @@ function compile(mode: Mode, imports: Imports): Promise - (await build({ + try: () => + build({ root: fileURLToPath(root), logLevel: "silent", configFile: false, @@ -154,7 +154,7 @@ function compile(mode: Mode, imports: Imports): Promise, catch: (cause) => new CompileError({ mode, step: "the build", cause }), }), ); From 9381b03488075ba6dfa84f56cb4981ba841fb4fa Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:25:09 +0900 Subject: [PATCH 33/52] refactor: rename the bundle-size results to BuildResult, BundleSize and Report --- scripts/bundle-size/cli.ts | 10 +++--- scripts/bundle-size/format.ts | 42 ++++++++++++------------ scripts/bundle-size/measure/index.ts | 41 +++++++++++------------ scripts/bundle-size/measure/toolchain.ts | 6 ++-- scripts/bundle-size/suite.ts | 38 ++++++++++----------- 5 files changed, 66 insertions(+), 71 deletions(-) diff --git a/scripts/bundle-size/cli.ts b/scripts/bundle-size/cli.ts index edb5fb8..87a969c 100644 --- a/scripts/bundle-size/cli.ts +++ b/scripts/bundle-size/cli.ts @@ -1,13 +1,13 @@ -import { over, report } from "./format.ts"; -import { MeasuredBundle } from "./measure/index.ts"; +import { over, render } from "./format.ts"; +import { BundleSize } from "./measure/index.ts"; import { Suite } from "./suite.ts"; export const Cli = { run: async (suite: Suite): Promise => { process.exitCode = (await Suite.measure(suite)).match({ - ok: (measurement) => { - console.log(report(measurement, process.argv.includes("--json"))); - const overs = measurement.results.filter(MeasuredBundle.over).map(over); + ok: (report) => { + console.log(render(report, process.argv.includes("--json"))); + const overs = report.bundles.filter(BundleSize.over).map(over); for (const line of overs.length > 0 ? ["", ...overs] : []) console.error(line); return overs.length > 0 ? 1 : 0; }, diff --git a/scripts/bundle-size/format.ts b/scripts/bundle-size/format.ts index 0c9b442..5b425e2 100644 --- a/scripts/bundle-size/format.ts +++ b/scripts/bundle-size/format.ts @@ -1,19 +1,19 @@ import { Imports } from "./bundle.ts"; -import { modes, type Measured, type MeasuredBundle } from "./measure/index.ts"; -import type { Measurement } from "./suite.ts"; +import { modes, type BuildResult, type BundleSize } from "./measure/index.ts"; +import type { Report } from "./suite.ts"; -export function over(result: MeasuredBundle): string { - return `over budget: ${label(result)} is ${amount(result, result.capped)}, budget ${amount(result, result.bundle.budget)}`; +export function over(size: BundleSize): string { + return `over budget: ${label(size)} is ${amount(size, size.capped)}, budget ${amount(size, size.bundle.budget)}`; } -export function report({ results, separateBase }: Measurement, asJson: boolean): string { +export function render({ bundles, separateBase }: Report, asJson: boolean): string { return asJson - ? json(results) + ? json(bundles) : [ - ...[...separateBase, ...results.map(({ measured }) => measured)].map( + ...[...separateBase, ...bundles.map(({ build }) => build)].map( (each) => `${sizes(each)}\n`, ), - budgets(results), + budgets(bundles), ].join("\n"); } @@ -30,38 +30,38 @@ function align(rows: readonly (readonly string[])[], sides: readonly ("left" | " return rows.map((_, index) => columns.map((cells) => cells[index] ?? "")); } -function amount({ bundle }: MeasuredBundle, bytes: number): string { +function amount({ bundle }: BundleSize, bytes: number): string { return bundle.base ? format(bytes) : `+${format(bytes)}`; } -function label({ bundle }: MeasuredBundle): string { +function label({ bundle }: BundleSize): string { return bundle.base ? bundle.name : `added by ${bundle.name}`; } -function sizes(measured: Measured): string { +function sizes(build: BuildResult): string { const heads = ["minified", "gzip", "brotli"] as const; const rows = align( [ ["", ...heads], - ...modes.map((mode) => [mode, ...heads.map((head) => format(measured[mode].sizes[head]))]), + ...modes.map((mode) => [mode, ...heads.map((head) => format(build[mode].sizes[head]))]), ], ["left", "right", "right", "right"], ); return [ - `bundle import { ${Imports.names(measured.imports).join(", ")} }`, + `bundle import { ${Imports.names(build.imports).join(", ")} }`, ...rows.map((cells) => ` ${cells.join(" ")}`), ].join("\n"); } -function budgets(results: readonly MeasuredBundle[]): string { - const rows = results.map((result) => { - const bytes = result.capped; - const max = result.bundle.budget; +function budgets(bundles: readonly BundleSize[]): string { + const rows = bundles.map((size) => { + const bytes = size.capped; + const max = size.bundle.budget; const left = bytes > max ? `${format(bytes - max)} over` : `${format(max - bytes)} left (${Math.round((1 - bytes / max) * 100)}%)`; - return [label(result), amount(result, bytes), amount(result, max), left]; + return [label(size), amount(size, bytes), amount(size, max), left]; }); return [ "budget (production gzip)", @@ -71,11 +71,11 @@ function budgets(results: readonly MeasuredBundle[]): string { ].join("\n"); } -function json(results: readonly MeasuredBundle[]): string { +function json(bundles: readonly BundleSize[]): string { const keyed = Object.fromEntries( - results.map(({ bundle, measured }) => [ + bundles.map(({ bundle, build }) => [ bundle.key, - Object.fromEntries(modes.map((mode) => [mode, measured[mode].sizes])), + Object.fromEntries(modes.map((mode) => [mode, build[mode].sizes])), ]), ); return JSON.stringify(keyed, null, 2); diff --git a/scripts/bundle-size/measure/index.ts b/scripts/bundle-size/measure/index.ts index a63c130..44b195b 100644 --- a/scripts/bundle-size/measure/index.ts +++ b/scripts/bundle-size/measure/index.ts @@ -28,8 +28,8 @@ export type Build = Val< >; export const Build = Val.sealer(); -export type Measured = Val< - "Measured", +export type BuildResult = Val< + "BuildResult", { imports: Imports; readsNodeEnv: boolean; @@ -37,44 +37,43 @@ export type Measured = Val< development: Build; } >; -export const Measured = Val.sealer().impl({ - check: (measured): R => - !measured.readsNodeEnv || - measured.production.sizes.minified !== measured.development.sizes.minified +export const BuildResult = Val.sealer().impl({ + check: (build): R => + !build.readsNodeEnv || build.production.sizes.minified !== build.development.sizes.minified ? R.ok() : R.err( new BranchIgnoredError({ - imports: measured.imports, - message: `production keeps the development branches: import { ${Imports.names(measured.imports).join(", ")} }`, + imports: build.imports, + message: `production keeps the development branches: import { ${Imports.names(build.imports).join(", ")} }`, }), ), }); -export type MeasuredBundle = Val< - "MeasuredBundle", +export type BundleSize = Val< + "BundleSize", { bundle: Bundle; - measured: Measured; + build: BuildResult; capped: number; } >; -export const MeasuredBundle = Val.companion() - .implCreate((bundle: Bundle, measured: Measured, base: Measured) => ({ +export const BundleSize = Val.companion() + .implCreate((bundle: Bundle, build: BuildResult, base: BuildResult) => ({ bundle, - measured, + build, capped: bundle.base - ? measured.production.sizes.gzip - : measured.production.sizes.gzip - base.production.sizes.gzip, + ? build.production.sizes.gzip + : build.production.sizes.gzip - base.production.sizes.gzip, })) .impl({ - over: (result): boolean => result.capped > result.bundle.budget, - check: (result, base: Measured): R => - result.bundle.base || result.measured.production.library !== base.production.library + over: (size): boolean => size.capped > size.bundle.budget, + check: (size, base: BuildResult): R => + size.bundle.base || size.build.production.library !== base.production.library ? R.ok() : R.err( new AddsNothingError({ - bundle: result.bundle, - message: `${result.bundle.name} adds nothing to the base, which already holds it`, + bundle: size.bundle, + message: `${size.bundle.name} adds nothing to the base, which already holds it`, }), ), }); diff --git a/scripts/bundle-size/measure/toolchain.ts b/scripts/bundle-size/measure/toolchain.ts index 865c05b..6cef029 100644 --- a/scripts/bundle-size/measure/toolchain.ts +++ b/scripts/bundle-size/measure/toolchain.ts @@ -9,7 +9,7 @@ import { Result as R, TaggedError } from "better-result"; import { minifySync, build, type Rolldown } from "vite"; import { Val } from "../../../src/index.ts"; import { Imports } from "../bundle.ts"; -import { Build, Measured, Sizes, type Mode } from "./index.ts"; +import { Build, BuildResult, Sizes, type Mode } from "./index.ts"; export class PackError extends TaggedError("PackError")<{ message: string }> {} @@ -54,13 +54,13 @@ export function pack(): Promise> { export function measure( imports: Imports, -): Promise> { +): Promise> { return R.gen(async function* () { const [production, development] = yield* R.await( R.allAsync([compile("production", imports), compile("development", imports)] as const), ); return R.ok( - Measured({ + BuildResult({ imports, readsNodeEnv: (yield* R.await( R.allAsync( diff --git a/scripts/bundle-size/suite.ts b/scripts/bundle-size/suite.ts index fd225f4..a206fff 100644 --- a/scripts/bundle-size/suite.ts +++ b/scripts/bundle-size/suite.ts @@ -2,8 +2,8 @@ import { Result as R, TaggedError } from "better-result"; import { Val } from "../../src/index.ts"; import { Imports, type Bundle } from "./bundle.ts"; import { - Measured, - MeasuredBundle, + BuildResult, + BundleSize, type AddsNothingError, type BranchIgnoredError, } from "./measure/index.ts"; @@ -18,15 +18,15 @@ import { export class NoBaseError extends TaggedError("NoBaseError")<{ message: string }> {} -export type Measurement = Val< - "Measurement", +export type Report = Val< + "Report", { - base: Measured; - results: MeasuredBundle[]; - separateBase: Measured[]; + base: BuildResult; + bundles: BundleSize[]; + separateBase: BuildResult[]; } >; -export const Measurement = Val.sealer(); +export const Report = Val.sealer(); export type Suite = Val<"Suite", Bundle[]>; export const Suite = Val.sealer().impl({ @@ -42,7 +42,7 @@ export const Suite = Val.sealer().impl({ suite: Suite, ): Promise< R< - Measurement, + Report, | PackError | NoBaseError | NoChunkError @@ -62,23 +62,19 @@ export const Suite = Val.sealer().impl({ await measure( bundle.base ? bundle.imports : Imports.merge(baseImports, bundle.imports), ) - ).map((measured) => ({ bundle, measured })), + ).map((build) => ({ bundle, build })), ), ), ); const [only, ...more] = results.filter(({ bundle }) => bundle.base); const base = - only !== undefined && more.length === 0 - ? only.measured - : yield* R.await(measure(baseImports)); - const separateBase = base === only?.measured ? [] : [base]; - for (const measured of [...separateBase, ...results.map(({ measured }) => measured)]) { - yield* Measured.check(measured); + only !== undefined && more.length === 0 ? only.build : yield* R.await(measure(baseImports)); + const separateBase = base === only?.build ? [] : [base]; + for (const build of [...separateBase, ...results.map(({ build }) => build)]) { + yield* BuildResult.check(build); } - const bundles = results.map(({ bundle, measured }) => - MeasuredBundle.create(bundle, measured, base), - ); - for (const result of bundles) yield* MeasuredBundle.check(result, base); - return R.ok(Measurement({ base, results: bundles, separateBase })); + const bundles = results.map(({ bundle, build }) => BundleSize.create(bundle, build, base)); + for (const size of bundles) yield* BundleSize.check(size, base); + return R.ok(Report({ base, bundles, separateBase })); }), }); From 80dc42d32644941572310d3b2459d107f3effcfa Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:36:51 +0900 Subject: [PATCH 34/52] refactor: chain the bundle-size promises instead of nesting them --- scripts/bundle-size/measure/toolchain.ts | 11 ++++++----- scripts/bundle-size/suite.ts | 10 ++++------ 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/scripts/bundle-size/measure/toolchain.ts b/scripts/bundle-size/measure/toolchain.ts index 6cef029..d4d8853 100644 --- a/scripts/bundle-size/measure/toolchain.ts +++ b/scripts/bundle-size/measure/toolchain.ts @@ -59,14 +59,15 @@ export function measure( const [production, development] = yield* R.await( R.allAsync([compile("production", imports), compile("development", imports)] as const), ); + const readsNodeEnv = yield* R.await( + R.allAsync( + development.modules.map((module) => Module.readsNodeEnv(module, "development")), + ).then(R.map((reads) => reads.some((read) => read))), + ); return R.ok( BuildResult({ imports, - readsNodeEnv: (yield* R.await( - R.allAsync( - development.modules.map((module) => Module.readsNodeEnv(module, "development")), - ), - )).some((reads) => reads), + readsNodeEnv, production: Build({ sizes: yield* Code.sizes(production.code), library: production.library, diff --git a/scripts/bundle-size/suite.ts b/scripts/bundle-size/suite.ts index a206fff..0d1a1ae 100644 --- a/scripts/bundle-size/suite.ts +++ b/scripts/bundle-size/suite.ts @@ -57,12 +57,10 @@ export const Suite = Val.sealer().impl({ const baseImports = yield* Suite.base(suite); const results = yield* R.await( R.allAsync( - suite.map(async (bundle) => - ( - await measure( - bundle.base ? bundle.imports : Imports.merge(baseImports, bundle.imports), - ) - ).map((build) => ({ bundle, build })), + suite.map((bundle) => + measure(bundle.base ? bundle.imports : Imports.merge(baseImports, bundle.imports)).then( + R.map((build) => ({ bundle, build })), + ), ), ), ); From b535402f5ef7c29a63a4f88b889e605467462408 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:47:02 +0900 Subject: [PATCH 35/52] feat: add pipe, flow and tap to the bundle-size scripts --- scripts/bundle-size/util.test.ts | 72 ++++++++++++++++++ scripts/bundle-size/util.ts | 124 +++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 scripts/bundle-size/util.test.ts create mode 100644 scripts/bundle-size/util.ts diff --git a/scripts/bundle-size/util.test.ts b/scripts/bundle-size/util.test.ts new file mode 100644 index 0000000..de7b986 --- /dev/null +++ b/scripts/bundle-size/util.test.ts @@ -0,0 +1,72 @@ +import { Result as R } from "better-result"; +import { describe, expect, expectTypeOf, test, vi } from "vite-plus/test"; + +import { flow, pipe, tap } from "./util.ts"; + +class NotFoundError extends Error {} +class InvalidError extends Error {} + +const wrap = (x: T): T[] => [x]; +const wrapAsync = async (x: T): Promise => [x]; + +describe("pipe", () => { + test("infers each step's argument from the previous step", () => { + const length = pipe( + R.ok("abc"), + R.map((s) => s.length), + R.andThen((n) => (n > 0 ? R.ok(n) : R.err(new InvalidError()))), + ); + expectTypeOf(length).toEqualTypeOf>(); + expect(length.unwrap()).toBe(3); + }); + + test("chains each of eight steps to the one before", () => { + expectTypeOf(pipe(0, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap)).toEqualTypeOf< + number[][][][][][][][] + >(); + }); +}); + +describe("flow", () => { + test("rejects with a step's rejection and skips the steps after it", async () => { + const error = new Error("failed"); + const rest = vi.fn(); + await expect(flow("a", () => Promise.reject(error), rest)).rejects.toBe(error); + expect(rest).not.toHaveBeenCalled(); + }); + + test("awaits the initial value", async () => { + expect(await flow(Promise.resolve("a"), (s) => `${s}b`)).toBe("ab"); + }); + + test("infers each step's argument from the resolved previous step", async () => { + const length = flow( + R.ok("abc"), + R.map((s) => s.length), + R.andThenAsync(async (n) => (n > 0 ? R.ok(n) : R.err(new InvalidError()))), + R.map((n) => n * 2), + ); + expectTypeOf(length).toEqualTypeOf>>(); + expect((await length).unwrap()).toBe(6); + }); + + test("chains each of eight steps to the resolved one before", () => { + expectTypeOf( + flow(0, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync), + ).toEqualTypeOf>(); + }); +}); + +describe("tap", () => { + test("calls the function with the value and passes the value on", () => { + const seen = vi.fn(); + const result = pipe( + 1, + tap((n) => seen(n.toFixed(1))), + (n) => n + 1, + ); + expectTypeOf(result).toEqualTypeOf(); + expect(seen).toHaveBeenCalledWith("1.0"); + expect(result).toBe(2); + }); +}); diff --git a/scripts/bundle-size/util.ts b/scripts/bundle-size/util.ts new file mode 100644 index 0000000..bf108c8 --- /dev/null +++ b/scripts/bundle-size/util.ts @@ -0,0 +1,124 @@ +type Step = (a: A) => B; +type AsyncStep = (a: Awaited) => B; + +/** Passes `a` through each step from left to right. */ +export function pipe(a: A, ab: Step): B; +export function pipe(a: A, ab: Step, bc: Step): C; +export function pipe(a: A, ab: Step, bc: Step, cd: Step): D; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, +): E; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, +): F; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, +): G; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, +): H; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, + hi: Step, +): I; +export function pipe(a: unknown, ...steps: Step[]): unknown { + return steps.reduce((value, step) => step(value), a); +} + +/** {@link pipe} that awaits `a` and every step's result before the next step. */ +export function flow(a: A, ab: AsyncStep): Promise>; +export function flow(a: A, ab: AsyncStep, bc: AsyncStep): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, +): Promise>; +export function flow(a: unknown, ...steps: Step[]): Promise { + return steps.reduce>((value, step) => value.then(step), Promise.resolve(a)); +} + +/** + * A step that calls `fn` with the value and passes the value on. A promise `fn` returns is not + * awaited. + */ +export function tap(fn: (a: A) => unknown): Step { + return (a) => { + fn(a); + return a; + }; +} From 72c0332061f5e02c40de5d1996a6947c5ec93346 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:07:18 +0900 Subject: [PATCH 36/52] feat: let tap wait for a promise its function returns --- scripts/bundle-size/util.test.ts | 60 +++++++++++++++++++++++++++++++- scripts/bundle-size/util.ts | 14 +++++--- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/scripts/bundle-size/util.test.ts b/scripts/bundle-size/util.test.ts index de7b986..7fe0c16 100644 --- a/scripts/bundle-size/util.test.ts +++ b/scripts/bundle-size/util.test.ts @@ -62,11 +62,69 @@ describe("tap", () => { const seen = vi.fn(); const result = pipe( 1, - tap((n) => seen(n.toFixed(1))), + tap((n) => { + seen(n.toFixed(1)); + }), (n) => n + 1, ); expectTypeOf(result).toEqualTypeOf(); expect(seen).toHaveBeenCalledWith("1.0"); expect(result).toBe(2); }); + + test("awaits the promise the function returns before the next step", async () => { + const order: string[] = []; + const tapped = pipe( + 1, + tap( + () => + new Promise((resolve) => + setTimeout(() => { + order.push("tap"); + resolve(); + }), + ), + ), + ); + expectTypeOf(tapped).toEqualTypeOf>(); + expect( + await flow(tapped, (n) => { + order.push("next"); + return n + 1; + }), + ).toBe(2); + expect(order).toEqual(["tap", "next"]); + }); + + test("types the step as a value or a promise when the function's result is any or unknown", () => { + // oxlint-disable-next-line no-explicit-any -- the case under test + expectTypeOf(tap(() => undefined)).returns.toEqualTypeOf< + number | Promise + >(); + expectTypeOf(tap(() => undefined)).returns.toEqualTypeOf< + number | Promise + >(); + }); + + test("passes the value on directly when the function returns a non-native thenable", () => { + const settled = Promise.resolve(); + // oxlint-disable-next-line no-thenable -- the case under test + const thenable: PromiseLike = { then: (onfulfilled) => settled.then(onfulfilled) }; + const result = pipe( + 1, + tap(() => thenable), + ); + expectTypeOf(result).toEqualTypeOf(); + expect(result).toBe(1); + }); + + test("rejects with the rejection of the promise the function returns", async () => { + const error = new Error("failed"); + await expect( + flow( + 1, + tap(() => Promise.reject(error)), + ), + ).rejects.toBe(error); + }); }); diff --git a/scripts/bundle-size/util.ts b/scripts/bundle-size/util.ts index bf108c8..9879783 100644 --- a/scripts/bundle-size/util.ts +++ b/scripts/bundle-size/util.ts @@ -113,12 +113,16 @@ export function flow(a: unknown, ...steps: Step[]): Promise(fn: (a: A) => unknown): Step { +// An `fn` that returns `any` or `unknown` gives `A | Promise`, since only the call tells which. +export function tap( + fn: (a: A) => R, +): (a: A) => unknown extends R ? A | Promise : R extends Promise ? Promise : A; +export function tap(fn: (a: A) => unknown): (a: A) => A | Promise { return (a) => { - fn(a); - return a; + const result = fn(a); + return result instanceof Promise ? result.then(() => a) : a; }; } From cb2edd9d76c7bf97294af89d0e4dccc9e8ebc150 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:33:38 +0900 Subject: [PATCH 37/52] feat: extend pipe and flow to 20 steps, with generated type tests --- scripts/bundle-size/util.ts | 128 ---- .../bundle-size/util/pipe/generate-tests.ts | 90 +++ .../bundle-size/util/pipe/generated.test.ts | 461 +++++++++++++++ .../{util.test.ts => util/pipe/index.test.ts} | 25 +- scripts/bundle-size/util/pipe/index.ts | 548 ++++++++++++++++++ 5 files changed, 1108 insertions(+), 144 deletions(-) delete mode 100644 scripts/bundle-size/util.ts create mode 100644 scripts/bundle-size/util/pipe/generate-tests.ts create mode 100644 scripts/bundle-size/util/pipe/generated.test.ts rename scripts/bundle-size/{util.test.ts => util/pipe/index.test.ts} (86%) create mode 100644 scripts/bundle-size/util/pipe/index.ts diff --git a/scripts/bundle-size/util.ts b/scripts/bundle-size/util.ts deleted file mode 100644 index 9879783..0000000 --- a/scripts/bundle-size/util.ts +++ /dev/null @@ -1,128 +0,0 @@ -type Step = (a: A) => B; -type AsyncStep = (a: Awaited) => B; - -/** Passes `a` through each step from left to right. */ -export function pipe(a: A, ab: Step): B; -export function pipe(a: A, ab: Step, bc: Step): C; -export function pipe(a: A, ab: Step, bc: Step, cd: Step): D; -export function pipe( - a: A, - ab: Step, - bc: Step, - cd: Step, - de: Step, -): E; -export function pipe( - a: A, - ab: Step, - bc: Step, - cd: Step, - de: Step, - ef: Step, -): F; -export function pipe( - a: A, - ab: Step, - bc: Step, - cd: Step, - de: Step, - ef: Step, - fg: Step, -): G; -export function pipe( - a: A, - ab: Step, - bc: Step, - cd: Step, - de: Step, - ef: Step, - fg: Step, - gh: Step, -): H; -export function pipe( - a: A, - ab: Step, - bc: Step, - cd: Step, - de: Step, - ef: Step, - fg: Step, - gh: Step, - hi: Step, -): I; -export function pipe(a: unknown, ...steps: Step[]): unknown { - return steps.reduce((value, step) => step(value), a); -} - -/** {@link pipe} that awaits `a` and every step's result before the next step. */ -export function flow(a: A, ab: AsyncStep): Promise>; -export function flow(a: A, ab: AsyncStep, bc: AsyncStep): Promise>; -export function flow( - a: A, - ab: AsyncStep, - bc: AsyncStep, - cd: AsyncStep, -): Promise>; -export function flow( - a: A, - ab: AsyncStep, - bc: AsyncStep, - cd: AsyncStep, - de: AsyncStep, -): Promise>; -export function flow( - a: A, - ab: AsyncStep, - bc: AsyncStep, - cd: AsyncStep, - de: AsyncStep, - ef: AsyncStep, -): Promise>; -export function flow( - a: A, - ab: AsyncStep, - bc: AsyncStep, - cd: AsyncStep, - de: AsyncStep, - ef: AsyncStep, - fg: AsyncStep, -): Promise>; -export function flow( - a: A, - ab: AsyncStep, - bc: AsyncStep, - cd: AsyncStep, - de: AsyncStep, - ef: AsyncStep, - fg: AsyncStep, - gh: AsyncStep, -): Promise>; -export function flow( - a: A, - ab: AsyncStep, - bc: AsyncStep, - cd: AsyncStep, - de: AsyncStep, - ef: AsyncStep, - fg: AsyncStep, - gh: AsyncStep, - hi: AsyncStep, -): Promise>; -export function flow(a: unknown, ...steps: Step[]): Promise { - return steps.reduce>((value, step) => value.then(step), Promise.resolve(a)); -} - -/** - * A step that calls `fn` with the value and passes the value on. When `fn` returns a promise, the - * step waits for it and passes the value on in a promise. - */ -// An `fn` that returns `any` or `unknown` gives `A | Promise`, since only the call tells which. -export function tap( - fn: (a: A) => R, -): (a: A) => unknown extends R ? A | Promise : R extends Promise ? Promise : A; -export function tap(fn: (a: A) => unknown): (a: A) => A | Promise { - return (a) => { - const result = fn(a); - return result instanceof Promise ? result.then(() => a) : a; - }; -} diff --git a/scripts/bundle-size/util/pipe/generate-tests.ts b/scripts/bundle-size/util/pipe/generate-tests.ts new file mode 100644 index 0000000..3ad0e63 --- /dev/null +++ b/scripts/bundle-size/util/pipe/generate-tests.ts @@ -0,0 +1,90 @@ +import { execFile } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +/** The most steps `pipe` and `flow` take, as their overloads in `index.ts` declare. */ +const steps = 20; + +const root = new URL("../../../../", import.meta.url); +const header = + "// Generated by generate-tests.ts. Run `node scripts/bundle-size/util/pipe/generate-tests.ts` to update."; + +function nested(depth: number): string { + return `number${"[]".repeat(depth)}`; +} + +// The type error lands on the first step past the last overload, so the directive goes there. +function tooMany(call: string): string { + return [ + ` ${call}(`, + " 0,", + ...Array(steps).fill(" wrap,"), + " // @ts-expect-error no overload takes this many steps", + " wrap,", + " );", + ].join("\n"); +} + +const lengths = Array.from({ length: steps }, (_, i) => i + 1); + +// Every step changes the type, so an overload that wires a step to the wrong one fails. The last +// step of each `flow` call is async, so an overload whose result skips `Awaited` fails too. +const tests = `${header} +import { describe, expectTypeOf, test } from "vite-plus/test"; + +import { flow, pipe } from "./index.ts"; + +const wrap = (x: T): T[] => [x]; +const wrapAsync = async (x: T): Promise => [x]; + +describe("pipe", () => { + test("chains each step to the one before, at every length", () => { +${lengths + .map( + (n) => + ` expectTypeOf(pipe(0, ${Array(n).fill("wrap").join(", ")})).toEqualTypeOf<${nested(n)}>();`, + ) + .join("\n")} +${tooMany("pipe")} + }); +}); + +describe("flow", () => { + test("chains each step to the resolved one before, at every length", () => { +${lengths + .map((n) => { + const chain = lengths + .slice(0, n) + .map((i) => ((n - i) % 2 === 0 ? "wrapAsync" : "wrap")) + .join(", "); + return ` expectTypeOf(flow(0, ${chain})).toEqualTypeOf>();`; + }) + .join("\n")} +${tooMany("void flow")} + }); +}); +`; + +function format(file: URL, text: string): Promise { + return new Promise((resolve, reject) => { + const child = execFile( + fileURLToPath(new URL("node_modules/.bin/vp", root)), + ["fmt", `--stdin-filepath=${fileURLToPath(file)}`], + { cwd: fileURLToPath(root) }, + (error, stdout) => (error ? reject(error) : resolve(stdout)), + ); + child.stdin?.end(text); + }); +} + +/** Where {@link generate} writes. */ +const output = new URL("generated.test.ts", import.meta.url); + +/** The type tests, one per overload, formatted. */ +export function generate(): Promise { + return format(output, tests); +} + +if (import.meta.main) { + await writeFile(output, await generate()); +} diff --git a/scripts/bundle-size/util/pipe/generated.test.ts b/scripts/bundle-size/util/pipe/generated.test.ts new file mode 100644 index 0000000..7b9d957 --- /dev/null +++ b/scripts/bundle-size/util/pipe/generated.test.ts @@ -0,0 +1,461 @@ +// Generated by generate-tests.ts. Run `node scripts/bundle-size/util/pipe/generate-tests.ts` to update. +import { describe, expectTypeOf, test } from "vite-plus/test"; + +import { flow, pipe } from "./index.ts"; + +const wrap = (x: T): T[] => [x]; +const wrapAsync = async (x: T): Promise => [x]; + +describe("pipe", () => { + test("chains each step to the one before, at every length", () => { + expectTypeOf(pipe(0, wrap)).toEqualTypeOf(); + expectTypeOf(pipe(0, wrap, wrap)).toEqualTypeOf(); + expectTypeOf(pipe(0, wrap, wrap, wrap)).toEqualTypeOf(); + expectTypeOf(pipe(0, wrap, wrap, wrap, wrap)).toEqualTypeOf(); + expectTypeOf(pipe(0, wrap, wrap, wrap, wrap, wrap)).toEqualTypeOf(); + expectTypeOf(pipe(0, wrap, wrap, wrap, wrap, wrap, wrap)).toEqualTypeOf(); + expectTypeOf(pipe(0, wrap, wrap, wrap, wrap, wrap, wrap, wrap)).toEqualTypeOf< + number[][][][][][][] + >(); + expectTypeOf(pipe(0, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap)).toEqualTypeOf< + number[][][][][][][][] + >(); + expectTypeOf(pipe(0, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap)).toEqualTypeOf< + number[][][][][][][][][] + >(); + expectTypeOf(pipe(0, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap)).toEqualTypeOf< + number[][][][][][][][][][] + >(); + expectTypeOf( + pipe(0, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap), + ).toEqualTypeOf(); + expectTypeOf( + pipe(0, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap), + ).toEqualTypeOf(); + expectTypeOf( + pipe(0, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap), + ).toEqualTypeOf(); + expectTypeOf( + pipe(0, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap), + ).toEqualTypeOf(); + expectTypeOf( + pipe( + 0, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + ), + ).toEqualTypeOf(); + expectTypeOf( + pipe( + 0, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + ), + ).toEqualTypeOf(); + expectTypeOf( + pipe( + 0, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + ), + ).toEqualTypeOf(); + expectTypeOf( + pipe( + 0, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + ), + ).toEqualTypeOf(); + expectTypeOf( + pipe( + 0, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + ), + ).toEqualTypeOf(); + expectTypeOf( + pipe( + 0, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + ), + ).toEqualTypeOf(); + pipe( + 0, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + // @ts-expect-error no overload takes this many steps + wrap, + ); + }); +}); + +describe("flow", () => { + test("chains each step to the resolved one before, at every length", () => { + expectTypeOf(flow(0, wrapAsync)).toEqualTypeOf>(); + expectTypeOf(flow(0, wrap, wrapAsync)).toEqualTypeOf>(); + expectTypeOf(flow(0, wrapAsync, wrap, wrapAsync)).toEqualTypeOf>(); + expectTypeOf(flow(0, wrap, wrapAsync, wrap, wrapAsync)).toEqualTypeOf< + Promise + >(); + expectTypeOf(flow(0, wrapAsync, wrap, wrapAsync, wrap, wrapAsync)).toEqualTypeOf< + Promise + >(); + expectTypeOf(flow(0, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync)).toEqualTypeOf< + Promise + >(); + expectTypeOf( + flow(0, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync), + ).toEqualTypeOf>(); + expectTypeOf( + flow(0, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync), + ).toEqualTypeOf>(); + expectTypeOf( + flow(0, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync), + ).toEqualTypeOf>(); + expectTypeOf( + flow(0, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync), + ).toEqualTypeOf>(); + expectTypeOf( + flow( + 0, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + ), + ).toEqualTypeOf>(); + expectTypeOf( + flow( + 0, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + ), + ).toEqualTypeOf>(); + expectTypeOf( + flow( + 0, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + ), + ).toEqualTypeOf>(); + expectTypeOf( + flow( + 0, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + ), + ).toEqualTypeOf>(); + expectTypeOf( + flow( + 0, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + ), + ).toEqualTypeOf>(); + expectTypeOf( + flow( + 0, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + ), + ).toEqualTypeOf>(); + expectTypeOf( + flow( + 0, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + ), + ).toEqualTypeOf>(); + expectTypeOf( + flow( + 0, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + ), + ).toEqualTypeOf>(); + expectTypeOf( + flow( + 0, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + ), + ).toEqualTypeOf>(); + expectTypeOf( + flow( + 0, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + ), + ).toEqualTypeOf>(); + void flow( + 0, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + // @ts-expect-error no overload takes this many steps + wrap, + ); + }); +}); diff --git a/scripts/bundle-size/util.test.ts b/scripts/bundle-size/util/pipe/index.test.ts similarity index 86% rename from scripts/bundle-size/util.test.ts rename to scripts/bundle-size/util/pipe/index.test.ts index 7fe0c16..c0a3cb5 100644 --- a/scripts/bundle-size/util.test.ts +++ b/scripts/bundle-size/util/pipe/index.test.ts @@ -1,14 +1,13 @@ import { Result as R } from "better-result"; +import { readFile } from "node:fs/promises"; import { describe, expect, expectTypeOf, test, vi } from "vite-plus/test"; -import { flow, pipe, tap } from "./util.ts"; +import { generate } from "./generate-tests.ts"; +import { flow, pipe, tap } from "./index.ts"; class NotFoundError extends Error {} class InvalidError extends Error {} -const wrap = (x: T): T[] => [x]; -const wrapAsync = async (x: T): Promise => [x]; - describe("pipe", () => { test("infers each step's argument from the previous step", () => { const length = pipe( @@ -19,12 +18,6 @@ describe("pipe", () => { expectTypeOf(length).toEqualTypeOf>(); expect(length.unwrap()).toBe(3); }); - - test("chains each of eight steps to the one before", () => { - expectTypeOf(pipe(0, wrap, wrap, wrap, wrap, wrap, wrap, wrap, wrap)).toEqualTypeOf< - number[][][][][][][][] - >(); - }); }); describe("flow", () => { @@ -49,12 +42,6 @@ describe("flow", () => { expectTypeOf(length).toEqualTypeOf>>(); expect((await length).unwrap()).toBe(6); }); - - test("chains each of eight steps to the resolved one before", () => { - expectTypeOf( - flow(0, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync), - ).toEqualTypeOf>(); - }); }); describe("tap", () => { @@ -128,3 +115,9 @@ describe("tap", () => { ).rejects.toBe(error); }); }); + +test("the generated tests match generate-tests.ts", async () => { + expect(await readFile(new URL("generated.test.ts", import.meta.url), "utf8")).toBe( + await generate(), + ); +}); diff --git a/scripts/bundle-size/util/pipe/index.ts b/scripts/bundle-size/util/pipe/index.ts new file mode 100644 index 0000000..3c834bb --- /dev/null +++ b/scripts/bundle-size/util/pipe/index.ts @@ -0,0 +1,548 @@ +type Step = (a: A) => B; +type AsyncStep = (a: Awaited) => B; + +/** Passes `a` through each step from left to right. */ +export function pipe(a: A, ab: Step): B; +export function pipe(a: A, ab: Step, bc: Step): C; +export function pipe(a: A, ab: Step, bc: Step, cd: Step): D; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, +): E; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, +): F; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, +): G; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, +): H; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, + hi: Step, +): I; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, + hi: Step, + ij: Step, +): J; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, + hi: Step, + ij: Step, + jk: Step, +): K; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, + hi: Step, + ij: Step, + jk: Step, + kl: Step, +): L; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, + hi: Step, + ij: Step, + jk: Step, + kl: Step, + lm: Step, +): M; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, + hi: Step, + ij: Step, + jk: Step, + kl: Step, + lm: Step, + mn: Step, +): N; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, + hi: Step, + ij: Step, + jk: Step, + kl: Step, + lm: Step, + mn: Step, + no: Step, +): O; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, + hi: Step, + ij: Step, + jk: Step, + kl: Step, + lm: Step, + mn: Step, + no: Step, + op: Step, +): P; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, + hi: Step, + ij: Step, + jk: Step, + kl: Step, + lm: Step, + mn: Step, + no: Step, + op: Step, + pq: Step, +): Q; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, + hi: Step, + ij: Step, + jk: Step, + kl: Step, + lm: Step, + mn: Step, + no: Step, + op: Step, + pq: Step, + qr: Step, +): R; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, + hi: Step, + ij: Step, + jk: Step, + kl: Step, + lm: Step, + mn: Step, + no: Step, + op: Step, + pq: Step, + qr: Step, + rs: Step, +): S; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, + hi: Step, + ij: Step, + jk: Step, + kl: Step, + lm: Step, + mn: Step, + no: Step, + op: Step, + pq: Step, + qr: Step, + rs: Step, + st: Step, +): T; +export function pipe( + a: A, + ab: Step, + bc: Step, + cd: Step, + de: Step, + ef: Step, + fg: Step, + gh: Step, + hi: Step, + ij: Step, + jk: Step, + kl: Step, + lm: Step, + mn: Step, + no: Step, + op: Step, + pq: Step, + qr: Step, + rs: Step, + st: Step, + tu: Step, +): U; +export function pipe(a: unknown, ...steps: Step[]): unknown { + return steps.reduce((value, step) => step(value), a); +} + +/** {@link pipe} that awaits `a` and every step's result before the next step. */ +export function flow(a: A, ab: AsyncStep): Promise>; +export function flow(a: A, ab: AsyncStep, bc: AsyncStep): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + no: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + no: AsyncStep, + op: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + no: AsyncStep, + op: AsyncStep, + pq: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + no: AsyncStep, + op: AsyncStep, + pq: AsyncStep, + qr: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + no: AsyncStep, + op: AsyncStep, + pq: AsyncStep, + qr: AsyncStep, + rs: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + no: AsyncStep, + op: AsyncStep, + pq: AsyncStep, + qr: AsyncStep, + rs: AsyncStep, + st: AsyncStep, +): Promise>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + no: AsyncStep, + op: AsyncStep, + pq: AsyncStep, + qr: AsyncStep, + rs: AsyncStep, + st: AsyncStep, + tu: AsyncStep, +): Promise>; +export function flow(a: unknown, ...steps: Step[]): Promise { + return steps.reduce>((value, step) => value.then(step), Promise.resolve(a)); +} + +/** + * A step that calls `fn` with the value and passes the value on. When `fn` returns a promise, the + * step waits for it and passes the value on in a promise. + */ +// An `fn` that returns `any` or `unknown` gives `A | Promise`, since only the call tells which. +export function tap( + fn: (a: A) => R, +): (a: A) => unknown extends R ? A | Promise : R extends Promise ? Promise : A; +export function tap(fn: (a: A) => unknown): (a: A) => A | Promise { + return (a) => { + const result = fn(a); + return result instanceof Promise ? result.then(() => a) : a; + }; +} From 611a0a22d30dd380580517164d4a3bad7db8e46b Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:39:30 +0900 Subject: [PATCH 38/52] refactor: chain the bundle-size steps with flow --- scripts/bundle-size/cli.ts | 30 ++++++++++++++---------- scripts/bundle-size/measure/toolchain.ts | 7 ++++-- scripts/bundle-size/suite.ts | 5 +++- 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/scripts/bundle-size/cli.ts b/scripts/bundle-size/cli.ts index 87a969c..979a21b 100644 --- a/scripts/bundle-size/cli.ts +++ b/scripts/bundle-size/cli.ts @@ -1,20 +1,26 @@ +import { Result as R } from "better-result"; import { over, render } from "./format.ts"; import { BundleSize } from "./measure/index.ts"; import { Suite } from "./suite.ts"; +import { flow } from "./util/pipe/index.ts"; export const Cli = { run: async (suite: Suite): Promise => { - process.exitCode = (await Suite.measure(suite)).match({ - ok: (report) => { - console.log(render(report, process.argv.includes("--json"))); - const overs = report.bundles.filter(BundleSize.over).map(over); - for (const line of overs.length > 0 ? ["", ...overs] : []) console.error(line); - return overs.length > 0 ? 1 : 0; - }, - err: (error) => { - console.error(error.message); - return 1; - }, - }); + process.exitCode = await flow( + suite, + Suite.measure, + R.match({ + ok: (report) => { + console.log(render(report, process.argv.includes("--json"))); + const overs = report.bundles.filter(BundleSize.over).map(over); + for (const line of overs.length > 0 ? ["", ...overs] : []) console.error(line); + return overs.length > 0 ? 1 : 0; + }, + err: (error) => { + console.error(error.message); + return 1; + }, + }), + ); }, }; diff --git a/scripts/bundle-size/measure/toolchain.ts b/scripts/bundle-size/measure/toolchain.ts index d4d8853..9f6bb8d 100644 --- a/scripts/bundle-size/measure/toolchain.ts +++ b/scripts/bundle-size/measure/toolchain.ts @@ -9,6 +9,7 @@ import { Result as R, TaggedError } from "better-result"; import { minifySync, build, type Rolldown } from "vite"; import { Val } from "../../../src/index.ts"; import { Imports } from "../bundle.ts"; +import { flow } from "../util/pipe/index.ts"; import { Build, BuildResult, Sizes, type Mode } from "./index.ts"; export class PackError extends TaggedError("PackError")<{ message: string }> {} @@ -60,9 +61,11 @@ export function measure( R.allAsync([compile("production", imports), compile("development", imports)] as const), ); const readsNodeEnv = yield* R.await( - R.allAsync( + flow( development.modules.map((module) => Module.readsNodeEnv(module, "development")), - ).then(R.map((reads) => reads.some((read) => read))), + R.allAsync, + R.map((reads) => reads.some((read) => read)), + ), ); return R.ok( BuildResult({ diff --git a/scripts/bundle-size/suite.ts b/scripts/bundle-size/suite.ts index 0d1a1ae..a744ff4 100644 --- a/scripts/bundle-size/suite.ts +++ b/scripts/bundle-size/suite.ts @@ -1,6 +1,7 @@ import { Result as R, TaggedError } from "better-result"; import { Val } from "../../src/index.ts"; import { Imports, type Bundle } from "./bundle.ts"; +import { flow } from "./util/pipe/index.ts"; import { BuildResult, BundleSize, @@ -58,7 +59,9 @@ export const Suite = Val.sealer().impl({ const results = yield* R.await( R.allAsync( suite.map((bundle) => - measure(bundle.base ? bundle.imports : Imports.merge(baseImports, bundle.imports)).then( + flow( + bundle.base ? bundle.imports : Imports.merge(baseImports, bundle.imports), + measure, R.map((build) => ({ bundle, build })), ), ), From b4b0217a6ce30388c84292815d1e32247315063e Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:37:19 +0900 Subject: [PATCH 39/52] feat: let flow end in a sink that takes the promise, and add GenAwait --- .../bundle-size/util/pipe/generate-tests.ts | 45 ++- .../bundle-size/util/pipe/generated.test.ts | 291 ++++++++++++++- scripts/bundle-size/util/pipe/index.test.ts | 49 ++- scripts/bundle-size/util/pipe/index.ts | 337 +++++++++++++++++- scripts/bundle-size/util/util.ts | 15 + 5 files changed, 719 insertions(+), 18 deletions(-) create mode 100644 scripts/bundle-size/util/util.ts diff --git a/scripts/bundle-size/util/pipe/generate-tests.ts b/scripts/bundle-size/util/pipe/generate-tests.ts index 3ad0e63..009562e 100644 --- a/scripts/bundle-size/util/pipe/generate-tests.ts +++ b/scripts/bundle-size/util/pipe/generate-tests.ts @@ -13,30 +13,43 @@ function nested(depth: number): string { return `number${"[]".repeat(depth)}`; } -// The type error lands on the first step past the last overload, so the directive goes there. -function tooMany(call: string): string { +// The type error lands on the first argument no overload can take, so the directive goes there. +function tooMany(call: string, last = "wrap"): string { return [ ` ${call}(`, " 0,", - ...Array(steps).fill(" wrap,"), + ...Array(last === "wrap" ? steps : steps + 1).fill(" wrap,"), " // @ts-expect-error no overload takes this many steps", - " wrap,", + ` ${last},`, " );", ].join("\n"); } +// The last step is async, so an overload whose result skips `Awaited` fails. +function chain(n: number): string { + return lengths + .slice(0, n) + .map((i) => ((n - i) % 2 === 0 ? "wrapAsync" : "wrap")) + .join(", "); +} + const lengths = Array.from({ length: steps }, (_, i) => i + 1); -// Every step changes the type, so an overload that wires a step to the wrong one fails. The last -// step of each `flow` call is async, so an overload whose result skips `Awaited` fails too. +// Every step changes the type, so an overload that wires a step to the wrong one fails. const tests = `${header} import { describe, expectTypeOf, test } from "vite-plus/test"; -import { flow, pipe } from "./index.ts"; +import { flow, pipe, type Sink } from "./index.ts"; const wrap = (x: T): T[] => [x]; const wrapAsync = async (x: T): Promise => [x]; +type Box

= { promise: P }; +interface Boxed extends Sink { + readonly out: Box; +} +const boxed = { sink: (promise: unknown) => ({ promise }) } as unknown as Boxed; + describe("pipe", () => { test("chains each step to the one before, at every length", () => { ${lengths @@ -52,16 +65,20 @@ ${tooMany("pipe")} describe("flow", () => { test("chains each step to the resolved one before, at every length", () => { ${lengths - .map((n) => { - const chain = lengths - .slice(0, n) - .map((i) => ((n - i) % 2 === 0 ? "wrapAsync" : "wrap")) - .join(", "); - return ` expectTypeOf(flow(0, ${chain})).toEqualTypeOf>();`; - }) + .map((n) => ` expectTypeOf(flow(0, ${chain(n)})).toEqualTypeOf>();`) .join("\n")} ${tooMany("void flow")} }); + + test("hands the chain's promise to a sink, at every length", () => { +${lengths + .map( + (n) => + ` expectTypeOf(flow(0, ${chain(n)}, boxed)).toEqualTypeOf<{ promise: Promise<${nested(n)}> }>();`, + ) + .join("\n")} +${tooMany("flow", "boxed")} + }); }); `; diff --git a/scripts/bundle-size/util/pipe/generated.test.ts b/scripts/bundle-size/util/pipe/generated.test.ts index 7b9d957..22aeb63 100644 --- a/scripts/bundle-size/util/pipe/generated.test.ts +++ b/scripts/bundle-size/util/pipe/generated.test.ts @@ -1,11 +1,17 @@ // Generated by generate-tests.ts. Run `node scripts/bundle-size/util/pipe/generate-tests.ts` to update. import { describe, expectTypeOf, test } from "vite-plus/test"; -import { flow, pipe } from "./index.ts"; +import { flow, pipe, type Sink } from "./index.ts"; const wrap = (x: T): T[] => [x]; const wrapAsync = async (x: T): Promise => [x]; +type Box

= { promise: P }; +interface Boxed extends Sink { + readonly out: Box; +} +const boxed = { sink: (promise: unknown) => ({ promise }) } as unknown as Boxed; + describe("pipe", () => { test("chains each step to the one before, at every length", () => { expectTypeOf(pipe(0, wrap)).toEqualTypeOf(); @@ -458,4 +464,287 @@ describe("flow", () => { wrap, ); }); + + test("hands the chain's promise to a sink, at every length", () => { + expectTypeOf(flow(0, wrapAsync, boxed)).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf(flow(0, wrap, wrapAsync, boxed)).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf(flow(0, wrapAsync, wrap, wrapAsync, boxed)).toEqualTypeOf<{ + promise: Promise; + }>(); + expectTypeOf(flow(0, wrap, wrapAsync, wrap, wrapAsync, boxed)).toEqualTypeOf<{ + promise: Promise; + }>(); + expectTypeOf(flow(0, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, boxed)).toEqualTypeOf<{ + promise: Promise; + }>(); + expectTypeOf(flow(0, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, boxed)).toEqualTypeOf<{ + promise: Promise; + }>(); + expectTypeOf( + flow(0, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, boxed), + ).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf( + flow(0, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, boxed), + ).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf( + flow(0, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, boxed), + ).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf( + flow( + 0, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + boxed, + ), + ).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf( + flow( + 0, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + boxed, + ), + ).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf( + flow( + 0, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + boxed, + ), + ).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf( + flow( + 0, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + boxed, + ), + ).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf( + flow( + 0, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + boxed, + ), + ).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf( + flow( + 0, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + boxed, + ), + ).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf( + flow( + 0, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + boxed, + ), + ).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf( + flow( + 0, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + boxed, + ), + ).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf( + flow( + 0, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + boxed, + ), + ).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf( + flow( + 0, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + boxed, + ), + ).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf( + flow( + 0, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + boxed, + ), + ).toEqualTypeOf<{ promise: Promise }>(); + flow( + 0, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + wrap, + // @ts-expect-error no overload takes this many steps + boxed, + ); + }); }); diff --git a/scripts/bundle-size/util/pipe/index.test.ts b/scripts/bundle-size/util/pipe/index.test.ts index c0a3cb5..2fdf352 100644 --- a/scripts/bundle-size/util/pipe/index.test.ts +++ b/scripts/bundle-size/util/pipe/index.test.ts @@ -3,7 +3,8 @@ import { readFile } from "node:fs/promises"; import { describe, expect, expectTypeOf, test, vi } from "vite-plus/test"; import { generate } from "./generate-tests.ts"; -import { flow, pipe, tap } from "./index.ts"; +import { flow, pipe, tap, type Sink } from "./index.ts"; +import { GenAwait } from "../util.ts"; class NotFoundError extends Error {} class InvalidError extends Error {} @@ -42,6 +43,52 @@ describe("flow", () => { expectTypeOf(length).toEqualTypeOf>>(); expect((await length).unwrap()).toBe(6); }); + + test("hands the chain's promise, not its value, to a sink", async () => { + const seen = vi.fn(); + const handed = flow(1, (n) => n + 1, { + sink: (promise: unknown) => { + seen(promise); + return "handed"; + }, + } as unknown as Sink); + expect(handed).toBe("handed"); + const [promise] = seen.mock.calls[0] ?? []; + expect(promise).toBeInstanceOf(Promise); + expect(await promise).toBe(2); + }); +}); + +describe("GenAwait", () => { + test("gives a Result generator the Ok value as flow's last step", async () => { + const result = await R.gen(async function* () { + const n = yield* flow( + R.ok(1), + R.map((n) => n + 1), + GenAwait, + ); + expectTypeOf(n).toEqualTypeOf(); + return R.ok(n * 10); + }); + expectTypeOf(result).toEqualTypeOf>(); + expect(result.unwrap()).toBe(20); + }); + + test("stops a Result generator at an Err as flow's last step", async () => { + const error = new NotFoundError(); + const after = vi.fn(); + const result = await R.gen(async function* () { + yield* flow( + R.err(error), + R.map((n) => n + 1), + GenAwait, + ); + after(); + return R.ok(0); + }); + expect(result.isErr() && result.error).toBe(error); + expect(after).not.toHaveBeenCalled(); + }); }); describe("tap", () => { diff --git a/scripts/bundle-size/util/pipe/index.ts b/scripts/bundle-size/util/pipe/index.ts index 3c834bb..e113921 100644 --- a/scripts/bundle-size/util/pipe/index.ts +++ b/scripts/bundle-size/util/pipe/index.ts @@ -1,6 +1,20 @@ type Step = (a: A) => B; type AsyncStep = (a: Awaited) => B; +/** + * A last step of {@link flow} that takes the chain's promise rather than its value. Extend it with + * an `out` written in terms of `this["in"]`, the promise's type, to type what `sink` returns. + */ +export interface Sink { + readonly in: unknown; + readonly out: unknown; + readonly sink: (promise: never) => unknown; +} + +// Reads `out` with `in` set to `P`: a type-level call, since `sink` alone cannot carry a +// return type that depends on the promise. +type Apply = (F & { readonly in: P })["out"]; + /** Passes `a` through each step from left to right. */ export function pipe(a: A, ab: Step): B; export function pipe(a: A, ab: Step, bc: Step): C; @@ -528,8 +542,327 @@ export function flow, tu: AsyncStep, ): Promise>; -export function flow(a: unknown, ...steps: Step[]): Promise { - return steps.reduce>((value, step) => value.then(step), Promise.resolve(a)); +export function flow( + a: A, + ab: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + no: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + no: AsyncStep, + op: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + no: AsyncStep, + op: AsyncStep, + pq: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + no: AsyncStep, + op: AsyncStep, + pq: AsyncStep, + qr: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + no: AsyncStep, + op: AsyncStep, + pq: AsyncStep, + qr: AsyncStep, + rs: AsyncStep, + last: Last, +): Apply>>; +export function flow( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + no: AsyncStep, + op: AsyncStep, + pq: AsyncStep, + qr: AsyncStep, + rs: AsyncStep, + st: AsyncStep, + last: Last, +): Apply>>; +export function flow< + A, + B, + C, + D, + E, + F, + G, + H, + I, + J, + K, + L, + M, + N, + O, + P, + Q, + R, + S, + T, + U, + Last extends Sink, +>( + a: A, + ab: AsyncStep, + bc: AsyncStep, + cd: AsyncStep, + de: AsyncStep, + ef: AsyncStep, + fg: AsyncStep, + gh: AsyncStep, + hi: AsyncStep, + ij: AsyncStep, + jk: AsyncStep, + kl: AsyncStep, + lm: AsyncStep, + mn: AsyncStep, + no: AsyncStep, + op: AsyncStep, + pq: AsyncStep, + qr: AsyncStep, + rs: AsyncStep, + st: AsyncStep, + tu: AsyncStep, + last: Last, +): Apply>>; +export function flow(a: unknown, ...steps: (Step | Sink)[]): unknown { + const last = steps.at(-1); + const sink = typeof last === "object" ? last : undefined; + const promise = (sink === undefined ? steps : steps.slice(0, -1)).reduce>( + (value, step) => value.then(step as Step), + Promise.resolve(a), + ); + return sink === undefined ? promise : sink.sink(promise as never); } /** diff --git a/scripts/bundle-size/util/util.ts b/scripts/bundle-size/util/util.ts new file mode 100644 index 0000000..95228d0 --- /dev/null +++ b/scripts/bundle-size/util/util.ts @@ -0,0 +1,15 @@ +import { Result as R, type Err } from "better-result"; + +import type { Sink } from "./pipe/index.ts"; + +interface GenAwait extends Sink { + readonly out: this["in"] extends Promise> + ? AsyncGenerator, A> + : never; +} + +/** + * The last step of a `flow` that holds a Result: inside `Result.gen`, `yield*` the call to + * take the Ok value, or to stop at the Err. + */ +export const GenAwait = { sink: R.await } as unknown as GenAwait; From 079b9b4bee27423f29ba961d13ff32d53f313199 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:47:08 +0900 Subject: [PATCH 40/52] refactor: await the suite's measurements with GenAwait --- scripts/bundle-size/suite.ts | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/scripts/bundle-size/suite.ts b/scripts/bundle-size/suite.ts index a744ff4..80ba163 100644 --- a/scripts/bundle-size/suite.ts +++ b/scripts/bundle-size/suite.ts @@ -2,6 +2,7 @@ import { Result as R, TaggedError } from "better-result"; import { Val } from "../../src/index.ts"; import { Imports, type Bundle } from "./bundle.ts"; import { flow } from "./util/pipe/index.ts"; +import { GenAwait } from "./util/util.ts"; import { BuildResult, BundleSize, @@ -55,27 +56,37 @@ export const Suite = Val.sealer().impl({ > => R.gen(async function* () { yield* R.await(pack()); + const baseImports = yield* Suite.base(suite); - const results = yield* R.await( - R.allAsync( - suite.map((bundle) => - flow( - bundle.base ? bundle.imports : Imports.merge(baseImports, bundle.imports), - measure, - R.map((build) => ({ bundle, build })), - ), + const results = yield* flow( + suite.map((bundle) => + flow( + bundle.base ? bundle.imports : Imports.merge(baseImports, bundle.imports), + measure, + R.map((build) => ({ bundle, build })), ), ), + R.allAsync, + GenAwait, ); + const [only, ...more] = results.filter(({ bundle }) => bundle.base); const base = - only !== undefined && more.length === 0 ? only.build : yield* R.await(measure(baseImports)); + only !== undefined && more.length === 0 + ? only.build + : yield* flow(baseImports, measure, GenAwait); const separateBase = base === only?.build ? [] : [base]; + for (const build of [...separateBase, ...results.map(({ build }) => build)]) { yield* BuildResult.check(build); } + const bundles = results.map(({ bundle, build }) => BundleSize.create(bundle, build, base)); - for (const size of bundles) yield* BundleSize.check(size, base); + + for (const size of bundles) { + yield* BundleSize.check(size, base); + } + return R.ok(Report({ base, bundles, separateBase })); }), }); From b6e73c469a49281319c276fd382577b8c3332715 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:54:40 +0900 Subject: [PATCH 41/52] refactor: await the toolchain's measurements with GenAwait --- scripts/bundle-size/measure/toolchain.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/scripts/bundle-size/measure/toolchain.ts b/scripts/bundle-size/measure/toolchain.ts index 9f6bb8d..0850ee7 100644 --- a/scripts/bundle-size/measure/toolchain.ts +++ b/scripts/bundle-size/measure/toolchain.ts @@ -10,6 +10,7 @@ import { minifySync, build, type Rolldown } from "vite"; import { Val } from "../../../src/index.ts"; import { Imports } from "../bundle.ts"; import { flow } from "../util/pipe/index.ts"; +import { GenAwait } from "../util/util.ts"; import { Build, BuildResult, Sizes, type Mode } from "./index.ts"; export class PackError extends TaggedError("PackError")<{ message: string }> {} @@ -57,15 +58,16 @@ export function measure( imports: Imports, ): Promise> { return R.gen(async function* () { - const [production, development] = yield* R.await( - R.allAsync([compile("production", imports), compile("development", imports)] as const), + const [production, development] = yield* flow( + [compile("production", imports), compile("development", imports)] as const, + R.allAsync, + GenAwait, ); - const readsNodeEnv = yield* R.await( - flow( - development.modules.map((module) => Module.readsNodeEnv(module, "development")), - R.allAsync, - R.map((reads) => reads.some((read) => read)), - ), + const readsNodeEnv = yield* flow( + development.modules.map((module) => Module.readsNodeEnv(module, "development")), + R.allAsync, + R.map((reads) => reads.some((read) => read)), + GenAwait, ); return R.ok( BuildResult({ From 9c6f5650a719739d3011efc589f9787160679fd9 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:55:09 +0900 Subject: [PATCH 42/52] refactor: build the bundle-size error messages in their constructors --- scripts/bundle-size/measure/index.ts | 33 ++++++++++++------------ scripts/bundle-size/measure/toolchain.ts | 16 +++++++++--- scripts/bundle-size/suite.ts | 12 ++++++--- 3 files changed, 37 insertions(+), 24 deletions(-) diff --git a/scripts/bundle-size/measure/index.ts b/scripts/bundle-size/measure/index.ts index 44b195b..797a8e8 100644 --- a/scripts/bundle-size/measure/index.ts +++ b/scripts/bundle-size/measure/index.ts @@ -2,15 +2,26 @@ import { Result as R, TaggedError } from "better-result"; import { Val } from "../../../src/index.ts"; import { Imports, type Bundle } from "../bundle.ts"; -export class BranchIgnoredError extends TaggedError("BranchIgnoredError")<{ +export class DevCodeKeptError extends TaggedError("DevCodeKeptError")<{ imports: Imports; message: string; -}> {} +}> { + constructor({ imports }: { imports: Imports }) { + super({ + imports, + message: `production keeps the development branches: import { ${Imports.names(imports).join(", ")} }`, + }); + } +} export class AddsNothingError extends TaggedError("AddsNothingError")<{ bundle: Bundle; message: string; -}> {} +}> { + constructor({ bundle }: { bundle: Bundle }) { + super({ bundle, message: `${bundle.name} adds nothing to the base, which already holds it` }); + } +} export type Mode = "production" | "development"; export const modes: readonly Mode[] = ["production", "development"]; @@ -38,15 +49,10 @@ export type BuildResult = Val< } >; export const BuildResult = Val.sealer().impl({ - check: (build): R => + check: (build): R => !build.readsNodeEnv || build.production.sizes.minified !== build.development.sizes.minified ? R.ok() - : R.err( - new BranchIgnoredError({ - imports: build.imports, - message: `production keeps the development branches: import { ${Imports.names(build.imports).join(", ")} }`, - }), - ), + : R.err(new DevCodeKeptError({ imports: build.imports })), }); export type BundleSize = Val< @@ -70,10 +76,5 @@ export const BundleSize = Val.companion() check: (size, base: BuildResult): R => size.bundle.base || size.build.production.library !== base.production.library ? R.ok() - : R.err( - new AddsNothingError({ - bundle: size.bundle, - message: `${size.bundle.name} adds nothing to the base, which already holds it`, - }), - ), + : R.err(new AddsNothingError({ bundle: size.bundle })), }); diff --git a/scripts/bundle-size/measure/toolchain.ts b/scripts/bundle-size/measure/toolchain.ts index 0850ee7..f80469f 100644 --- a/scripts/bundle-size/measure/toolchain.ts +++ b/scripts/bundle-size/measure/toolchain.ts @@ -13,9 +13,17 @@ import { flow } from "../util/pipe/index.ts"; import { GenAwait } from "../util/util.ts"; import { Build, BuildResult, Sizes, type Mode } from "./index.ts"; -export class PackError extends TaggedError("PackError")<{ message: string }> {} +export class PackError extends TaggedError("PackError")<{ output: string; message: string }> { + constructor({ output }: { output: string }) { + super({ output, message: `vp pack failed\n${output}` }); + } +} -export class NoChunkError extends TaggedError("NoChunkError")<{ mode: Mode; message: string }> {} +export class NoChunkError extends TaggedError("NoChunkError")<{ mode: Mode; message: string }> { + constructor({ mode }: { mode: Mode }) { + super({ mode, message: `no chunk emitted for ${mode}` }); + } +} export class CompileError extends TaggedError("CompileError")<{ mode: Mode; @@ -49,7 +57,7 @@ export function pack(): Promise> { catch: (cause) => { const { stdout = "", stderr = "" } = cause as { stdout?: string; stderr?: string }; const output = `${stdout}${stderr}` || String(cause); - return new PackError({ message: `vp pack failed\n${output}` }); + return new PackError({ output }); }, }); } @@ -166,7 +174,7 @@ function compile(mode: Mode, imports: Imports): Promise output.type === "chunk"); if (chunk?.type !== "chunk") { - return R.err(new NoChunkError({ mode, message: `no chunk emitted for ${mode}` })); + return R.err(new NoChunkError({ mode })); } const entry = chunk.facadeModuleId === null ? undefined : chunk.modules[chunk.facadeModuleId]; return R.ok( diff --git a/scripts/bundle-size/suite.ts b/scripts/bundle-size/suite.ts index 80ba163..ec4dc2d 100644 --- a/scripts/bundle-size/suite.ts +++ b/scripts/bundle-size/suite.ts @@ -7,7 +7,7 @@ import { BuildResult, BundleSize, type AddsNothingError, - type BranchIgnoredError, + type DevCodeKeptError, } from "./measure/index.ts"; import { measure, @@ -18,7 +18,11 @@ import { type PackError, } from "./measure/toolchain.ts"; -export class NoBaseError extends TaggedError("NoBaseError")<{ message: string }> {} +export class NoBaseError extends TaggedError("NoBaseError")<{ message: string }> { + constructor() { + super({ message: "no bundle sets base" }); + } +} export type Report = Val< "Report", @@ -38,7 +42,7 @@ export const Suite = Val.sealer().impl({ .map((bundle) => bundle.imports); return first !== undefined ? R.ok(others.reduce((merged, imports) => Imports.merge(merged, imports), first)) - : R.err(new NoBaseError({ message: "no bundle sets base" })); + : R.err(new NoBaseError()); }, measure: ( suite: Suite, @@ -50,7 +54,7 @@ export const Suite = Val.sealer().impl({ | NoChunkError | CompileError | MinifyError - | BranchIgnoredError + | DevCodeKeptError | AddsNothingError > > => From 362a480cc1653db73f35993ffd7d0c1e028f94c0 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:57:21 +0900 Subject: [PATCH 43/52] =?UTF-8?q?docs(notes):=20record=20the=20DevCodeKept?= =?UTF-8?q?Error=20rename=20in=20=C2=A716.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- notes/design.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/notes/design.md b/notes/design.md index d980d46..d1814d4 100644 --- a/notes/design.md +++ b/notes/design.md @@ -6571,10 +6571,10 @@ import しない core で「equals が残っている」と出た。development 今は 2 つの検査とも、特定の名前や文字列に頼らず、サイズで比べる。 -| 検査 | 失敗する条件 | -| -------------------- | ---------------------------------------------------------------------------------------------------- | -| `BranchIgnoredError` | バンドルに入ったソースが `NODE_ENV` を読むのに、production と development の minify 後のサイズが同じ | -| `AddsNothingError` | ベースの上に積んだバンドルのライブラリ部分が、ベースと同じ長さ。足した import はベースに入っている | +| 検査 | 失敗する条件 | +| ------------------ | ---------------------------------------------------------------------------------------------------- | +| `DevCodeKeptError` | バンドルに入ったソースが `NODE_ENV` を読むのに、production と development の minify 後のサイズが同じ | +| `AddsNothingError` | ベースの上に積んだバンドルのライブラリ部分が、ベースと同じ長さ。足した import はベースに入っている | - **ライブラリ部分は「チャンクの長さ − エントリの `renderedLength`」で測る。**エントリは import した名前を `console.log` に渡すので、全体を比べると名前の分だけ必ず違う。各モジュールの `renderedLength` は @@ -6586,7 +6586,7 @@ import しない core で「equals が残っている」と出た。development | 壊し方 | 結果 | | -------------------------------------------------------------- | ------------------------------------------ | | `src/val.ts` のトップレベルで `equals` を参照し、core に漏らす | `AddsNothingError` | -| `define` を効かなくし、両方のモードを同じコードにする | `BranchIgnoredError` | +| `define` を効かなくし、両方のモードを同じコードにする | `DevCodeKeptError` | | `development` を `true` に固定する | 検査は通る。ソースが `NODE_ENV` を読まない | **拾えないもの: development 用の分岐の一部だけが production に残る場合。**`development` で囲み忘れた @@ -6597,6 +6597,14 @@ import しない core で「equals が残っている」と出た。development コードが約 +790 B、`equals` が +187 B。ただし `equals` は余りより 59 B 大きいだけで、core が増えないまま core の予算を 60 B 上げれば通る。サイズの比較は余りに左右されない。 +**名前は検査の範囲に合わせる、2026-10-16。**`BranchIgnoredError` を `DevCodeKeptError` に改めた。前の名前は、 +何の分岐を何が無視したのかを言っていなかった。 + +- **却下: `TreeShakingError`。**tree-shaking は使われない export やモジュールを落とすこと。`if` の分岐を消すのは + dead code elimination で、別の工程 +- **却下: `DeadCodeEliminationError`。**この検査が見るのは development 用の分岐だけで、dead code 全般ではない。 + 名前が検査の保証より広い + --- ## 17. Effect v4 調査、2026-09-10 From b507e07150ff050a4f304a7ece533a32f4ceec65 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:01:38 +0900 Subject: [PATCH 44/52] style: break the bundle-size functions into paragraphs --- scripts/bundle-size/bundle.ts | 1 + scripts/bundle-size/cli.ts | 5 ++++- scripts/bundle-size/format.ts | 3 +++ scripts/bundle-size/measure/toolchain.ts | 9 +++++++++ 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/scripts/bundle-size/bundle.ts b/scripts/bundle-size/bundle.ts index b49ae6c..1645fc3 100644 --- a/scripts/bundle-size/bundle.ts +++ b/scripts/bundle-size/bundle.ts @@ -10,6 +10,7 @@ export const Imports = Val.sealer().impl({ merged[module] = [...new Set([...(merged[module] ?? []), ...names])]; } } + return Imports(merged); }, }); diff --git a/scripts/bundle-size/cli.ts b/scripts/bundle-size/cli.ts index 979a21b..dc93602 100644 --- a/scripts/bundle-size/cli.ts +++ b/scripts/bundle-size/cli.ts @@ -12,8 +12,11 @@ export const Cli = { R.match({ ok: (report) => { console.log(render(report, process.argv.includes("--json"))); + const overs = report.bundles.filter(BundleSize.over).map(over); - for (const line of overs.length > 0 ? ["", ...overs] : []) console.error(line); + for (const line of overs.length > 0 ? ["", ...overs] : []) { + console.error(line); + } return overs.length > 0 ? 1 : 0; }, err: (error) => { diff --git a/scripts/bundle-size/format.ts b/scripts/bundle-size/format.ts index 5b425e2..5d477fd 100644 --- a/scripts/bundle-size/format.ts +++ b/scripts/bundle-size/format.ts @@ -47,6 +47,7 @@ function sizes(build: BuildResult): string { ], ["left", "right", "right", "right"], ); + return [ `bundle import { ${Imports.names(build.imports).join(", ")} }`, ...rows.map((cells) => ` ${cells.join(" ")}`), @@ -63,6 +64,7 @@ function budgets(bundles: readonly BundleSize[]): string { : `${format(max - bytes)} left (${Math.round((1 - bytes / max) * 100)}%)`; return [label(size), amount(size, bytes), amount(size, max), left]; }); + return [ "budget (production gzip)", ...align(rows, ["left", "right", "right", "left"]).map(([name, used, limit, left]) => @@ -78,5 +80,6 @@ function json(bundles: readonly BundleSize[]): string { Object.fromEntries(modes.map((mode) => [mode, build[mode].sizes])), ]), ); + return JSON.stringify(keyed, null, 2); } diff --git a/scripts/bundle-size/measure/toolchain.ts b/scripts/bundle-size/measure/toolchain.ts index f80469f..dc13f46 100644 --- a/scripts/bundle-size/measure/toolchain.ts +++ b/scripts/bundle-size/measure/toolchain.ts @@ -71,12 +71,14 @@ export function measure( R.allAsync, GenAwait, ); + const readsNodeEnv = yield* flow( development.modules.map((module) => Module.readsNodeEnv(module, "development")), R.allAsync, R.map((reads) => reads.some((read) => read)), GenAwait, ); + return R.ok( BuildResult({ imports, @@ -138,11 +140,13 @@ function compile(mode: Mode, imports: Imports): Promise new CompileError({ mode, step: "creating the entry directory", cause }), }), ); + const file = join(dir, "entry.mjs"); const lines = Object.entries(imports).map( ([module, names]) => `import { ${names.join(", ")} } from ${JSON.stringify(fileURLToPath(new URL(module, root)))};`, ); + const built = await R.gen(async function* () { yield* R.await( R.tryPromise({ @@ -154,6 +158,7 @@ function compile(mode: Mode, imports: Imports): Promise new CompileError({ mode, step: "writing the entry", cause }), }), ); + const result = yield* R.await( R.tryPromise({ try: () => @@ -172,10 +177,12 @@ function compile(mode: Mode, imports: Imports): Promise new CompileError({ mode, step: "the build", cause }), }), ); + const chunk = result[0]?.output.find((output) => output.type === "chunk"); if (chunk?.type !== "chunk") { return R.err(new NoChunkError({ mode })); } + const entry = chunk.facadeModuleId === null ? undefined : chunk.modules[chunk.facadeModuleId]; return R.ok( Compiled({ @@ -189,6 +196,7 @@ function compile(mode: Mode, imports: Imports): Promise new CompileError({ mode, step: "removing the entry directory", cause }), }), ); + return built; }); } From 3bb2ebfdddf3513ecadc7ac848e514047238da73 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:04:44 +0900 Subject: [PATCH 45/52] refactor: stop at a missing chunk with yield* instead of an early return --- scripts/bundle-size/cli.ts | 1 + scripts/bundle-size/measure/toolchain.ts | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/bundle-size/cli.ts b/scripts/bundle-size/cli.ts index dc93602..b4db51c 100644 --- a/scripts/bundle-size/cli.ts +++ b/scripts/bundle-size/cli.ts @@ -17,6 +17,7 @@ export const Cli = { for (const line of overs.length > 0 ? ["", ...overs] : []) { console.error(line); } + return overs.length > 0 ? 1 : 0; }, err: (error) => { diff --git a/scripts/bundle-size/measure/toolchain.ts b/scripts/bundle-size/measure/toolchain.ts index dc13f46..8492ae5 100644 --- a/scripts/bundle-size/measure/toolchain.ts +++ b/scripts/bundle-size/measure/toolchain.ts @@ -178,10 +178,10 @@ function compile(mode: Mode, imports: Imports): Promise output.type === "chunk"); - if (chunk?.type !== "chunk") { - return R.err(new NoChunkError({ mode })); - } + const found = result[0]?.output.find((output) => output.type === "chunk"); + const chunk = yield* found?.type === "chunk" + ? R.ok(found) + : R.err(new NoChunkError({ mode })); const entry = chunk.facadeModuleId === null ? undefined : chunk.modules[chunk.facadeModuleId]; return R.ok( From 399a8873bdbf0f8a6f032f00b36bac5dc22c6aab Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:14:06 +0900 Subject: [PATCH 46/52] refactor: fold single-use bundle-size variables into pipe and flow --- scripts/bundle-size/format.ts | 51 ++++++++++++------------ scripts/bundle-size/measure/toolchain.ts | 30 +++++++------- 2 files changed, 41 insertions(+), 40 deletions(-) diff --git a/scripts/bundle-size/format.ts b/scripts/bundle-size/format.ts index 5d477fd..e8118be 100644 --- a/scripts/bundle-size/format.ts +++ b/scripts/bundle-size/format.ts @@ -1,6 +1,7 @@ import { Imports } from "./bundle.ts"; import { modes, type BuildResult, type BundleSize } from "./measure/index.ts"; import type { Report } from "./suite.ts"; +import { pipe } from "./util/pipe/index.ts"; export function over(size: BundleSize): string { return `over budget: ${label(size)} is ${amount(size, size.capped)}, budget ${amount(size, size.bundle.budget)}`; @@ -40,46 +41,44 @@ function label({ bundle }: BundleSize): string { function sizes(build: BuildResult): string { const heads = ["minified", "gzip", "brotli"] as const; - const rows = align( + return pipe( [ ["", ...heads], ...modes.map((mode) => [mode, ...heads.map((head) => format(build[mode].sizes[head]))]), ], - ["left", "right", "right", "right"], + (rows) => align(rows, ["left", "right", "right", "right"]), + (rows) => rows.map((cells) => ` ${cells.join(" ")}`), + (lines) => [`bundle import { ${Imports.names(build.imports).join(", ")} }`, ...lines], + (lines) => lines.join("\n"), ); - - return [ - `bundle import { ${Imports.names(build.imports).join(", ")} }`, - ...rows.map((cells) => ` ${cells.join(" ")}`), - ].join("\n"); } function budgets(bundles: readonly BundleSize[]): string { - const rows = bundles.map((size) => { - const bytes = size.capped; - const max = size.bundle.budget; - const left = - bytes > max - ? `${format(bytes - max)} over` - : `${format(max - bytes)} left (${Math.round((1 - bytes / max) * 100)}%)`; - return [label(size), amount(size, bytes), amount(size, max), left]; - }); - - return [ - "budget (production gzip)", - ...align(rows, ["left", "right", "right", "left"]).map(([name, used, limit, left]) => - ` ${name} ${used} / ${limit} ${left}`.trimEnd(), - ), - ].join("\n"); + return pipe( + bundles.map((size) => { + const bytes = size.capped; + const max = size.bundle.budget; + const left = + bytes > max + ? `${format(bytes - max)} over` + : `${format(max - bytes)} left (${Math.round((1 - bytes / max) * 100)}%)`; + return [label(size), amount(size, bytes), amount(size, max), left]; + }), + (rows) => align(rows, ["left", "right", "right", "left"]), + (rows) => + rows.map(([name, used, limit, left]) => ` ${name} ${used} / ${limit} ${left}`.trimEnd()), + (lines) => ["budget (production gzip)", ...lines], + (lines) => lines.join("\n"), + ); } function json(bundles: readonly BundleSize[]): string { - const keyed = Object.fromEntries( + return pipe( bundles.map(({ bundle, build }) => [ bundle.key, Object.fromEntries(modes.map((mode) => [mode, build[mode].sizes])), ]), + (entries) => Object.fromEntries(entries), + (keyed) => JSON.stringify(keyed, null, 2), ); - - return JSON.stringify(keyed, null, 2); } diff --git a/scripts/bundle-size/measure/toolchain.ts b/scripts/bundle-size/measure/toolchain.ts index 8492ae5..4a7acbe 100644 --- a/scripts/bundle-size/measure/toolchain.ts +++ b/scripts/bundle-size/measure/toolchain.ts @@ -9,7 +9,7 @@ import { Result as R, TaggedError } from "better-result"; import { minifySync, build, type Rolldown } from "vite"; import { Val } from "../../../src/index.ts"; import { Imports } from "../bundle.ts"; -import { flow } from "../util/pipe/index.ts"; +import { flow, pipe } from "../util/pipe/index.ts"; import { GenAwait } from "../util/util.ts"; import { Build, BuildResult, Sizes, type Mode } from "./index.ts"; @@ -56,8 +56,7 @@ export function pack(): Promise> { }, catch: (cause) => { const { stdout = "", stderr = "" } = cause as { stdout?: string; stderr?: string }; - const output = `${stdout}${stderr}` || String(cause); - return new PackError({ output }); + return new PackError({ output: `${stdout}${stderr}` || String(cause) }); }, }); } @@ -142,10 +141,6 @@ function compile(mode: Mode, imports: Imports): Promise - `import { ${names.join(", ")} } from ${JSON.stringify(fileURLToPath(new URL(module, root)))};`, - ); const built = await R.gen(async function* () { yield* R.await( @@ -153,13 +148,20 @@ function compile(mode: Mode, imports: Imports): Promise writeFile( file, - `${lines.join("\n")}\nconsole.log(${Imports.names(imports).join(", ")});\n`, + pipe( + Object.entries(imports).map( + ([module, names]) => + `import { ${names.join(", ")} } from ${JSON.stringify(fileURLToPath(new URL(module, root)))};`, + ), + (lines) => [...lines, `console.log(${Imports.names(imports).join(", ")});`, ""], + (lines) => lines.join("\n"), + ), ), catch: (cause) => new CompileError({ mode, step: "writing the entry", cause }), }), ); - const result = yield* R.await( + const chunk = yield* flow( R.tryPromise({ try: () => build({ @@ -176,13 +178,13 @@ function compile(mode: Mode, imports: Imports): Promise, catch: (cause) => new CompileError({ mode, step: "the build", cause }), }), + R.map((result) => result[0]?.output.find((output) => output.type === "chunk")), + R.andThen((found) => + found?.type === "chunk" ? R.ok(found) : R.err(new NoChunkError({ mode })), + ), + GenAwait, ); - const found = result[0]?.output.find((output) => output.type === "chunk"); - const chunk = yield* found?.type === "chunk" - ? R.ok(found) - : R.err(new NoChunkError({ mode })); - const entry = chunk.facadeModuleId === null ? undefined : chunk.modules[chunk.facadeModuleId]; return R.ok( Compiled({ From 17a85effe1de75f6866d91b62d145e03e3618a9d Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:18:29 +0900 Subject: [PATCH 47/52] refactor: name the entry's content before writing it --- scripts/bundle-size/measure/toolchain.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/scripts/bundle-size/measure/toolchain.ts b/scripts/bundle-size/measure/toolchain.ts index 4a7acbe..b0b4172 100644 --- a/scripts/bundle-size/measure/toolchain.ts +++ b/scripts/bundle-size/measure/toolchain.ts @@ -145,18 +145,17 @@ function compile(mode: Mode, imports: Imports): Promise - writeFile( - file, - pipe( - Object.entries(imports).map( - ([module, names]) => - `import { ${names.join(", ")} } from ${JSON.stringify(fileURLToPath(new URL(module, root)))};`, - ), - (lines) => [...lines, `console.log(${Imports.names(imports).join(", ")});`, ""], - (lines) => lines.join("\n"), + try: () => { + const content = pipe( + Object.entries(imports).map( + ([module, names]) => + `import { ${names.join(", ")} } from ${JSON.stringify(fileURLToPath(new URL(module, root)))};`, ), - ), + (lines) => [...lines, `console.log(${Imports.names(imports).join(", ")});`, ""], + (lines) => lines.join("\n"), + ); + return writeFile(file, content); + }, catch: (cause) => new CompileError({ mode, step: "writing the entry", cause }), }), ); @@ -186,6 +185,7 @@ function compile(mode: Mode, imports: Imports): Promise Date: Fri, 16 Oct 2026 03:20:21 +0900 Subject: [PATCH 48/52] test: rename the sink fixture away from the reserved Box --- .../bundle-size/util/pipe/generate-tests.ts | 12 ++-- .../bundle-size/util/pipe/generated.test.ts | 64 +++++++++++-------- 2 files changed, 45 insertions(+), 31 deletions(-) diff --git a/scripts/bundle-size/util/pipe/generate-tests.ts b/scripts/bundle-size/util/pipe/generate-tests.ts index 009562e..7a887a4 100644 --- a/scripts/bundle-size/util/pipe/generate-tests.ts +++ b/scripts/bundle-size/util/pipe/generate-tests.ts @@ -44,11 +44,11 @@ import { flow, pipe, type Sink } from "./index.ts"; const wrap = (x: T): T[] => [x]; const wrapAsync = async (x: T): Promise => [x]; -type Box

= { promise: P }; -interface Boxed extends Sink { - readonly out: Box; +type Held

= { promise: P }; +interface Holder extends Sink { + readonly out: Held; } -const boxed = { sink: (promise: unknown) => ({ promise }) } as unknown as Boxed; +const holder = { sink: (promise: unknown) => ({ promise }) } as unknown as Holder; describe("pipe", () => { test("chains each step to the one before, at every length", () => { @@ -74,10 +74,10 @@ ${tooMany("void flow")} ${lengths .map( (n) => - ` expectTypeOf(flow(0, ${chain(n)}, boxed)).toEqualTypeOf<{ promise: Promise<${nested(n)}> }>();`, + ` expectTypeOf(flow(0, ${chain(n)}, holder)).toEqualTypeOf<{ promise: Promise<${nested(n)}> }>();`, ) .join("\n")} -${tooMany("flow", "boxed")} +${tooMany("flow", "holder")} }); }); `; diff --git a/scripts/bundle-size/util/pipe/generated.test.ts b/scripts/bundle-size/util/pipe/generated.test.ts index 22aeb63..a6da9c3 100644 --- a/scripts/bundle-size/util/pipe/generated.test.ts +++ b/scripts/bundle-size/util/pipe/generated.test.ts @@ -6,11 +6,11 @@ import { flow, pipe, type Sink } from "./index.ts"; const wrap = (x: T): T[] => [x]; const wrapAsync = async (x: T): Promise => [x]; -type Box

= { promise: P }; -interface Boxed extends Sink { - readonly out: Box; +type Held

= { promise: P }; +interface Holder extends Sink { + readonly out: Held; } -const boxed = { sink: (promise: unknown) => ({ promise }) } as unknown as Boxed; +const holder = { sink: (promise: unknown) => ({ promise }) } as unknown as Holder; describe("pipe", () => { test("chains each step to the one before, at every length", () => { @@ -466,28 +466,42 @@ describe("flow", () => { }); test("hands the chain's promise to a sink, at every length", () => { - expectTypeOf(flow(0, wrapAsync, boxed)).toEqualTypeOf<{ promise: Promise }>(); - expectTypeOf(flow(0, wrap, wrapAsync, boxed)).toEqualTypeOf<{ promise: Promise }>(); - expectTypeOf(flow(0, wrapAsync, wrap, wrapAsync, boxed)).toEqualTypeOf<{ + expectTypeOf(flow(0, wrapAsync, holder)).toEqualTypeOf<{ promise: Promise }>(); + expectTypeOf(flow(0, wrap, wrapAsync, holder)).toEqualTypeOf<{ + promise: Promise; + }>(); + expectTypeOf(flow(0, wrapAsync, wrap, wrapAsync, holder)).toEqualTypeOf<{ promise: Promise; }>(); - expectTypeOf(flow(0, wrap, wrapAsync, wrap, wrapAsync, boxed)).toEqualTypeOf<{ + expectTypeOf(flow(0, wrap, wrapAsync, wrap, wrapAsync, holder)).toEqualTypeOf<{ promise: Promise; }>(); - expectTypeOf(flow(0, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, boxed)).toEqualTypeOf<{ + expectTypeOf(flow(0, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, holder)).toEqualTypeOf<{ promise: Promise; }>(); - expectTypeOf(flow(0, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, boxed)).toEqualTypeOf<{ + expectTypeOf(flow(0, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, holder)).toEqualTypeOf<{ promise: Promise; }>(); expectTypeOf( - flow(0, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, boxed), + flow(0, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, holder), ).toEqualTypeOf<{ promise: Promise }>(); expectTypeOf( - flow(0, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, boxed), + flow(0, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, holder), ).toEqualTypeOf<{ promise: Promise }>(); expectTypeOf( - flow(0, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, wrap, wrapAsync, boxed), + flow( + 0, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + wrap, + wrapAsync, + holder, + ), ).toEqualTypeOf<{ promise: Promise }>(); expectTypeOf( flow( @@ -502,7 +516,7 @@ describe("flow", () => { wrapAsync, wrap, wrapAsync, - boxed, + holder, ), ).toEqualTypeOf<{ promise: Promise }>(); expectTypeOf( @@ -519,7 +533,7 @@ describe("flow", () => { wrapAsync, wrap, wrapAsync, - boxed, + holder, ), ).toEqualTypeOf<{ promise: Promise }>(); expectTypeOf( @@ -537,7 +551,7 @@ describe("flow", () => { wrapAsync, wrap, wrapAsync, - boxed, + holder, ), ).toEqualTypeOf<{ promise: Promise }>(); expectTypeOf( @@ -556,7 +570,7 @@ describe("flow", () => { wrapAsync, wrap, wrapAsync, - boxed, + holder, ), ).toEqualTypeOf<{ promise: Promise }>(); expectTypeOf( @@ -576,7 +590,7 @@ describe("flow", () => { wrapAsync, wrap, wrapAsync, - boxed, + holder, ), ).toEqualTypeOf<{ promise: Promise }>(); expectTypeOf( @@ -597,7 +611,7 @@ describe("flow", () => { wrapAsync, wrap, wrapAsync, - boxed, + holder, ), ).toEqualTypeOf<{ promise: Promise }>(); expectTypeOf( @@ -619,7 +633,7 @@ describe("flow", () => { wrapAsync, wrap, wrapAsync, - boxed, + holder, ), ).toEqualTypeOf<{ promise: Promise }>(); expectTypeOf( @@ -642,7 +656,7 @@ describe("flow", () => { wrapAsync, wrap, wrapAsync, - boxed, + holder, ), ).toEqualTypeOf<{ promise: Promise }>(); expectTypeOf( @@ -666,7 +680,7 @@ describe("flow", () => { wrapAsync, wrap, wrapAsync, - boxed, + holder, ), ).toEqualTypeOf<{ promise: Promise }>(); expectTypeOf( @@ -691,7 +705,7 @@ describe("flow", () => { wrapAsync, wrap, wrapAsync, - boxed, + holder, ), ).toEqualTypeOf<{ promise: Promise }>(); expectTypeOf( @@ -717,7 +731,7 @@ describe("flow", () => { wrapAsync, wrap, wrapAsync, - boxed, + holder, ), ).toEqualTypeOf<{ promise: Promise }>(); flow( @@ -744,7 +758,7 @@ describe("flow", () => { wrap, wrap, // @ts-expect-error no overload takes this many steps - boxed, + holder, ); }); }); From 6ab80ec7ba92efa821933d7791c75a05553f6a3f Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:29:45 +0900 Subject: [PATCH 49/52] refactor: wrap the compile steps in attempt and measure sizes on Compiled --- scripts/bundle-size/measure/toolchain.ts | 71 +++++++++++------------- 1 file changed, 33 insertions(+), 38 deletions(-) diff --git a/scripts/bundle-size/measure/toolchain.ts b/scripts/bundle-size/measure/toolchain.ts index b0b4172..2c18015 100644 --- a/scripts/bundle-size/measure/toolchain.ts +++ b/scripts/bundle-size/measure/toolchain.ts @@ -82,14 +82,8 @@ export function measure( BuildResult({ imports, readsNodeEnv, - production: Build({ - sizes: yield* Code.sizes(production.code), - library: production.library, - }), - development: Build({ - sizes: yield* Code.sizes(development.code), - library: development.library, - }), + production: yield* Compiled.measure(production), + development: yield* Compiled.measure(development), }), ); }); @@ -122,47 +116,45 @@ type Module = Val<"Module", string>; const Module = Val.sealer().impl({ /** Whether the module's source reads `NODE_ENV`, before `define` replaces it. */ readsNodeEnv: (module, mode: Mode): Promise> => - R.tryPromise({ - try: async () => (await readFile(module, "utf8")).includes("NODE_ENV"), - catch: (cause) => new CompileError({ mode, step: "reading the library", cause }), - }), + attempt(mode, "reading the library", async () => + (await readFile(module, "utf8")).includes("NODE_ENV"), + ), }); type Compiled = Val<"Compiled", { code: Code; modules: Module[]; library: number }>; -const Compiled = Val.sealer(); +const Compiled = Val.sealer().impl({ + measure: (compiled): R => + Code.sizes(compiled.code).map((sizes) => Build({ sizes, library: compiled.library })), +}); function compile(mode: Mode, imports: Imports): Promise> { return R.gen(async function* () { const dir = yield* R.await( - R.tryPromise({ - try: () => mkdtemp(join(tmpdir(), "valof-size-")), - catch: (cause) => new CompileError({ mode, step: "creating the entry directory", cause }), - }), + attempt(mode, "creating the entry directory", () => mkdtemp(join(tmpdir(), "valof-size-"))), ); const file = join(dir, "entry.mjs"); const built = await R.gen(async function* () { yield* R.await( - R.tryPromise({ - try: () => { - const content = pipe( - Object.entries(imports).map( - ([module, names]) => - `import { ${names.join(", ")} } from ${JSON.stringify(fileURLToPath(new URL(module, root)))};`, - ), - (lines) => [...lines, `console.log(${Imports.names(imports).join(", ")});`, ""], - (lines) => lines.join("\n"), - ); - return writeFile(file, content); - }, - catch: (cause) => new CompileError({ mode, step: "writing the entry", cause }), + attempt(mode, "writing the entry", () => { + const content = pipe( + Object.entries(imports).map( + ([module, names]) => + `import { ${names.join(", ")} } from ${JSON.stringify(fileURLToPath(new URL(module, root)))};`, + ), + (lines) => [...lines, `console.log(${Imports.names(imports).join(", ")});`, ""], + (lines) => lines.join("\n"), + ); + return writeFile(file, content); }), ); const chunk = yield* flow( - R.tryPromise({ - try: () => + attempt( + mode, + "the build", + () => build({ root: fileURLToPath(root), logLevel: "silent", @@ -175,8 +167,7 @@ function compile(mode: Mode, imports: Imports): Promise, - catch: (cause) => new CompileError({ mode, step: "the build", cause }), - }), + ), R.map((result) => result[0]?.output.find((output) => output.type === "chunk")), R.andThen((found) => found?.type === "chunk" ? R.ok(found) : R.err(new NoChunkError({ mode })), @@ -201,12 +192,16 @@ function compile(mode: Mode, imports: Imports): Promise rm(dir, { recursive: true, force: true }), - catch: (cause) => new CompileError({ mode, step: "removing the entry directory", cause }), - }), + attempt(mode, "removing the entry directory", () => + rm(dir, { recursive: true, force: true }), + ), ); return built; }); } + +/** Runs one step of building a bundle, turning a rejection into a {@link CompileError}. */ +function attempt(mode: Mode, step: string, run: () => Promise): Promise> { + return R.tryPromise({ try: run, catch: (cause) => new CompileError({ mode, step, cause }) }); +} From d0a3e2c086dac1b84661a15fc29cd615ea914d61 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:43:07 +0900 Subject: [PATCH 50/52] refactor: move the files several scripts share into scripts/shared --- scripts/bundle-size/cli.ts | 2 +- scripts/bundle-size/format.ts | 2 +- scripts/bundle-size/measure/toolchain.ts | 4 ++-- scripts/bundle-size/suite.ts | 4 ++-- scripts/{bundle-size/util => shared}/pipe/generate-tests.ts | 4 ++-- scripts/{bundle-size/util => shared}/pipe/generated.test.ts | 2 +- scripts/{bundle-size/util => shared}/pipe/index.test.ts | 0 scripts/{bundle-size/util => shared}/pipe/index.ts | 0 scripts/{ => shared}/typescript-lines.ts | 2 +- scripts/{bundle-size/util => shared}/util.ts | 0 scripts/ts-compatibility/index.ts | 2 +- scripts/type-perf/index.ts | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) rename scripts/{bundle-size/util => shared}/pipe/generate-tests.ts (94%) rename scripts/{bundle-size/util => shared}/pipe/generated.test.ts (99%) rename scripts/{bundle-size/util => shared}/pipe/index.test.ts (100%) rename scripts/{bundle-size/util => shared}/pipe/index.ts (100%) rename scripts/{ => shared}/typescript-lines.ts (95%) rename scripts/{bundle-size/util => shared}/util.ts (100%) diff --git a/scripts/bundle-size/cli.ts b/scripts/bundle-size/cli.ts index b4db51c..e8d734c 100644 --- a/scripts/bundle-size/cli.ts +++ b/scripts/bundle-size/cli.ts @@ -2,7 +2,7 @@ import { Result as R } from "better-result"; import { over, render } from "./format.ts"; import { BundleSize } from "./measure/index.ts"; import { Suite } from "./suite.ts"; -import { flow } from "./util/pipe/index.ts"; +import { flow } from "../shared/pipe/index.ts"; export const Cli = { run: async (suite: Suite): Promise => { diff --git a/scripts/bundle-size/format.ts b/scripts/bundle-size/format.ts index e8118be..b0a5ee8 100644 --- a/scripts/bundle-size/format.ts +++ b/scripts/bundle-size/format.ts @@ -1,7 +1,7 @@ import { Imports } from "./bundle.ts"; import { modes, type BuildResult, type BundleSize } from "./measure/index.ts"; import type { Report } from "./suite.ts"; -import { pipe } from "./util/pipe/index.ts"; +import { pipe } from "../shared/pipe/index.ts"; export function over(size: BundleSize): string { return `over budget: ${label(size)} is ${amount(size, size.capped)}, budget ${amount(size, size.bundle.budget)}`; diff --git a/scripts/bundle-size/measure/toolchain.ts b/scripts/bundle-size/measure/toolchain.ts index 2c18015..063fd5f 100644 --- a/scripts/bundle-size/measure/toolchain.ts +++ b/scripts/bundle-size/measure/toolchain.ts @@ -9,8 +9,8 @@ import { Result as R, TaggedError } from "better-result"; import { minifySync, build, type Rolldown } from "vite"; import { Val } from "../../../src/index.ts"; import { Imports } from "../bundle.ts"; -import { flow, pipe } from "../util/pipe/index.ts"; -import { GenAwait } from "../util/util.ts"; +import { flow, pipe } from "../../shared/pipe/index.ts"; +import { GenAwait } from "../../shared/util.ts"; import { Build, BuildResult, Sizes, type Mode } from "./index.ts"; export class PackError extends TaggedError("PackError")<{ output: string; message: string }> { diff --git a/scripts/bundle-size/suite.ts b/scripts/bundle-size/suite.ts index ec4dc2d..398c535 100644 --- a/scripts/bundle-size/suite.ts +++ b/scripts/bundle-size/suite.ts @@ -1,8 +1,8 @@ import { Result as R, TaggedError } from "better-result"; import { Val } from "../../src/index.ts"; import { Imports, type Bundle } from "./bundle.ts"; -import { flow } from "./util/pipe/index.ts"; -import { GenAwait } from "./util/util.ts"; +import { flow } from "../shared/pipe/index.ts"; +import { GenAwait } from "../shared/util.ts"; import { BuildResult, BundleSize, diff --git a/scripts/bundle-size/util/pipe/generate-tests.ts b/scripts/shared/pipe/generate-tests.ts similarity index 94% rename from scripts/bundle-size/util/pipe/generate-tests.ts rename to scripts/shared/pipe/generate-tests.ts index 7a887a4..73e2088 100644 --- a/scripts/bundle-size/util/pipe/generate-tests.ts +++ b/scripts/shared/pipe/generate-tests.ts @@ -5,9 +5,9 @@ import { fileURLToPath } from "node:url"; /** The most steps `pipe` and `flow` take, as their overloads in `index.ts` declare. */ const steps = 20; -const root = new URL("../../../../", import.meta.url); +const root = new URL("../../../", import.meta.url); const header = - "// Generated by generate-tests.ts. Run `node scripts/bundle-size/util/pipe/generate-tests.ts` to update."; + "// Generated by generate-tests.ts. Run `node scripts/shared/pipe/generate-tests.ts` to update."; function nested(depth: number): string { return `number${"[]".repeat(depth)}`; diff --git a/scripts/bundle-size/util/pipe/generated.test.ts b/scripts/shared/pipe/generated.test.ts similarity index 99% rename from scripts/bundle-size/util/pipe/generated.test.ts rename to scripts/shared/pipe/generated.test.ts index a6da9c3..7789aee 100644 --- a/scripts/bundle-size/util/pipe/generated.test.ts +++ b/scripts/shared/pipe/generated.test.ts @@ -1,4 +1,4 @@ -// Generated by generate-tests.ts. Run `node scripts/bundle-size/util/pipe/generate-tests.ts` to update. +// Generated by generate-tests.ts. Run `node scripts/shared/pipe/generate-tests.ts` to update. import { describe, expectTypeOf, test } from "vite-plus/test"; import { flow, pipe, type Sink } from "./index.ts"; diff --git a/scripts/bundle-size/util/pipe/index.test.ts b/scripts/shared/pipe/index.test.ts similarity index 100% rename from scripts/bundle-size/util/pipe/index.test.ts rename to scripts/shared/pipe/index.test.ts diff --git a/scripts/bundle-size/util/pipe/index.ts b/scripts/shared/pipe/index.ts similarity index 100% rename from scripts/bundle-size/util/pipe/index.ts rename to scripts/shared/pipe/index.ts diff --git a/scripts/typescript-lines.ts b/scripts/shared/typescript-lines.ts similarity index 95% rename from scripts/typescript-lines.ts rename to scripts/shared/typescript-lines.ts index e737b62..6bb57fe 100644 --- a/scripts/typescript-lines.ts +++ b/scripts/shared/typescript-lines.ts @@ -8,7 +8,7 @@ import { promisify } from "node:util"; export const run = promisify(execFile); -export const root = fileURLToPath(new URL("../", import.meta.url)); +export const root = fileURLToPath(new URL("../../", import.meta.url)); /** * The floor the README claims, the line above it, and the current one, each installed under an diff --git a/scripts/bundle-size/util/util.ts b/scripts/shared/util.ts similarity index 100% rename from scripts/bundle-size/util/util.ts rename to scripts/shared/util.ts diff --git a/scripts/ts-compatibility/index.ts b/scripts/ts-compatibility/index.ts index e7215e0..0fd346b 100644 --- a/scripts/ts-compatibility/index.ts +++ b/scripts/ts-compatibility/index.ts @@ -2,7 +2,7 @@ import { mkdir, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { docBlocks } from "../../docs/tools/twoslash/index.ts"; -import { aliases, pack, root, tsc, version } from "../typescript-lines.ts"; +import { aliases, pack, root, tsc, version } from "../shared/typescript-lines.ts"; const directory = join(root, "docs/.generated/ts-compatibility"); diff --git a/scripts/type-perf/index.ts b/scripts/type-perf/index.ts index 234652f..f3da161 100644 --- a/scripts/type-perf/index.ts +++ b/scripts/type-perf/index.ts @@ -5,7 +5,7 @@ // the times are printed and never checked. import { readFile, rm, writeFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; -import { floor, pack, tsc, version, type Tsc } from "../typescript-lines.ts"; +import { floor, pack, tsc, version, type Tsc } from "../shared/typescript-lines.ts"; const here = new URL("./", import.meta.url); From cd6a528d0506b1d96b031590c1cab2b0d6b0c6f7 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:47:01 +0900 Subject: [PATCH 51/52] chore: pin the scripts to the released valof through #valof --- package.json | 4 ++++ pnpm-lock.yaml | 16 ++++++++++++++++ scripts/bundle-size/bundle.ts | 2 +- scripts/bundle-size/measure/index.ts | 2 +- scripts/bundle-size/measure/toolchain.ts | 2 +- scripts/bundle-size/suite.ts | 2 +- 6 files changed, 24 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index ade63c6..ce0bbc4 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,9 @@ ], "type": "module", "sideEffects": false, + "imports": { + "#valof": "valof-release" + }, "exports": { ".": "./dist/index.mjs", "./eslint-plugin": "./dist/eslint-plugin.mjs", @@ -70,6 +73,7 @@ "typescript": "^7.0.2", "typescript-5": "npm:typescript@^5.9.3", "typescript-6": "npm:typescript@^6.0.3", + "valof-release": "npm:valof@0.9.0", "vite": "catalog:", "vite-plus": "catalog:" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7be215..1f01f43 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -255,6 +255,9 @@ importers: typescript-6: specifier: npm:typescript@^6.0.3 version: typescript@6.0.3 + valof-release: + specifier: npm:valof@0.9.0 + version: valof@0.9.0(oxc-parser@0.148.0) vite: specifier: npm:@voidzero-dev/vite-plus-core@0.2.9 version: '@voidzero-dev/vite-plus-core@0.2.9(@types/node@26.5.0)(jiti@2.7.0)(typescript@7.0.2)(yaml@2.9.0)' @@ -2091,6 +2094,15 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + valof@0.9.0: + resolution: {integrity: sha512-dK8txjbRViHXFBw9OOIGd/NRFRW/qGOJwo61FOvW6RlgdQXyfpKDECEacj1aQAxQhwzTLgrcDcV4p/FXJQ0TaQ==} + hasBin: true + peerDependencies: + oxc-parser: '>=0.148' + peerDependenciesMeta: + oxc-parser: + optional: true + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -3740,6 +3752,10 @@ snapshots: dependencies: punycode: 2.3.1 + valof@0.9.0(oxc-parser@0.148.0): + optionalDependencies: + oxc-parser: 0.148.0 + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 diff --git a/scripts/bundle-size/bundle.ts b/scripts/bundle-size/bundle.ts index 1645fc3..8ce1795 100644 --- a/scripts/bundle-size/bundle.ts +++ b/scripts/bundle-size/bundle.ts @@ -1,4 +1,4 @@ -import { Val } from "../../src/index.ts"; +import { Val } from "#valof"; export type Imports = Val<"Imports", Record>; export const Imports = Val.sealer().impl({ diff --git a/scripts/bundle-size/measure/index.ts b/scripts/bundle-size/measure/index.ts index 797a8e8..2279e8f 100644 --- a/scripts/bundle-size/measure/index.ts +++ b/scripts/bundle-size/measure/index.ts @@ -1,5 +1,5 @@ import { Result as R, TaggedError } from "better-result"; -import { Val } from "../../../src/index.ts"; +import { Val } from "#valof"; import { Imports, type Bundle } from "../bundle.ts"; export class DevCodeKeptError extends TaggedError("DevCodeKeptError")<{ diff --git a/scripts/bundle-size/measure/toolchain.ts b/scripts/bundle-size/measure/toolchain.ts index 063fd5f..28afcf5 100644 --- a/scripts/bundle-size/measure/toolchain.ts +++ b/scripts/bundle-size/measure/toolchain.ts @@ -7,7 +7,7 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { Result as R, TaggedError } from "better-result"; import { minifySync, build, type Rolldown } from "vite"; -import { Val } from "../../../src/index.ts"; +import { Val } from "#valof"; import { Imports } from "../bundle.ts"; import { flow, pipe } from "../../shared/pipe/index.ts"; import { GenAwait } from "../../shared/util.ts"; diff --git a/scripts/bundle-size/suite.ts b/scripts/bundle-size/suite.ts index 398c535..171bcd0 100644 --- a/scripts/bundle-size/suite.ts +++ b/scripts/bundle-size/suite.ts @@ -1,5 +1,5 @@ import { Result as R, TaggedError } from "better-result"; -import { Val } from "../../src/index.ts"; +import { Val } from "#valof"; import { Imports, type Bundle } from "./bundle.ts"; import { flow } from "../shared/pipe/index.ts"; import { GenAwait } from "../shared/util.ts"; From a2939bc0eb5b57dadcba6100733ccd6cffb0edb9 Mon Sep 17 00:00:00 2001 From: Joichiro Hayashi <43955981+jo16oh@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:54:47 +0900 Subject: [PATCH 52/52] docs: check the scripts against src before a release, and bump valof-release after it --- AGENTS.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f72f056..572aa7f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,17 +46,23 @@ title and body as a draft and stop there. ## Releasing 1. Write the release notes for users, not reviewers, in a file outside the repo. -2. Run `vp run bump-version --body-file notes.md --release --yes`, where `` is the +2. Set `#valof` in `package.json` to `./src/index.ts`, run `vp check` and `vp run bundle-size`, and + set it back to `valof-release`. Report what breaks, and where the new API is awkward to use in + the scripts. A user encounters both when upgrading. If a problem is serious, stop the release and + rework the design. +3. Run `vp run bump-version --body-file notes.md --release --yes`, where `` is the release type I give, such as `patch`. It pushes a release branch and opens its PR with the file as the body. Options other than `--body-file` go to bumpp. -3. If CI reports stale bundle-size claims, run +4. If CI reports stale bundle-size claims, run `vp run replace-bundle-size-claims --write README.md package.json` on that branch, commit, and push. -4. I merge the PR. The release workflow tags the merge commit, publishes to npm, creates the GitHub +5. I merge the PR. The release workflow tags the merge commit, publishes to npm, creates the GitHub release, and publishes the book. The notes start with the PR body, followed by the PRs merged since the last tag. +6. Once npm has the new version, set `valof-release` in `package.json` to it, and fix the scripts + where step 2 found a break. -Steps 2 and 3 push, and step 2 opens a PR. They are the only exception to "Never push, and never +Steps 3 and 4 push, and step 3 opens a PR. They are the only exception to "Never push, and never open a PR" above. Run them only on my explicit go-ahead. ## Writing Style