🌳 fix: make cereale/min tree-shake too, and correct what I overclaimed

An audit of the last commit found two real problems and several claims of
mine that went further than the evidence.

**cereale/min was not tree-shakable.** scripts/build-bundle.mjs used
esbuild's `minify: true`, whose minifyWhitespace pass strips comments —
/*#__PURE__*/ annotations included. The published entry point therefore
reproduced exactly the bug the previous commit fixed: one decorator came
out at 5,066 bytes with all 26 unrelated rule messages, against 1,837 from
the per-module entry. Every source-level check stayed green, because they
all bundled src/ and the annotations are stripped on the way into dist/.

It is now minified for syntax and identifiers but not whitespace: 33.9 KB
raw and 9.6 KB gzipped against 26.0/8.7, so about a kilobyte over the wire
for a file that behaves correctly however it is used. One decorator via
cereale/min is now 1,996 bytes. The build asserts the annotation count
survives, and treeshake.test.ts now bundles the published artifact as well
as the source — the gap that let this through.

**sideEffects was partly inert.** `./dist/cjs/metadata.js` could never
match: the build writes dist/cjs/package.json, which becomes the nearest
descriptor for everything beneath it, so bundlers read sideEffects from
there. That file now carries its own declaration. `./src/metadata.ts` was
missing while src/ is published, which declared the Symbol.metadata install
droppable in the source tree. Five module-scope caches in utils.ts are
annotated for the same reason as the rules.

I checked the audit's third blocker — that the Symbol.metadata install is
dropped by bundlers — and it is not. It survives every case where it is
load-bearing (a decorator import, toPlainSync, modelOf, and a decorated
model bundled with an app). It is dropped only when importing nothing but
flattenErrors, which needs no metadata, so that is correct.

Corrections to my own wording:
- "all three bundlers land within 20 bytes" held only for the one-decorator
  row; larger imports differ by up to a few hundred bytes
- "measured through three bundlers, and pinned by a test" read as though the
  test covered all three; it covers esbuild, on source and on dist
- "every rule is a top-level call" — 30 of the 68 are
- the docs page "loads nothing from the network" — it fetches its own
  vendored compiler, same-origin, on first Run. It has no third-party
  dependencies, which is the claim I should have made
- cereale/min's size, everywhere it appears

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
This commit is contained in:
Claude
2026-08-05 16:41:32 +00:00
parent 2a2f7345ad
commit c48a106a05
8 changed files with 118 additions and 39 deletions
+13 -7
View File
@@ -9,16 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### `cereale/min` — one file, no bundler
The whole library flattened into a single minified ES module: **25.5 KB, 8.6 KB gzipped**, for
The whole library flattened into a single minified ES module: **33.9 KB, 9.6 KB gzipped**, for
import maps, `<script type="module">`, Deno and Workers. It is built from `dist/esm/index.js`,
so the decorator lowering and the ES2025 target are whatever `tsc` produced — esbuild only
flattens and minifies.
It is an addition, not a replacement, and the measurement is why. Bundled through esbuild the
flat and per-module builds produce consumer bundles within **2 bytes** of each other; through
rollup + terser the flat one is 165 bytes smaller; unused decorators tree-shake out of both.
Since the size argument is a wash, the per-module build stays the default `import` for the one
thing it does better — readable stack traces for anyone not loading source maps.
It is an addition, not a replacement. The per-module build stays the default `import`: it keeps
readable stack traces for anyone not loading source maps, and it is what a bundler should be
given.
The flat file is minified for syntax and identifiers but **not** whitespace. Full minification
strips comments — including the `/*#__PURE__*/` annotations below — which silently made
`cereale/min` un-tree-shakable: one decorator came out at 5,066 bytes against 1,837 from the
per-module entry, with all 26 unrelated rule messages back in the output. Keeping the
annotations costs about a kilobyte gzipped and is asserted by the build.
### Tree-shaking
@@ -31,7 +35,9 @@ The cause is that every rule is a top-level call — `export const IsString = ru
proves such a call side-effect-free by reading the factory, which is why rollup was already
producing 1,823 bytes and hid the problem from a single-bundler measurement. esbuild and
webpack will not do that analysis, and keep the call. Thirty declarations now carry
`/*#__PURE__*/`, and all three bundlers land within 20 bytes of each other.
`/*#__PURE__*/`. On the single-decorator import that exposed the problem the three bundlers now
land within 19 bytes of each other; on larger imports they still differ by up to a few hundred,
which is ordinary bundler variation rather than anything left unshaken.
Measured, minified, across esbuild / rollup / webpack: