Files
cereale/scripts/diagnostics.mjs
Claude c828f5cfe9 🌾 feat: rebuild the landing page, and stop it from rotting again
The old page had been quietly broken for some time. It loaded
@babel/standalone from an **unpinned** CDN URL, which rolled over to Babel
8 and dropped the `proposal-class-properties` plugin the page asked for, so
Babel.transform threw before it ever reached the decorators — and the
decorator config it passed was `{ legacy: true }`, which 0.2.0 had already
made wrong. Nothing on the page said so. The copy was still selling the
0.1.0 pitch ("Spring-like"), listed about half the decorators, showed
`npm install cereale` for a package the registry returns 404 for, and
claimed "Zero overhead" against a README that publishes the real
microsecond costs.

The rebuild is one self-contained page: hand-written CSS, no Tailwind CDN,
no CodeMirror, and a vendored compiler pinned by package.json. It loads
nothing from the network. The playground runs the real bundled library
across six examples, all verified in a headless browser. The reference
covers all 68 decorators and the full API, counted from the bundle at
runtime so it cannot drift.

The hero's compiler error is not typed into the HTML. scripts/build-docs.mjs
compiles the snippets with the real tsc and writes the verbatim diagnostics
into docs/diagnostics.js, failing the build if a snippet the page calls a
compile error ever compiles — and two snippets that must compile guard
against the harness passing vacuously.

Three guards keep it honest, all wired into CI:
- check:docs fails on any remote subresource
- build:docs + git diff fails if docs/ is stale against src/
- check:types compiles a consumer against dist/ with no DOM lib, no
  @types/node and no skipLibCheck

That last one found a real packaging defect: `fromRequest` was declared as
taking the global `Request`, so cereale's own published .d.ts raised
"Cannot find name 'Request'" in any project whose lib and types did not
happen to supply it — inside a dependency, in code they may never call, and
unfixable from the outside. It now takes a structural JsonBody, which a
Request still satisfies. The library's own type tests had been hiding it by
enabling both DOM and skipLibCheck.

An adversarial review of the finished page caught four more: the lede
claimed *every* rule is type-checked (@IsDefined and @IsNotIn deliberately
are not), the guarantee section was wrong about the mechanism (a legacy
decorator does get design:type under emitDecoratorMetadata — the real claim
is about its type signature), one sample called a Movie method on a Media[]
and did not compile, and "nested objects come back as real classes" omitted
that you have to declare them. WCAG contrast was measured rather than
eyeballed: seven real failures fixed in the two themes.

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

211 lines
6.8 KiB
JavaScript

