Files
cereale/src/treeshake.test.ts
T
Claude 5714fab42e 🧹 refactor: cut the duplicated checks and prose from the tree-shaking work
Acting on ponytail-review. The findings were about verification written twice
and comments restating the CHANGELOG, not about the fixes themselves.

- dist/cjs/package.json had two writers: the build script echoed it, then
  build-bundle.mjs rewrote it three lines later with the sideEffects entry.
  One writer now, the one that knows what belongs in it.
- Dropped the banner-version assert. Same process, same `pkg.version` going in
  and coming out — the "stale bundle" it claimed to catch cannot happen.
- Dropped the `includes('toInstanceSync')` text check. ci.yml imports the flat
  bundle and typeof-checks the export, which is the same claim actually tested.
- Dropped the treeshake case asserting annotations survive minification; the
  build already asserts it. Its one non-duplicated assertion was the floor on
  the expected count, and that gap was real: the build compared flat >= esm, so
  if tsc ever stopped emitting annotations both sides would read 0 and the
  assert would pass on nothing. Folded in as an explicit `expected < 30` check,
  verified by stripping the annotations and watching the build fail.
- Removed the `CEREALE` placeholder from treeshake.test.ts. A template language
  for one variable, with two no-op `.replace('CEREALE', 'unused')` calls left
  behind by it. Interpolated directly.
- Removed `.replace(/\.js$/, '.js')`, which was the identity function.
- Trimmed the comment above the `Symbol.metadata` install from 16 lines to 7,
  and the one above `minifySyntax` from 9 to 3, keeping the parts that are not
  written down anywhere else.

Also synced the CHANGELOG's byte table to the README's. The two had already
diverged in the webpack column — which is the duplication the review warned
about, showing up before anyone edited either on purpose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAcqrz3FcadkYr3xG32CjK
2026-08-06 03:47:29 +00:00

