🌳 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
+34
View File
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import { build } from 'esbuild';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { existsSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
@@ -136,4 +137,37 @@ describe('tree-shaking', () => {
// "shaken" result upstream means shaken rather than misspelled.
expectShaken(code, Object.values(MARKER), []);
});
/**
* The cases above bundle `src/`, which is where the annotations are written — so they cannot
* see what happens to them on the way into `dist/`. That is exactly where this broke: the
* flat bundle behind `cereale/min` was built with esbuild's `minify: true`, whose
* `minifyWhitespace` pass strips comments, annotations included. The published entry point
* kept all 26 unrelated rules (5,066 bytes against 1,837) while every source-level check
* stayed green.
*/
describe('the published artifacts', () => {
const dist = path.resolve('dist');
const built = existsSync(path.join(dist, 'cereale.min.js'));
it.runIf(built)('cereale/min tree-shakes as well as the per-module entry', async () => {
const code = await bundle(`
import { IsString } from ${JSON.stringify(path.join(dist, 'cereale.min.js'))};
export const d = IsString();
`.replace('CEREALE', 'unused'));
expectShaken(code, [MARKER.isString], [MARKER.isLatitude, MARKER.isSemVer, MARKER.serializer]);
expect(code.length).toBeLessThan(3000);
});
it.runIf(built)('keeps its purity annotations through minification', async () => {
const flat = readFileSync(path.join(dist, 'cereale.min.js'), 'utf8');
const perModule = readFileSync(path.join(dist, 'esm/decorators.js'), 'utf8');
const count = (s: string) => (s.match(/__PURE__/g) ?? []).length;
expect(count(perModule), 'src annotations should reach dist/esm').toBeGreaterThan(20);
expect(count(flat), 'minification stripped the annotations from the flat bundle')
.toBeGreaterThanOrEqual(count(perModule));
});
});
});
+5 -5
View File
@@ -42,7 +42,7 @@ export class JsonMappingError extends Error {
* next steps in a pollution chain. This library exists to parse request bodies, so the
* transform layer drops them rather than trusting callers to sanitise first.
*/
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
const FORBIDDEN_KEYS = /*#__PURE__*/ new Set(['__proto__', 'constructor', 'prototype']);
/**
* Stands in for the value of a property that is never serialized, so that a failing password
@@ -96,7 +96,7 @@ interface OutboundProperty {
serializer?: any;
}
const outboundCache = new WeakMap<ClassModel, { version: number; byStrategy: Map<unknown, Map<string, OutboundProperty>> }>();
const outboundCache = /*#__PURE__*/ new WeakMap<ClassModel, { version: number; byStrategy: Map<unknown, Map<string, OutboundProperty>> }>();
/**
* Resolves how one property is written out, memoized per (prototype, naming strategy).
@@ -167,7 +167,7 @@ interface InboundNames {
// Name maps are derived purely from decorator metadata, which is fixed once a class is
// declared, so they are cached per (prototype, naming strategy).
const inboundCache = new WeakMap<ClassModel, { version: number; byStrategy: Map<unknown, InboundNames> }>();
const inboundCache = /*#__PURE__*/ new WeakMap<ClassModel, { version: number; byStrategy: Map<unknown, InboundNames> }>();
/**
* Builds the JSON-name -> property-key lookup used when reading a payload.
@@ -614,7 +614,7 @@ interface CachedPlan {
// Turning a class model into a per-property plan is cheap, but doing it on every call was
// measurably not: profiling showed roughly half of all validation time re-deriving answers
// that cannot change. Plans are memoized per model and invalidated by the model version.
const planCache = new WeakMap<ClassModel, CachedPlan>();
const planCache = /*#__PURE__*/ new WeakMap<ClassModel, CachedPlan>();
/**
* Collapses rules that are genuinely identical.
@@ -663,7 +663,7 @@ function validationPlan(model: ClassModel): PropertyPlan[] {
// Serializers and deserializers are stateless by contract, so one instance per class is
// enough. Constructing a fresh one for every property of every object was pure waste.
const converterCache = new WeakMap<object, any>();
const converterCache = /*#__PURE__*/ new WeakMap<object, any>();
function converterFor(clazz: any): any {
let instance = converterCache.get(clazz);