🔊 feat!: make the three silent failures loud

Every change here answers one question: where does cereale currently fail
without saying so?

**Vite 8 / Vitest 4 drop decorators silently.** Both transform with oxc,
which does not implement the standard decorator transform and does not
report that. `vitest` prints "0 test" beside a bare SyntaxError, and
`vite build` reports success while emitting a bundle that throws on first
import. Ship the plugin that fixes it as `cereale/vite`, transforming with
esbuild and falling back to tsc — cereale depends on neither. The library's
own suite now runs through it, so it is exercised by every test.

**Legacy decorators died opaquely.** With `experimentalDecorators: true`,
still the default in most existing TypeScript projects, decorators are
invoked as (prototype, "name") and cereale raised "TypeError: Cannot convert
undefined or null to object". All decorators now resolve metadata through
one checkpoint that names the tsconfig setting instead, and reject
application to a method, getter or accessor field.

**Values JSON cannot carry were emptied.** A populated Map serialized to
{}, a Uint8Array to index-keyed noise, a bigint straight through so the
caller's own JSON.stringify threw somewhere unrelated. All now raise
JsonMappingError naming the property path and both ways out. Covers what a
@JsonSerialize serializer returns, sync or async. Circular-reference and
depth errors name the path too.

Also fixed: defineRule on a subclass with no decorators of its own wrote
the rule into its base class, because the base's metadata object is
inherited through the static prototype chain and `??=` found it non-nullish.

The README's toolchain table (tsc, esbuild, swc ✅, oxc ❌) is now executed
by a test rather than asserted, and the positioning leads with
class-validator + class-transformer, the stack cereale actually replaces,
rather than Zod, which it deliberately is not.