/**
* Runs the real TypeScript compiler over the snippets the landing page quotes, and writes
* the verbatim diagnostics into docs/diagnostics.js.
*
* The page's central claim is that a rule which does not fit its field does not compile. The
* honest way to show that is not to type a plausible-looking error into the HTML — it is to
* compile the snippet and print whatever the compiler said. If a snippet marked `rejected`
* ever starts compiling, or a snippet marked `compiles` stops, the build fails here rather
* than the page quietly going on claiming something that is no longer true.
*/
import ts from 'typescript';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
/** Each case is compiled against the real library, not a stub. */
export const CASES = [
{
id: 'hero',
expect: 'rejected',
// Kept character-for-character in step with the hero block in docs/index.html.
source: `import { JsonProperty, IsString, Matches, IsInt, Min, fromJsonSync } from '../src/index.js';
declare const body: string;
class Order {
@JsonProperty('order_ref')
@IsString() @Matches(/^[A-Z]-\\d+$/)
ref!: string;
@IsInt() @Min(1)
quantity!: number;
@IsString()
placedAt!: Date;
total(): number { return this.quantity * 9.99; }
}
const order = fromJsonSync(Order, body);
order.total();
`,
},
{
id: 'compare',
expect: 'rejected',
source: `import { IsString } from '../src/index.js';
export class User {
@IsString()
age!: number;
}
`,
},
{
id: 'instances',
expect: 'compiles',
// The "What you get back" section. It is here because an earlier draft showed
// `list.items[0].hours()` on a `Media[]`, which tsc rejects with TS2339 — TypeScript that
// the compiler refuses, on a page whose whole argument is that the compiler is the authority.
source: `import { IsString, IsInt, Min, JsonPolymorphic, ValidateNested, fromJsonSync } from '../src/index.js';
declare const body: string;
class Media {
@IsString() title!: string;
}
class Movie extends Media {
@IsInt() @Min(1) duration!: number;
hours() { return this.duration / 60; }
}
class Song extends Media {
@IsString() artist!: string;
}
class Playlist {
@JsonPolymorphic<Media>('type', [
{ value: Movie, name: 'movie' },
{ value: Song, name: 'song' },
])
@ValidateNested({ each: true })
items!: Media[];
}
const list = fromJsonSync(Playlist, body);
const first = list.items[0];
first instanceof Movie;
if (first instanceof Movie) {
first.hours();
}
`,
},
{
id: 'correct',
expect: 'compiles',
// The same class with the rule that actually fits, so a broken harness cannot make the
// two cases above "pass" by failing everything.
source: `import { JsonProperty, IsString, Matches, IsInt, Min, IsDate, fromJsonSync } from '../src/index.js';
declare const body: string;
class Order {
@JsonProperty('order_ref')
@IsString() @Matches(/^[A-Z]-\\d+$/)
ref!: string;
@IsInt() @Min(1)
quantity!: number;
@IsDate()
placedAt!: Date;
total(): number { return this.quantity * 9.99; }
}
const order = fromJsonSync(Order, body);
order.total();
`,
},
];
/** Compiles every case in one program and returns its diagnostics, keyed by case id. */
export async function collectDiagnostics(root) {
const dir = await mkdtemp(path.join(tmpdir(), 'cereale-diagnostics-'));
try {
const files = new Map();
for (const testCase of CASES) {
const file = path.join(dir, `${testCase.id}.ts`);
// The snippets import '../src/index.js' relative to a sibling of src/, so they are
// written one directory below the repo root.
const target = path.join(root, '.diagnostics', `${testCase.id}.ts`);
files.set(testCase.id, target);
await writeFile(file, testCase.source);
}
// Write into the repo so that '../src/index.js' resolves the way it does for a consumer.
const scratch = path.join(root, '.diagnostics');
await rm(scratch, { recursive: true, force: true });
const { mkdir } = await import('node:fs/promises');
await mkdir(scratch, { recursive: true });
for (const testCase of CASES) {
await writeFile(files.get(testCase.id), testCase.source);
}
const configPath = path.join(root, 'tsconfig.json');
const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, root);
const program = ts.createProgram([...files.values()], {
...parsed.options,
noEmit: true,
rootDir: root,
outDir: undefined,
declaration: false,
declarationMap: false,
sourceMap: false,
});
const all = [...program.getSemanticDiagnostics(), ...program.getSyntacticDiagnostics()];
const byCase = {};
const problems = [];
for (const testCase of CASES) {
const file = files.get(testCase.id);
const mine = all.filter((d) => d.file && path.resolve(d.file.fileName) === path.resolve(file));
if (testCase.expect === 'rejected' && mine.length === 0) {
problems.push(`case "${testCase.id}" was expected to be rejected by tsc, but it compiled. ` +
'The landing page claims this is a compile error — either the claim or the library is wrong.');
}
if (testCase.expect === 'compiles' && mine.length > 0) {
problems.push(`case "${testCase.id}" was expected to compile, but tsc reported: ` +
ts.flattenDiagnosticMessageText(mine[0].messageText, ' '));
}
byCase[testCase.id] = mine.map((diagnostic) => {
const { line } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start ?? 0);
return {
code: diagnostic.code,
// The chain, flattened one message per level, so the page can show the headline
// and the "Type X is not assignable to type Y" leaf without inventing either.
messages: flattenChain(diagnostic.messageText),
line: line + 1,
};
});
}
// Diagnostics anywhere else mean the harness itself is broken.
const stray = all.filter((d) => !d.file || ![...files.values()].some((f) => path.resolve(d.file.fileName) === path.resolve(f)));
if (stray.length > 0) {
problems.push(`the diagnostics harness produced ${stray.length} error(s) outside the cases, ` +
`starting with: ${ts.flattenDiagnosticMessageText(stray[0].messageText, ' ')}`);
}
await rm(scratch, { recursive: true, force: true });
return { byCase, problems };
} finally {
await rm(dir, { recursive: true, force: true });
}
}
function flattenChain(messageText) {
if (typeof messageText === 'string') return [messageText];
const out = [];
let node = messageText;
while (node) {
out.push(node.messageText);
node = node.next && node.next[0];
}
return out;
}