202 lines
8.5 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import { build } from 'esbuild';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
/**
* Tree-shakability is a property of the source that nothing else notices when it breaks.
*
* Every rule is declared as a top-level call — `export const IsString = rule(...)` — and rollup
* can prove such a call pure by reading the factory, but esbuild and webpack will not. Without
* the `/*#__PURE__*\/` annotations on those declarations, importing one decorator dragged in the
* message and validator of all 68: 4909 bytes rather than 1837 through esbuild, 4823 rather
* than 1818 through webpack. Nothing failed. The library simply got three times heavier in
* every consumer's bundle, and the only way to notice was to go and measure.
*
* So these assertions are mostly about *content* rather than bytes: a byte ceiling tells you
* something drifted, but naming the thing that should not be there says what.
*/
/** Markers that identify a chunk of the library in minified output. */
const MARKER = {
isString: 'must be a string',
minLength: 'must be longer than or equal to',
isLatitude: 'must be a latitude',
isSemVer: 'must be a valid semantic version',
arraySize: 'must contain at least',
serializer: 'Circular reference',
representable: 'cannot be serialized to JSON',
deserializer: 'Unknown property',
validator: '[redacted]',
naming: 'SCREAMING_SNAKE_CASE',
} as const;
const ENTRY = JSON.stringify(path.resolve('src/index.js'));
async function bundle(source: string): Promise<string> {
const dir = await mkdtemp(path.join(tmpdir(), 'cereale-shake-'));
try {
const entry = path.join(dir, 'entry.ts');
await writeFile(entry, source);
const result = await build({
entryPoints: [entry],
bundle: true,
format: 'esm',
minify: true,
target: 'es2022',
write: false,
tsconfigRaw: { compilerOptions: { experimentalDecorators: false, useDefineForClassFields: true } },
});
return result.outputFiles[0]!.text;
} finally {
await rm(dir, { recursive: true, force: true });
}
}
/**
* Asserts what a bundle kept and what it dropped.
*
* `keeps` is not decoration. A bundle that failed to build, or that resolved the library as an
* external and inlined none of it, contains none of the markers — so an "everything was shaken"
* result and a broken harness look identical without it.
*/
function expectShaken(code: string, keeps: string[], drops: string[]) {
expect(code.length, 'the bundle is empty — the harness is broken, not the tree-shaking').toBeGreaterThan(200);
for (const marker of keeps) {
expect(code, `expected the bundle to contain ${JSON.stringify(marker)}`).toContain(marker);
}
for (const marker of drops) {
expect(code, `${JSON.stringify(marker)} should have been shaken out`).not.toContain(marker);
}
}
describe('tree-shaking', () => {
it('drops the 67 rules you did not import', async () => {
const code = await bundle(`
import { IsString } from ${ENTRY};
export const d = IsString();
`);
expectShaken(code, [MARKER.isString], [
MARKER.isLatitude, MARKER.isSemVer, MARKER.arraySize, MARKER.minLength,
MARKER.serializer, MARKER.deserializer, MARKER.naming,
]);
// Generous ceiling: the measured figure is ~1.8 KB, and this is here to catch a regression
// of the kind above (which trebled it), not to police every byte.
expect(code.length).toBeLessThan(3000);
});
it('keeps the deserializer and drops the serializer when only reading', async () => {
const code = await bundle(`
import { toInstanceSync } from ${ENTRY};
export const f = (C, p) => toInstanceSync(C, p, { validate: false });
`);
// Validation is kept on purpose: `validate` defaults to true, so the entry point
// references it whatever the call site passes.
expectShaken(code, [MARKER.deserializer, MARKER.validator], [MARKER.serializer, MARKER.representable]);
});
it('keeps the serializer and drops the deserializer when only writing', async () => {
const code = await bundle(`
import { toPlainSync } from ${ENTRY};
export const f = (o) => toPlainSync(o, { validate: false });
`);
expectShaken(code, [MARKER.serializer, MARKER.representable, MARKER.validator], [MARKER.deserializer]);
});
it('drops both engines when only validating', async () => {
const code = await bundle(`
import { validateSync } from ${ENTRY};
export const f = (o) => validateSync(o);
`);
expectShaken(code, [MARKER.validator], [MARKER.serializer, MARKER.deserializer, MARKER.isString]);
});
/**
* The `Symbol.metadata` install at the top of metadata.ts is a bare statement, not an export,
* so it survives only because `sideEffects` names that module — and naming it is one hop short
* of enough. The barrel that re-exports it is side-effect-free too, so a bundler prunes the
* `export * from './metadata.js'` edge before metadata.js's own marking is ever consulted.
*
* Nothing notices, in the usual way. `import { configure } from 'cereale'` came out at 145
* bytes through esbuild, 143 through webpack and 144 through rollup with `Symbol.metadata`
* absent from all three — and a tsc-compiled consumer on a runtime without the well-known
* symbol is then decorated with `metadata: undefined`, which is to say with no rules at all.
*/
it('installs Symbol.metadata even when nothing model-shaped is imported', async () => {
const code = await bundle(`
import { configure } from ${ENTRY};
export const f = (o) => configure(o);
`);
expect(code, 'the Symbol.metadata install was pruned along with the barrel').toContain('Symbol.metadata');
expectShaken(code, [], [MARKER.isString, MARKER.serializer, MARKER.deserializer, MARKER.validator]);
});
it('costs almost nothing to import only an error helper', async () => {
const code = await bundle(`
import { flattenErrors } from ${ENTRY};
export const f = (e) => flattenErrors(e);
`);
expectShaken(code, [], [MARKER.serializer, MARKER.deserializer, MARKER.validator, MARKER.isString]);
expect(code.length).toBeLessThan(1500);
});
it('still contains everything when everything is used', async () => {
const code = await bundle(`
import * as cereale from ${ENTRY};
export default cereale;
`);
// The counterweight to every assertion above: proves the markers are findable at all, so a
// "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();
`);
expectShaken(code, [MARKER.isString], [MARKER.isLatitude, MARKER.isSemVer, MARKER.serializer]);
expect(code.length).toBeLessThan(3000);
});
/**
* The source-level case above proves the `sideEffects` mechanism works; this one proves the
* two entries are spelled the way the published tree is laid out. `./src/index.ts` and
* `./dist/esm/index.js` are separate paths in the manifest, and a typo in either is invisible
* from the other side.
*/
it.runIf(built)('installs Symbol.metadata from the published barrel too', async () => {
const code = await bundle(`
import { configure } from ${JSON.stringify(path.join(dist, 'esm/index.js'))};
export const f = (o) => configure(o);
`);
expect(code, 'the Symbol.metadata install was pruned from dist/esm').toContain('Symbol.metadata');
expectShaken(code, [], [MARKER.isString, MARKER.serializer, MARKER.deserializer]);
});
});
});