193 -> 249 tests.

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 09:11:32 +00:00
parent d56c47d55c
commit 0938300477
14 changed files with 1237 additions and 77 deletions
+184
View File
@@ -0,0 +1,184 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import { tmpdir } from 'node:os';
import path from 'node:path';
import ts from 'typescript';
import { transform } from 'esbuild';
import { transform as swcTransform } from '@swc/core';
import { IsString, modelOf } from './index.js';
import { standardDecorators } from './vite.js';
/**
* The support matrix in the README, executed.
*
* cereale reads `context.metadata`, which only exists if the compiler emitted TC39 standard
* decorators — so which compiler a consumer uses, and how it is configured, decides whether
* the library works at all. Claiming that in prose is not worth much; each row below actually
* compiles a decorated class with the tool in question and checks the metadata arrived.
*
* The one row that cannot run here is oxc, the transformer Vite 8 and Vitest 4 use, because it
* ships inside a native binary with no standalone transform API. Its behaviour is why
* `cereale/vite` exists, and the plugin is covered further down.
*/
const PROBE = `
const Rule = globalThis.__cerealeProbeRule;
export class Probe {
@Rule() name;
}
`;
/** Compiler options a consumer needs for cereale to work. */
const STANDARD = { experimentalDecorators: false, useDefineForClassFields: true };
/** What most existing TypeScript projects still have, because class-validator required it. */
const LEGACY = { experimentalDecorators: true, useDefineForClassFields: false };
const emit = {
tsc(source: string, options: typeof STANDARD): string {
return ts.transpileModule(source, {
compilerOptions: {
target: ts.ScriptTarget.ES2022,
module: ts.ModuleKind.ESNext,
...options,
},
}).outputText;
},
async esbuild(source: string, options: typeof STANDARD): Promise<string> {
const result = await transform(source, {
loader: 'ts',
target: 'es2022',
tsconfigRaw: { compilerOptions: options },
});
return result.code;
},
async swc(source: string, options: typeof STANDARD): Promise<string> {
const result = await swcTransform(source, {
filename: 'probe.ts',
jsc: {
parser: { syntax: 'typescript', decorators: true },
target: 'es2022',
// swc spells the choice as a proposal date rather than a boolean.
transform: { decoratorVersion: options.experimentalDecorators ? '2021-12' : '2022-03' },
},
module: { type: 'es6' },
});
return result.code;
},
};
let workspace: string;
let counter = 0;
beforeAll(async () => {
workspace = await mkdtemp(path.join(tmpdir(), 'cereale-toolchain-'));
// The emitted probe reaches the decorator through a global rather than an import, so that
// it needs no module resolution back into a package that has not been built yet.
(globalThis as Record<string, unknown>).__cerealeProbeRule = IsString;
});
afterAll(async () => {
delete (globalThis as Record<string, unknown>).__cerealeProbeRule;
await rm(workspace, { recursive: true, force: true });
});
/** Writes emitted JavaScript to disk and imports it, the way a consumer's runtime would. */
async function load(code: string): Promise<{ Probe: unknown }> {
const file = path.join(workspace, `probe-${counter++}.mjs`);
await writeFile(file, code);
return import(pathToFileURL(file).href) as Promise<{ Probe: unknown }>;
}
describe('compilers that emit standard decorators', () => {
it('tsc records the rule', async () => {
const { Probe } = await load(emit.tsc(PROBE, STANDARD));
expect(Object.keys(modelOf(Probe))).toEqual(['name']);
});
it('esbuild records the rule', async () => {
const { Probe } = await load(await emit.esbuild(PROBE, STANDARD));
expect(Object.keys(modelOf(Probe))).toEqual(['name']);
});
it('swc records the rule', async () => {
const { Probe } = await load(await emit.swc(PROBE, STANDARD));
expect(Object.keys(modelOf(Probe))).toEqual(['name']);
});
});
describe('compilers configured for legacy decorators', () => {
// Left unguarded, both of these die inside cereale with `TypeError: Cannot convert undefined
// or null to object`, which names neither the cause nor the setting that fixes it.
it('tsc emit is refused by name', async () => {
await expect(load(emit.tsc(PROBE, LEGACY))).rejects.toThrow(/experimentalDecorators/);
});
it('esbuild emit is refused by name', async () => {
await expect(load(await emit.esbuild(PROBE, LEGACY))).rejects.toThrow(/experimentalDecorators/);
});
it('swc emit is refused by name', async () => {
await expect(load(await emit.swc(PROBE, LEGACY))).rejects.toThrow(/experimentalDecorators/);
});
});
describe('cereale/vite', () => {
const plugin = (options?: Parameters<typeof standardDecorators>[0]) => standardDecorators(options);
it('lowers decorator syntax that oxc would pass through untouched', async () => {
const result = await plugin().transform(PROBE, '/app/src/model.ts');
expect(result).not.toBeNull();
expect(result!.code).not.toMatch(/@Rule\(\)/);
const { Probe } = await load(result!.code);
expect(Object.keys(modelOf(Probe))).toEqual(['name']);
});
it('produces working output through the TypeScript compiler too', async () => {
const result = await plugin({ transformer: 'typescript' }).transform(PROBE, '/app/src/model.ts');
const { Probe } = await load(result!.code);
expect(Object.keys(modelOf(Probe))).toEqual(['name']);
});
it('emits a source map', async () => {
const result = await plugin().transform(PROBE, '/app/src/model.ts');
expect(result!.map).toBeTruthy();
});
// tsc appends one pointing at a file that was never written; Vite follows it and logs a
// failure to read the map for every transformed module.
it('does not leave a sourceMappingURL comment behind', async () => {
for (const transformer of ['esbuild', 'typescript'] as const) {
const result = await plugin({ transformer }).transform(PROBE, '/app/src/model.ts');
expect(result!.code, transformer).not.toMatch(/sourceMappingURL/);
}
});
for (const id of ['/app/src/model.ts', '/app/src/model.mts', '/app/src/model.cts', '/app/src/model.ts?v=123']) {
it(`transforms ${id}`, async () => {
expect(await plugin().transform(PROBE, id)).not.toBeNull();
});
}
for (const id of ['/app/node_modules/dep/model.ts', '/app/src/model.js', '/app/src/model.tsx', '/app/src/style.css']) {
it(`leaves ${id} alone`, async () => {
expect(await plugin().transform(PROBE, id)).toBeNull();
});
}
it('honours a caller-supplied include', async () => {
const onlyModels = plugin({ include: id => id.includes('/models/') });
expect(await onlyModels.transform(PROBE, '/app/src/models/user.ts')).not.toBeNull();
expect(await onlyModels.transform(PROBE, '/app/src/routes/user.ts')).toBeNull();
});
it('runs before Vite’s own transform', () => {
expect(plugin().enforce).toBe('pre');
});
it('rejects a target the compiler does not know', async () => {
const bad = plugin({ transformer: 'typescript', target: 'es1999' });
await expect(bad.transform(PROBE, '/app/src/model.ts')).rejects.toThrow(/es1999/);
});
});