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
202 lines
8.5 KiB
TypeScript
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]);
|
|
});
|
|
|
|
});
|
|
});
|